mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7272e0bbfd | |||
| c7eaf3aa79 | |||
| deef5e6b81 | |||
| 6d72006b28 | |||
| 26c1676cdd | |||
| 4ddfa92c14 | |||
| 19c9e17884 | |||
| 14ef2d4a4a | |||
| de859318fa | |||
| bcbb516448 | |||
| 71870e4567 | |||
| 9819473157 | |||
| eb7984f40d | |||
| 9caa0acc24 | |||
| 8ddfa8fde0 | |||
| 41d4b2a8be | |||
| 10ebf46a98 | |||
| 70809d6c27 | |||
| a314ba2b80 | |||
| d8f03f6bea | |||
| 533d6f84d8 | |||
| 095cb1b9d1 | |||
| 0a0205fcf9 | |||
| 9aed5ff2ed | |||
| d189d6d776 | |||
| 262905e357 | |||
| 4a4643f33f | |||
| a6a7edf0b2 | |||
| 949d0967d2 | |||
| cd634093af | |||
| 7201380504 | |||
| 1166a09835 | |||
| 6f2d7c8f5e | |||
| e6c4c22a1d | |||
| 9a044ada28 | |||
| da5e77f78d | |||
| cc8be328f9 | |||
| f1c4155d81 | |||
| d4899a8dee | |||
| a973a1b4f8 | |||
| 73b0534053 | |||
| 931c5bd990 | |||
| ee54308819 | |||
| 66b00c24e2 | |||
| f6d08582ec | |||
| 8d9a511edf | |||
| 3059d53d11 | |||
| 3074724f2f | |||
| 21ed7ea4a2 | |||
| 267271d97a | |||
| 874c1292c7 | |||
| a9948499e4 | |||
| 90301e62ce | |||
| 377422a9d5 | |||
| d90a059dfa | |||
| 1e20f024d5 | |||
| 9a81baa809 | |||
| 11b85a2d70 | |||
| d04629605e | |||
| 187989cc1d | |||
| 6444b2b4ce | |||
| 42ebc7c298 | |||
| 8bca921b30 | |||
| 12f8b6eb55 | |||
| 202cfb6a63 | |||
| b6f9664ec2 | |||
| 9f8075171d | |||
| 02b907e764 | |||
| e05e021f41 | |||
| 615c6bae58 | |||
| 62fbc26811 | |||
| 2171203a4c | |||
| b28b483b90 | |||
| 020cafade1 | |||
| e4b2262d4d | |||
| d2efd960b5 | |||
| c51a27371b | |||
| 252d2d22a8 | |||
| 80c2486570 | |||
| 7dcd89fb71 | |||
| 8458481950 | |||
| 808b7f7a72 | |||
| f4ee7b868d | |||
| e99960c3b6 | |||
| c39d242cfb | |||
| 2f8a189319 | |||
| 44138af11a | |||
| bc6c59f358 | |||
| 54804d0e5f | |||
| 631e47944b | |||
| 3abcc0ec76 | |||
| 530f233b7d | |||
| fbb3bb862c | |||
| 3c3b7b9136 | |||
| 99514ddce1 | |||
| b0ffb63d67 | |||
| d909aac751 | |||
| e91b79ebfc | |||
| 2d7babcba3 | |||
| e56ea068ef | |||
| a091051387 | |||
| df3e62af5c | |||
| 399e4acf03 | |||
| e0fd9830d9 | |||
| 7a445583d7 | |||
| 1d9d628e2d | |||
| 005c08dcea | |||
| e25fec4e4a | |||
| 85e69b8a3d | |||
| 1d57eacfa4 | |||
| ecf7433980 | |||
| 433d780f74 | |||
| 27f8856e9b | |||
| f2c90ee0f4 | |||
| 83d256ebac | |||
| 3c4f5f7193 | |||
| 31124a604a | |||
| 0d9dbb6286 | |||
| 66ae577b7b | |||
| 706548c45d | |||
| aa32df5ee1 | |||
| 1f9ae8e4b5 | |||
| d69585a820 | |||
| 723f8a1c3d | |||
| 678fe2d12c | |||
| e97ecd558f | |||
| 3d33191925 | |||
| 48e1b732d8 | |||
| d50c84b755 | |||
| fcbfeb6793 | |||
| 77f2c616de | |||
| 9f8d3f8d99 | |||
| 3f26a68f64 | |||
| a3b6a89471 | |||
| ee54d89144 | |||
| b6d927a3d6 |
@@ -35,7 +35,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
outputs: type=image,name=mauriceboe/nomad,push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
||||||
no-cache: true
|
no-cache: true
|
||||||
|
|
||||||
- name: Export digest
|
- name: Export digest
|
||||||
@@ -56,6 +56,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: build
|
||||||
steps:
|
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
|
- name: Download build digests
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -73,8 +79,13 @@ jobs:
|
|||||||
- name: Create and push multi-arch manifest
|
- name: Create and push multi-arch manifest
|
||||||
working-directory: /tmp/digests
|
working-directory: /tmp/digests
|
||||||
run: |
|
run: |
|
||||||
mapfile -t digests < <(printf 'mauriceboe/nomad@sha256:%s\n' *)
|
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||||
docker buildx imagetools create -t mauriceboe/nomad:latest "${digests[@]}"
|
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
|
- name: Inspect manifest
|
||||||
run: docker buildx imagetools inspect mauriceboe/nomad:latest
|
run: docker buildx imagetools inspect mauriceboe/trek:latest
|
||||||
|
|||||||
+5
-2
@@ -11,9 +11,9 @@ FROM node:22-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Server-Dependencies installieren (better-sqlite3 braucht Build-Tools)
|
# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools)
|
||||||
COPY server/package*.json ./
|
COPY server/package*.json ./
|
||||||
RUN apk add --no-cache python3 make g++ && \
|
RUN apk add --no-cache tzdata python3 make g++ && \
|
||||||
npm ci --production && \
|
npm ci --production && \
|
||||||
apk del python3 make g++
|
apk del python3 make g++
|
||||||
|
|
||||||
@@ -30,6 +30,9 @@ COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
|||||||
RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data
|
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data
|
||||||
|
|
||||||
|
RUN chown -R node:node /app
|
||||||
|
USER node
|
||||||
|
|
||||||
# Umgebung setzen
|
# Umgebung setzen
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -2,27 +2,27 @@
|
|||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
|
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.svg" />
|
<source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.svg" />
|
||||||
<img src="client/public/logo-light.svg" alt="NOMAD" height="60" />
|
<img src="client/public/logo-light.svg" alt="TREK" height="60" />
|
||||||
</picture>
|
</picture>
|
||||||
<br />
|
<br />
|
||||||
<em>Navigation Organizer for Maps, Activities & Destinations</em>
|
<em>Your Trips. Your Plan.</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
|
||||||
<a href="https://hub.docker.com/r/mauriceboe/nomad"><img src="https://img.shields.io/docker/pulls/mauriceboe/nomad" alt="Docker Pulls" /></a>
|
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
|
||||||
<a href="https://github.com/mauriceboe/NOMAD"><img src="https://img.shields.io/github/stars/mauriceboe/NOMAD" alt="GitHub Stars" /></a>
|
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
|
||||||
<a href="https://github.com/mauriceboe/NOMAD/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/NOMAD" alt="Last Commit" /></a>
|
<a href="https://github.com/mauriceboe/TREK/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/TREK" alt="Last Commit" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
|
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
|
||||||
<br />
|
<br />
|
||||||
<strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try NOMAD without installing. Resets hourly.
|
<strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try TREK without installing. Resets hourly.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>More Screenshots</summary>
|
<summary>More Screenshots</summary>
|
||||||
@@ -44,13 +44,16 @@
|
|||||||
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
|
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
|
||||||
- **Route Optimization** — Auto-optimize place order and export to Google Maps
|
- **Route Optimization** — Auto-optimize place order and export to Google Maps
|
||||||
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
|
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
|
||||||
|
- **Map Category Filter** — Filter places by category and see only matching pins on the map
|
||||||
|
|
||||||
### Travel Management
|
### Travel Management
|
||||||
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
|
- **Reservations & Bookings** — Track flights, accommodations, restaurants with status, confirmation numbers, and file attachments
|
||||||
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
|
- **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
|
- **Packing Lists** — Category-based checklists with user assignment, packing templates, and progress tracking
|
||||||
|
- **Packing Templates** — Create reusable packing templates in the admin panel with categories and items, apply to any trip
|
||||||
|
- **Bag Tracking** — Optional weight tracking and bag assignment for packing items with iOS-style weight distribution (admin-toggleable)
|
||||||
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
|
- **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
|
### Mobile & PWA
|
||||||
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
|
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
|
||||||
@@ -61,19 +64,22 @@
|
|||||||
### Collaboration
|
### Collaboration
|
||||||
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
|
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
|
||||||
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
|
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
|
||||||
|
- **Invite Links** — Create one-time registration links with configurable max uses and expiry for easy onboarding
|
||||||
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
|
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||||
|
- **Two-Factor Authentication (MFA)** — TOTP-based 2FA with QR code setup, works with Google Authenticator, Authy, etc.
|
||||||
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
||||||
|
|
||||||
### Addons (modular, admin-toggleable)
|
### Addons (modular, admin-toggleable)
|
||||||
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking
|
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking
|
||||||
- **Atlas** — Interactive world map with visited countries, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
|
- **Atlas** — Interactive world map with visited countries, bucket list with planned travel dates, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
|
||||||
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
||||||
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
|
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
|
||||||
|
|
||||||
### Customization & Admin
|
### Customization & Admin
|
||||||
|
- **Dashboard Views** — Toggle between card grid and compact list view on the My Trips page
|
||||||
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
||||||
- **Multilingual** — English and German (i18n)
|
- **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Arabic (with RTL support)
|
||||||
- **Admin Panel** — User management, global categories, addon management, API keys, backups, and GitHub release history
|
- **Admin Panel** — User management, invite links, packing templates, global categories, addon management, API keys, backups, and GitHub release history
|
||||||
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
||||||
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
||||||
|
|
||||||
@@ -84,7 +90,7 @@
|
|||||||
- **PWA**: vite-plugin-pwa + Workbox
|
- **PWA**: vite-plugin-pwa + Workbox
|
||||||
- **Real-Time**: WebSocket (`ws`)
|
- **Real-Time**: WebSocket (`ws`)
|
||||||
- **State**: Zustand
|
- **State**: Zustand
|
||||||
- **Auth**: JWT + OIDC
|
- **Auth**: JWT + OIDC + TOTP (MFA)
|
||||||
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
||||||
- **Weather**: Open-Meteo API (free, no key required)
|
- **Weather**: Open-Meteo API (free, no key required)
|
||||||
- **Icons**: lucide-react
|
- **Icons**: lucide-react
|
||||||
@@ -92,19 +98,19 @@
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```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.
|
The app runs on port `3000`. The first user to register becomes the admin.
|
||||||
|
|
||||||
### Install as App (PWA)
|
### 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"
|
2. **iOS**: Share button → "Add to Home Screen"
|
||||||
3. **Android**: Menu → "Install app" or "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
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Docker Compose (recommended for production)</summary>
|
<summary>Docker Compose (recommended for production)</summary>
|
||||||
@@ -112,13 +118,18 @@ NOMAD works as a Progressive Web App — no App Store needed:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: mauriceboe/nomad:latest
|
image: mauriceboe/trek:latest
|
||||||
container_name: nomad
|
container_name: trek
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
|
# - OIDC_ISSUER=https://auth.example.com
|
||||||
|
# - OIDC_CLIENT_ID=trek
|
||||||
|
# - OIDC_CLIENT_SECRET=supersecret
|
||||||
|
# - OIDC_DISPLAY_NAME="SSO"
|
||||||
|
# - OIDC_ONLY=true # disable password auth entirely
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
@@ -142,20 +153,20 @@ docker compose pull && docker compose up -d
|
|||||||
**Docker Run** — use the same volume paths from your original `docker run` command:
|
**Docker Run** — use the same volume paths from your original `docker run` command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull mauriceboe/nomad
|
docker pull mauriceboe/trek
|
||||||
docker rm -f nomad
|
docker rm -f trek
|
||||||
docker run -d --name nomad -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/nomad
|
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.
|
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
|
||||||
|
|
||||||
### Reverse Proxy (recommended)
|
### 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.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Nginx</summary>
|
<summary>Nginx</summary>
|
||||||
@@ -163,13 +174,13 @@ For production, put NOMAD behind a reverse proxy with HTTPS (e.g. Nginx, Caddy,
|
|||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name nomad.yourdomain.com;
|
server_name trek.yourdomain.com;
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name nomad.yourdomain.com;
|
server_name trek.yourdomain.com;
|
||||||
|
|
||||||
ssl_certificate /path/to/fullchain.pem;
|
ssl_certificate /path/to/fullchain.pem;
|
||||||
ssl_certificate_key /path/to/privkey.pem;
|
ssl_certificate_key /path/to/privkey.pem;
|
||||||
@@ -204,13 +215,29 @@ server {
|
|||||||
Caddy handles WebSocket upgrades automatically:
|
Caddy handles WebSocket upgrades automatically:
|
||||||
|
|
||||||
```
|
```
|
||||||
nomad.yourdomain.com {
|
trek.yourdomain.com {
|
||||||
reverse_proxy localhost:3000
|
reverse_proxy localhost:3000
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `PORT` | Server port | `3000` |
|
||||||
|
| `NODE_ENV` | Environment | `production` |
|
||||||
|
| `JWT_SECRET` | JWT signing secret | Auto-generated |
|
||||||
|
| `FORCE_HTTPS` | Redirect HTTP to HTTPS | `false` |
|
||||||
|
| `OIDC_ISSUER` | OIDC provider URL | — |
|
||||||
|
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||||
|
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
|
||||||
|
| `OIDC_DISPLAY_NAME` | SSO button label | `SSO` |
|
||||||
|
| `OIDC_ONLY` | Disable password auth | `false` |
|
||||||
|
| `TRUST_PROXY` | Trust proxy headers | `1` |
|
||||||
|
| `DEMO_MODE` | Enable demo mode | `false` |
|
||||||
|
|
||||||
## Optional API Keys
|
## Optional API Keys
|
||||||
|
|
||||||
API keys are configured in the **Admin Panel** after login. Keys set by the admin are automatically shared with all users — no per-user configuration needed.
|
API keys are configured in the **Admin Panel** after login. Keys set by the admin are automatically shared with all users — no per-user configuration needed.
|
||||||
@@ -220,14 +247,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/)
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
2. Create a project and enable the **Places API (New)**
|
2. Create a project and enable the **Places API (New)**
|
||||||
3. Create an API key under Credentials
|
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
|
## Building from Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/mauriceboe/NOMAD.git
|
git clone https://github.com/mauriceboe/TREK.git
|
||||||
cd NOMAD
|
cd TREK
|
||||||
docker build -t nomad .
|
docker build -t trek .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data & Backups
|
## Data & Backups
|
||||||
|
|||||||
+1
-1
@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
|
|||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This policy covers the NOMAD application and its Docker image (`mauriceboe/nomad`).
|
This policy covers the TREK application and its Docker image (`mauriceboe/trek`).
|
||||||
|
|
||||||
Third-party dependencies are monitored via GitHub Dependabot.
|
Third-party dependencies are monitored via GitHub Dependabot.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: trek
|
||||||
|
version: 0.1.0
|
||||||
|
description: Minimal Helm chart for TREK app
|
||||||
|
appVersion: "latest"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# TREK Helm Chart
|
||||||
|
|
||||||
|
This is a minimal Helm chart for deploying the TREK app.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Deploys the TREK container
|
||||||
|
- Exposes port 3000 via Service
|
||||||
|
- Optional persistent storage for `/app/data` and `/app/uploads`
|
||||||
|
- Configurable environment variables and secrets
|
||||||
|
- Optional generic Ingress support
|
||||||
|
- Health checks on `/api/health`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
helm install trek ./chart \
|
||||||
|
--set secretEnv.JWT_SECRET=your_jwt_secret \
|
||||||
|
--set ingress.enabled=true \
|
||||||
|
--set ingress.hosts[0].host=yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
See `values.yaml` for more options.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `Chart.yaml` — chart metadata
|
||||||
|
- `values.yaml` — configuration values
|
||||||
|
- `templates/` — Kubernetes manifests
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||||
|
- PVCs require a default StorageClass or specify one as needed.
|
||||||
|
- JWT_SECRET must be set for production use.
|
||||||
|
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
1. JWT_SECRET handling:
|
||||||
|
- By default, the chart creates a secret with the value from `values.yaml: secretEnv.JWT_SECRET`.
|
||||||
|
- To generate a random JWT_SECRET at install, set `generateJwtSecret: true`.
|
||||||
|
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must have a key matching `existingSecretKey` (defaults to `JWT_SECRET`).
|
||||||
|
|
||||||
|
2. Example usage:
|
||||||
|
- Set a custom secret: `--set secretEnv.JWT_SECRET=your_secret`
|
||||||
|
- Generate a random secret: `--set generateJwtSecret=true`
|
||||||
|
- Use an existing secret: `--set existingSecret=my-k8s-secret`
|
||||||
|
- Use a custom key in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_KEY`
|
||||||
|
|
||||||
|
3. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence.
|
||||||
|
If using `existingSecret`, ensure the referenced secret and key exist in the target namespace.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{{/*
|
||||||
|
Expand the name of the chart.
|
||||||
|
*/}}
|
||||||
|
{{- define "trek.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create a default fully qualified app name.
|
||||||
|
*/}}
|
||||||
|
{{- define "trek.fullname" -}}
|
||||||
|
{{- if .Values.fullnameOverride -}}
|
||||||
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||||
|
{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}-config
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
data:
|
||||||
|
NODE_ENV: {{ .Values.env.NODE_ENV | quote }}
|
||||||
|
PORT: {{ .Values.env.PORT | quote }}
|
||||||
|
{{- if .Values.env.ALLOWED_ORIGINS }}
|
||||||
|
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
spec:
|
||||||
|
{{- if .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- range .Values.imagePullSecrets }}
|
||||||
|
- name: {{ .name }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: trek
|
||||||
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: {{ include "trek.fullname" . }}-config
|
||||||
|
env:
|
||||||
|
- name: JWT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||||
|
key: {{ .Values.existingSecretKey | default "JWT_SECRET" }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /app/data
|
||||||
|
- name: uploads
|
||||||
|
mountPath: /app/uploads
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ include "trek.fullname" . }}-data
|
||||||
|
- name: uploads
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ include "trek.fullname" . }}-uploads
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- if .Values.ingress.tls }}
|
||||||
|
tls:
|
||||||
|
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
rules:
|
||||||
|
{{- range .Values.ingress.hosts }}
|
||||||
|
- host: {{ .host }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
{{- range .paths }}
|
||||||
|
- path: {{ . }}
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ include "trek.fullname" $ }}
|
||||||
|
port:
|
||||||
|
number: {{ $.Values.service.port }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}-data
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.persistence.data.size }}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}-uploads
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.persistence.uploads.size }}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{{- if and (not .Values.existingSecret) (not .Values.generateJwtSecret) }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}-secret
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- if and (not .Values.existingSecret) (.Values.generateJwtSecret) }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}-secret
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.service.port }}
|
||||||
|
targetPort: 3000
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
|
||||||
|
image:
|
||||||
|
repository: mauriceboe/trek
|
||||||
|
tag: latest
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
# Optional image pull secrets for private registries
|
||||||
|
imagePullSecrets: []
|
||||||
|
# - name: my-registry-secret
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 3000
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3000
|
||||||
|
# ALLOWED_ORIGINS: ""
|
||||||
|
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
||||||
|
|
||||||
|
|
||||||
|
# JWT secret configuration
|
||||||
|
secretEnv:
|
||||||
|
# If set, use this value for JWT_SECRET (base64-encoded in secret.yaml)
|
||||||
|
JWT_SECRET: ""
|
||||||
|
|
||||||
|
# If true, a random JWT_SECRET will be generated during install (overrides secretEnv.JWT_SECRET)
|
||||||
|
generateJwtSecret: false
|
||||||
|
|
||||||
|
# If set, use an existing Kubernetes secret for JWT_SECRET
|
||||||
|
existingSecret: ""
|
||||||
|
existingSecretKey: JWT_SECRET
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
data:
|
||||||
|
size: 1Gi
|
||||||
|
uploads:
|
||||||
|
size: 1Gi
|
||||||
|
|
||||||
|
resources: {}
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
annotations: {}
|
||||||
|
hosts:
|
||||||
|
- host: chart-example.local
|
||||||
|
paths:
|
||||||
|
- /
|
||||||
|
tls: []
|
||||||
|
# - secretName: chart-example-tls
|
||||||
|
# hosts:
|
||||||
|
# - chart-example.local
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.6.1",
|
"version": "2.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.6.1",
|
"version": "2.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.6.2",
|
"version": "2.7.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+26
-3
@@ -11,6 +11,7 @@ import AdminPage from './pages/AdminPage'
|
|||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import VacayPage from './pages/VacayPage'
|
import VacayPage from './pages/VacayPage'
|
||||||
import AtlasPage from './pages/AtlasPage'
|
import AtlasPage from './pages/AtlasPage'
|
||||||
|
import SharedTripPage from './pages/SharedTripPage'
|
||||||
import { ToastContainer } from './components/shared/Toast'
|
import { ToastContainer } from './components/shared/Toast'
|
||||||
import { TranslationProvider, useTranslation } from './i18n'
|
import { TranslationProvider, useTranslation } from './i18n'
|
||||||
import DemoBanner from './components/Layout/DemoBanner'
|
import DemoBanner from './components/Layout/DemoBanner'
|
||||||
@@ -62,16 +63,37 @@ function RootRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey } = useAuthStore()
|
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone } = useAuthStore()
|
||||||
const { loadSettings } = useSettingsStore()
|
const { loadSettings } = useSettingsStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
loadUser()
|
loadUser()
|
||||||
}
|
}
|
||||||
authApi.getAppConfig().then((config: { demo_mode?: boolean; has_maps_key?: boolean }) => {
|
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string }) => {
|
||||||
if (config?.demo_mode) setDemoMode(true)
|
if (config?.demo_mode) setDemoMode(true)
|
||||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||||
|
if (config?.timezone) setServerTimezone(config.timezone)
|
||||||
|
|
||||||
|
if (config?.version) {
|
||||||
|
const storedVersion = localStorage.getItem('trek_app_version')
|
||||||
|
if (storedVersion && storedVersion !== config.version) {
|
||||||
|
try {
|
||||||
|
if ('caches' in window) {
|
||||||
|
const names = await caches.keys()
|
||||||
|
await Promise.all(names.map(n => caches.delete(n)))
|
||||||
|
}
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const regs = await navigator.serviceWorker.getRegistrations()
|
||||||
|
await Promise.all(regs.map(r => r.unregister()))
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
localStorage.setItem('trek_app_version', config.version)
|
||||||
|
window.location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
localStorage.setItem('trek_app_version', config.version)
|
||||||
|
}
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -107,7 +129,8 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<RootRedirect />} />
|
<Route path="/" element={<RootRedirect />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<Navigate to="/login" replace />} />
|
<Route path="/shared/:token" element={<SharedTripPage />} />
|
||||||
|
<Route path="/register" element={<LoginPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -39,8 +39,13 @@ apiClient.interceptors.response.use(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
register: (data: { username: string; email: string; password: string }) => apiClient.post('/auth/register', data).then(r => r.data),
|
register: (data: { username: string; email: string; password: string; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data),
|
||||||
|
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
|
||||||
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
|
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
|
||||||
|
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
||||||
|
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
|
||||||
|
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data),
|
||||||
|
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
|
||||||
me: () => apiClient.get('/auth/me').then(r => r.data),
|
me: () => apiClient.get('/auth/me').then(r => r.data),
|
||||||
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
||||||
updateApiKeys: (data: Record<string, string | null>) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
|
updateApiKeys: (data: Record<string, string | null>) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
|
||||||
@@ -86,6 +91,10 @@ export const placesApi = {
|
|||||||
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||||
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||||
|
importGpx: (tripId: number | string, file: File) => {
|
||||||
|
const fd = new FormData(); fd.append('file', file)
|
||||||
|
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const assignmentsApi = {
|
export const assignmentsApi = {
|
||||||
@@ -103,9 +112,17 @@ export const assignmentsApi = {
|
|||||||
export const packingApi = {
|
export const packingApi = {
|
||||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
|
||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
||||||
|
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
||||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
|
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
|
||||||
|
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
||||||
|
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
|
||||||
|
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
||||||
|
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
|
||||||
|
createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
|
||||||
|
updateBag: (tripId: number | string, bagId: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
|
||||||
|
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tagsApi = {
|
export const tagsApi = {
|
||||||
@@ -135,6 +152,24 @@ export const adminApi = {
|
|||||||
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||||
installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data),
|
installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data),
|
||||||
|
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||||
|
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||||
|
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||||
|
getPackingTemplate: (id: number) => apiClient.get(`/admin/packing-templates/${id}`).then(r => r.data),
|
||||||
|
createPackingTemplate: (data: { name: string }) => apiClient.post('/admin/packing-templates', data).then(r => r.data),
|
||||||
|
updatePackingTemplate: (id: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${id}`, data).then(r => r.data),
|
||||||
|
deletePackingTemplate: (id: number) => apiClient.delete(`/admin/packing-templates/${id}`).then(r => r.data),
|
||||||
|
addTemplateCategory: (templateId: number, data: { name: string }) => apiClient.post(`/admin/packing-templates/${templateId}/categories`, data).then(r => r.data),
|
||||||
|
updateTemplateCategory: (templateId: number, catId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/categories/${catId}`, data).then(r => r.data),
|
||||||
|
deleteTemplateCategory: (templateId: number, catId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/categories/${catId}`).then(r => r.data),
|
||||||
|
addTemplateItem: (templateId: number, catId: number, data: { name: string }) => apiClient.post(`/admin/packing-templates/${templateId}/categories/${catId}/items`, data).then(r => r.data),
|
||||||
|
updateTemplateItem: (templateId: number, itemId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/items/${itemId}`, data).then(r => r.data),
|
||||||
|
deleteTemplateItem: (templateId: number, itemId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/items/${itemId}`).then(r => r.data),
|
||||||
|
listInvites: () => apiClient.get('/admin/invites').then(r => r.data),
|
||||||
|
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
||||||
|
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
||||||
|
auditLog: (params?: { limit?: number; offset?: number }) =>
|
||||||
|
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addonsApi = {
|
export const addonsApi = {
|
||||||
@@ -146,6 +181,7 @@ export const mapsApi = {
|
|||||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).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),
|
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),
|
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
||||||
|
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const budgetApi = {
|
export const budgetApi = {
|
||||||
@@ -156,6 +192,7 @@ export const budgetApi = {
|
|||||||
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
|
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
|
||||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
|
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
|
||||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||||
|
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const filesApi = {
|
export const filesApi = {
|
||||||
@@ -169,6 +206,9 @@ export const filesApi = {
|
|||||||
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).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),
|
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),
|
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
|
||||||
|
addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
|
||||||
|
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
|
||||||
|
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const reservationsApi = {
|
export const reservationsApi = {
|
||||||
@@ -176,6 +216,7 @@ export const reservationsApi = {
|
|||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||||
|
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[]) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const weatherApi = {
|
export const weatherApi = {
|
||||||
@@ -250,4 +291,17 @@ export const backupApi = {
|
|||||||
setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
|
setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const shareApi = {
|
||||||
|
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/share-link`).then(r => r.data),
|
||||||
|
createLink: (tripId: number | string, perms?: Record<string, boolean>) => apiClient.post(`/trips/${tripId}/share-link`, perms || {}).then(r => r.data),
|
||||||
|
deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/share-link`).then(r => r.data),
|
||||||
|
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationsApi = {
|
||||||
|
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||||
|
updatePreferences: (prefs: Record<string, boolean>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||||
|
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
export default apiClient
|
export default apiClient
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { adminApi } from '../../api/client'
|
|||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase } from 'lucide-react'
|
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image } from 'lucide-react'
|
||||||
|
|
||||||
const ICON_MAP = {
|
const ICON_MAP = {
|
||||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase,
|
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Addon {
|
interface Addon {
|
||||||
@@ -27,7 +27,7 @@ function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
|||||||
return <Icon size={size} />
|
return <Icon size={size} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddonManager() {
|
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
@@ -104,7 +104,28 @@ export default function AddonManager() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{tripAddons.map(addon => (
|
{tripAddons.map(addon => (
|
||||||
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
<div key={addon.id}>
|
||||||
|
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||||
|
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||||
|
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('admin.bagTracking.title')}</div>
|
||||||
|
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="hidden sm:inline text-xs font-medium" style={{ color: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||||
|
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||||
|
</span>
|
||||||
|
<button onClick={onToggleBagTracking}
|
||||||
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||||
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: bagTrackingEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -136,8 +157,21 @@ interface AddonRowProps {
|
|||||||
t: (key: string) => string
|
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) {
|
function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||||
const isComingSoon = false
|
const isComingSoon = false
|
||||||
|
const label = getAddonLabel(t, addon)
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
@@ -148,7 +182,7 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
|||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{addon.name}</span>
|
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{label.name}</span>
|
||||||
{isComingSoon && (
|
{isComingSoon && (
|
||||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
|
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
|
||||||
Coming Soon
|
Coming Soon
|
||||||
@@ -161,12 +195,12 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
|||||||
{addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
|
{addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{addon.description}</p>
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{label.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toggle */}
|
{/* Toggle */}
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className="text-xs font-medium" style={{ color: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
<span className="hidden sm:inline text-xs font-medium" style={{ color: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||||
{isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
{isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { adminApi } from '../../api/client'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { RefreshCw, ClipboardList } from 'lucide-react'
|
||||||
|
|
||||||
|
interface AuditEntry {
|
||||||
|
id: number
|
||||||
|
created_at: string
|
||||||
|
user_id: number | null
|
||||||
|
username: string | null
|
||||||
|
user_email: string | null
|
||||||
|
action: string
|
||||||
|
resource: string | null
|
||||||
|
details: Record<string, unknown> | null
|
||||||
|
ip: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditLogPanel(): React.ReactElement {
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const limit = 100
|
||||||
|
|
||||||
|
const loadFirstPage = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.auditLog({ limit, offset: 0 }) as {
|
||||||
|
entries: AuditEntry[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
setEntries(data.entries || [])
|
||||||
|
setTotal(data.total ?? 0)
|
||||||
|
setOffset(0)
|
||||||
|
} catch {
|
||||||
|
setEntries([])
|
||||||
|
setTotal(0)
|
||||||
|
setOffset(0)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
const nextOffset = offset + limit
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.auditLog({ limit, offset: nextOffset }) as {
|
||||||
|
entries: AuditEntry[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
setEntries((prev) => [...prev, ...(data.entries || [])])
|
||||||
|
setTotal(data.total ?? 0)
|
||||||
|
setOffset(nextOffset)
|
||||||
|
} catch {
|
||||||
|
/* keep existing */
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [offset])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFirstPage()
|
||||||
|
}, [loadFirstPage])
|
||||||
|
|
||||||
|
const fmtTime = (iso: string) => {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString(locale, {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'medium',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtDetails = (d: Record<string, unknown> | null) => {
|
||||||
|
if (!d || Object.keys(d).length === 0) return '—'
|
||||||
|
try {
|
||||||
|
return JSON.stringify(d)
|
||||||
|
} catch {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userLabel = (e: AuditEntry) => {
|
||||||
|
if (e.username) return e.username
|
||||||
|
if (e.user_email) return e.user_email
|
||||||
|
if (e.user_id != null) return `#${e.user_id}`
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-lg m-0 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
<ClipboardList size={20} />
|
||||||
|
{t('admin.tabs.audit')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm m-0 mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => loadFirstPage()}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-primary)', background: 'var(--bg-card)' }}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
|
{t('admin.audit.refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs m-0" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
{t('admin.audit.showing', { count: entries.length, total })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && entries.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('common.loading')}</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.empty')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border overflow-x-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||||
|
<table className="w-full text-sm border-collapse min-w-[720px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.time')}</th>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.user')}</th>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.action')}</th>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.resource')}</th>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.ip')}</th>
|
||||||
|
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.details')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<tr key={e.id} className="border-b align-top" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<td className="p-3 whitespace-nowrap font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{fmtTime(e.created_at)}</td>
|
||||||
|
<td className="p-3" style={{ color: 'var(--text-primary)' }}>{userLabel(e)}</td>
|
||||||
|
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{e.action}</td>
|
||||||
|
<td className="p-3 font-mono text-xs break-all max-w-[140px]" style={{ color: 'var(--text-muted)' }}>{e.resource || '—'}</td>
|
||||||
|
<td className="p-3 font-mono text-xs whitespace-nowrap" style={{ color: 'var(--text-muted)' }}>{e.ip || '—'}</td>
|
||||||
|
<td className="p-3 font-mono text-xs break-all max-w-[280px]" style={{ color: 'var(--text-faint)' }}>{fmtDetails(e.details)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries.length < total && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => loadMore()}
|
||||||
|
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{t('admin.audit.loadMore')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { backupApi } from '../../api/client'
|
|||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { getApiErrorMessage } from '../../types'
|
import { getApiErrorMessage } from '../../types'
|
||||||
|
|
||||||
const INTERVAL_OPTIONS = [
|
const INTERVAL_OPTIONS = [
|
||||||
@@ -21,19 +23,35 @@ const KEEP_OPTIONS = [
|
|||||||
{ value: 0, labelKey: 'backup.keep.forever' },
|
{ value: 0, labelKey: 'backup.keep.forever' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const DAYS_OF_WEEK = [
|
||||||
|
{ value: 0, labelKey: 'backup.dow.sunday' },
|
||||||
|
{ value: 1, labelKey: 'backup.dow.monday' },
|
||||||
|
{ value: 2, labelKey: 'backup.dow.tuesday' },
|
||||||
|
{ value: 3, labelKey: 'backup.dow.wednesday' },
|
||||||
|
{ value: 4, labelKey: 'backup.dow.thursday' },
|
||||||
|
{ value: 5, labelKey: 'backup.dow.friday' },
|
||||||
|
{ value: 6, labelKey: 'backup.dow.saturday' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||||
|
|
||||||
|
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
|
||||||
|
|
||||||
export default function BackupPanel() {
|
export default function BackupPanel() {
|
||||||
const [backups, setBackups] = useState([])
|
const [backups, setBackups] = useState([])
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
const [restoringFile, setRestoringFile] = useState(null)
|
const [restoringFile, setRestoringFile] = useState(null)
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
|
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
|
||||||
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
||||||
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
||||||
|
const [serverTimezone, setServerTimezone] = useState('')
|
||||||
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
|
|
||||||
const loadBackups = async () => {
|
const loadBackups = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -51,6 +69,7 @@ export default function BackupPanel() {
|
|||||||
try {
|
try {
|
||||||
const data = await backupApi.getAutoSettings()
|
const data = await backupApi.getAutoSettings()
|
||||||
setAutoSettings(data.settings)
|
setAutoSettings(data.settings)
|
||||||
|
if (data.timezone) setServerTimezone(data.timezone)
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,10 +166,12 @@ export default function BackupPanel() {
|
|||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
if (!dateStr) return '-'
|
if (!dateStr) return '-'
|
||||||
try {
|
try {
|
||||||
return new Date(dateStr).toLocaleString(locale, {
|
const opts: Intl.DateTimeFormatOptions = {
|
||||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
hour: '2-digit', minute: '2-digit',
|
hour: '2-digit', minute: '2-digit',
|
||||||
})
|
}
|
||||||
|
if (serverTimezone) opts.timeZone = serverTimezone
|
||||||
|
return new Date(dateStr).toLocaleString(locale, opts)
|
||||||
} catch { return dateStr }
|
} catch { return dateStr }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +352,68 @@ export default function BackupPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hour picker (for daily, weekly, monthly) */}
|
||||||
|
{autoSettings.interval !== 'hourly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={String(autoSettings.hour)}
|
||||||
|
onChange={v => handleAutoSettingsChange('hour', parseInt(v, 10))}
|
||||||
|
size="sm"
|
||||||
|
options={HOURS.map(h => {
|
||||||
|
let label: string
|
||||||
|
if (is12h) {
|
||||||
|
const period = h >= 12 ? 'PM' : 'AM'
|
||||||
|
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||||
|
label = `${h12}:00 ${period}`
|
||||||
|
} else {
|
||||||
|
label = `${String(h).padStart(2, '0')}:00`
|
||||||
|
}
|
||||||
|
return { value: String(h), label }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Day of week (for weekly) */}
|
||||||
|
{autoSettings.interval === 'weekly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{DAYS_OF_WEEK.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
autoSettings.day_of_week === opt.value
|
||||||
|
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||||
|
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(opt.labelKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Day of month (for monthly) */}
|
||||||
|
{autoSettings.interval === 'monthly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={String(autoSettings.day_of_month)}
|
||||||
|
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
|
||||||
|
size="sm"
|
||||||
|
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{t('backup.auto.dayOfMonthHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Keep duration */}
|
{/* Keep duration */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
||||||
|
|||||||
@@ -198,7 +198,6 @@ export default function CategoryManager() {
|
|||||||
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">{t('categories.new')}</span>
|
<span className="hidden sm:inline">{t('categories.new')}</span>
|
||||||
<span className="sm:hidden">Add</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee } from 'lucide-react'
|
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
|
|
||||||
const REPO = 'mauriceboe/NOMAD'
|
const REPO = 'mauriceboe/NOMAD'
|
||||||
@@ -18,8 +18,8 @@ export default function GitHubPanel() {
|
|||||||
|
|
||||||
const fetchReleases = async (pageNum = 1, append = false) => {
|
const fetchReleases = async (pageNum = 1, append = false) => {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get(`/auth/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
|
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
|
||||||
const data = res.data
|
const data = Array.isArray(res.data) ? res.data : []
|
||||||
setReleases(prev => append ? [...prev, ...data] : data)
|
setReleases(prev => append ? [...prev, ...data] : data)
|
||||||
setHasMore(data.length === PER_PAGE)
|
setHasMore(data.length === PER_PAGE)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -46,7 +46,7 @@ export default function GitHubPanel() {
|
|||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
const d = new Date(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)
|
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
||||||
@@ -130,7 +130,7 @@ export default function GitHubPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
|
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
|
||||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{language === 'de' ? 'Hilft mir, TREK weiterzuentwickeln' : 'Helps me keep building TREK'}</div>
|
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||||
</div>
|
</div>
|
||||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||||
</a>
|
</a>
|
||||||
@@ -148,7 +148,7 @@ export default function GitHubPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
|
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
|
||||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{language === 'de' ? 'Hilft mir, TREK weiterzuentwickeln' : 'Helps me keep building TREK'}</div>
|
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||||
</div>
|
</div>
|
||||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { adminApi } from '../../api/client'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { Plus, Trash2, Edit2, Package, X, Check, ChevronDown, ChevronRight, FolderPlus } from 'lucide-react'
|
||||||
|
|
||||||
|
interface TemplateCategory { id: number; template_id: number; name: string; sort_order: number }
|
||||||
|
interface TemplateItem { id: number; category_id: number; name: string; sort_order: number }
|
||||||
|
interface Template { id: number; name: string; item_count: number; category_count: number; created_by_name: string }
|
||||||
|
|
||||||
|
export default function PackingTemplateManager() {
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [createName, setCreateName] = useState('')
|
||||||
|
|
||||||
|
// Expanded template state
|
||||||
|
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||||
|
const [categories, setCategories] = useState<TemplateCategory[]>([])
|
||||||
|
const [items, setItems] = useState<TemplateItem[]>([])
|
||||||
|
|
||||||
|
// Editing states
|
||||||
|
const [editingTemplate, setEditingTemplate] = useState<number | null>(null)
|
||||||
|
const [editTemplateName, setEditTemplateName] = useState('')
|
||||||
|
const [editingCatId, setEditingCatId] = useState<number | null>(null)
|
||||||
|
const [editCatName, setEditCatName] = useState('')
|
||||||
|
const [editingItemId, setEditingItemId] = useState<number | null>(null)
|
||||||
|
const [editItemName, setEditItemName] = useState('')
|
||||||
|
|
||||||
|
// Adding states
|
||||||
|
const [addingCategory, setAddingCategory] = useState(false)
|
||||||
|
const [newCatName, setNewCatName] = useState('')
|
||||||
|
const [addingItemToCatId, setAddingItemToCatId] = useState<number | null>(null)
|
||||||
|
const [newItemName, setNewItemName] = useState('')
|
||||||
|
const addItemRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
useEffect(() => { loadTemplates() }, [])
|
||||||
|
|
||||||
|
const loadTemplates = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.packingTemplates()
|
||||||
|
setTemplates(data.templates || [])
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.loadError')) }
|
||||||
|
finally { setIsLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleExpand = async (id: number) => {
|
||||||
|
if (expandedId === id) { setExpandedId(null); return }
|
||||||
|
setExpandedId(id)
|
||||||
|
setAddingCategory(false)
|
||||||
|
setAddingItemToCatId(null)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getPackingTemplate(id)
|
||||||
|
setCategories(data.categories || [])
|
||||||
|
setItems(data.items || [])
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.loadError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template CRUD
|
||||||
|
const handleCreateTemplate = async () => {
|
||||||
|
if (!createName.trim()) return
|
||||||
|
try {
|
||||||
|
const data = await adminApi.createPackingTemplate({ name: createName.trim() })
|
||||||
|
setTemplates(prev => [{ ...data.template, item_count: 0, category_count: 0 }, ...prev])
|
||||||
|
setCreateName(''); setShowCreate(false)
|
||||||
|
setExpandedId(data.template.id); setCategories([]); setItems([])
|
||||||
|
toast.success(t('admin.packingTemplates.created'))
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.createError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteTemplate = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await adminApi.deletePackingTemplate(id)
|
||||||
|
setTemplates(prev => prev.filter(t => t.id !== id))
|
||||||
|
if (expandedId === id) setExpandedId(null)
|
||||||
|
toast.success(t('admin.packingTemplates.deleted'))
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenameTemplate = async (id: number) => {
|
||||||
|
if (!editTemplateName.trim()) { setEditingTemplate(null); return }
|
||||||
|
try {
|
||||||
|
await adminApi.updatePackingTemplate(id, { name: editTemplateName.trim() })
|
||||||
|
setTemplates(prev => prev.map(t => t.id === id ? { ...t, name: editTemplateName.trim() } : t))
|
||||||
|
setEditingTemplate(null)
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category CRUD
|
||||||
|
const handleAddCategory = async () => {
|
||||||
|
if (!newCatName.trim() || !expandedId) return
|
||||||
|
try {
|
||||||
|
const data = await adminApi.addTemplateCategory(expandedId, { name: newCatName.trim() })
|
||||||
|
setCategories(prev => [...prev, data.category])
|
||||||
|
setNewCatName(''); setAddingCategory(false)
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenameCategory = async (catId: number) => {
|
||||||
|
if (!editCatName.trim() || !expandedId) { setEditingCatId(null); return }
|
||||||
|
try {
|
||||||
|
await adminApi.updateTemplateCategory(expandedId, catId, { name: editCatName.trim() })
|
||||||
|
setCategories(prev => prev.map(c => c.id === catId ? { ...c, name: editCatName.trim() } : c))
|
||||||
|
setEditingCatId(null)
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteCategory = async (catId: number) => {
|
||||||
|
if (!expandedId) return
|
||||||
|
try {
|
||||||
|
await adminApi.deleteTemplateCategory(expandedId, catId)
|
||||||
|
setCategories(prev => prev.filter(c => c.id !== catId))
|
||||||
|
setItems(prev => prev.filter(i => i.category_id !== catId))
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item CRUD
|
||||||
|
const handleAddItem = async (catId: number) => {
|
||||||
|
if (!newItemName.trim() || !expandedId) return
|
||||||
|
try {
|
||||||
|
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
|
||||||
|
setItems(prev => [...prev, data.item])
|
||||||
|
setNewItemName('')
|
||||||
|
setTimeout(() => addItemRef.current?.focus(), 30)
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenameItem = async (itemId: number) => {
|
||||||
|
if (!editItemName.trim() || !expandedId) { setEditingItemId(null); return }
|
||||||
|
try {
|
||||||
|
await adminApi.updateTemplateItem(expandedId, itemId, { name: editItemName.trim() })
|
||||||
|
setItems(prev => prev.map(i => i.id === itemId ? { ...i, name: editItemName.trim() } : i))
|
||||||
|
setEditingItemId(null)
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteItem = async (itemId: number) => {
|
||||||
|
if (!expandedId) return
|
||||||
|
try {
|
||||||
|
await adminApi.deleteTemplateItem(expandedId, itemId)
|
||||||
|
setItems(prev => prev.filter(i => i.id !== itemId))
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none'
|
||||||
|
const btnIcon = 'p-1.5 rounded-lg transition-colors'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowCreate(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
|
||||||
|
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create template */}
|
||||||
|
{showCreate && (
|
||||||
|
<div className="px-5 py-3 border-b border-slate-100 flex items-center gap-3">
|
||||||
|
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||||
|
<input autoFocus value={createName} onChange={e => setCreateName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
|
||||||
|
placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} />
|
||||||
|
<button onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
|
||||||
|
<button onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Template list */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-8 text-center"><div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" /></div>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-slate-400">{t('admin.packingTemplates.empty')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{templates.map(tmpl => (
|
||||||
|
<div key={tmpl.id}>
|
||||||
|
{/* Template row */}
|
||||||
|
<div className="px-5 py-3 flex items-center gap-3 hover:bg-slate-50 transition-colors">
|
||||||
|
<button onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
|
||||||
|
{expandedId === tmpl.id ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||||
|
</button>
|
||||||
|
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||||
|
{editingTemplate === tmpl.id ? (
|
||||||
|
<input autoFocus value={editTemplateName} onChange={e => setEditTemplateName(e.target.value)}
|
||||||
|
onBlur={() => handleRenameTemplate(tmpl.id)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
|
||||||
|
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
|
||||||
|
) : (
|
||||||
|
<span onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-slate-400 px-2 py-0.5 bg-slate-100 rounded-full">
|
||||||
|
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||||
|
className={`${btnIcon} hover:bg-slate-100 text-slate-400 hover:text-slate-700`}><Edit2 size={14} /></button>
|
||||||
|
<button onClick={() => handleDeleteTemplate(tmpl.id)}
|
||||||
|
className={`${btnIcon} hover:bg-red-50 text-slate-400 hover:text-red-500`}><Trash2 size={14} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded content */}
|
||||||
|
{expandedId === tmpl.id && (
|
||||||
|
<div className="px-5 pb-4 ml-8 space-y-3">
|
||||||
|
{categories.map(cat => {
|
||||||
|
const catItems = items.filter(i => i.category_id === cat.id)
|
||||||
|
return (
|
||||||
|
<div key={cat.id} className="border border-slate-200 rounded-lg overflow-hidden">
|
||||||
|
{/* Category header */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50">
|
||||||
|
{editingCatId === cat.id ? (
|
||||||
|
<>
|
||||||
|
<input autoFocus value={editCatName} onChange={e => setEditCatName(e.target.value)}
|
||||||
|
onBlur={() => handleRenameCategory(cat.id)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleRenameCategory(cat.id); if (e.key === 'Escape') setEditingCatId(null) }}
|
||||||
|
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm font-semibold" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="flex-1 text-xs font-bold text-slate-500 uppercase tracking-wider">{cat.name}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-slate-400">{catItems.length}</span>
|
||||||
|
<button onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||||
|
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Plus size={13} /></button>
|
||||||
|
<button onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
||||||
|
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Edit2 size={13} /></button>
|
||||||
|
<button onClick={() => handleDeleteCategory(cat.id)}
|
||||||
|
className={`${btnIcon} text-slate-400 hover:text-red-500`}><Trash2 size={13} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
{(catItems.length > 0 || addingItemToCatId === cat.id) && (
|
||||||
|
<div className="divide-y divide-slate-50">
|
||||||
|
{catItems.map(item => (
|
||||||
|
<div key={item.id} className="flex items-center gap-3 px-4 py-2 group">
|
||||||
|
{editingItemId === item.id ? (
|
||||||
|
<>
|
||||||
|
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
|
||||||
|
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||||
|
<button onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
|
||||||
|
<button onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
|
||||||
|
<button onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
|
||||||
|
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 transition-all"><Edit2 size={12} /></button>
|
||||||
|
<button onClick={() => handleDeleteItem(item.id)}
|
||||||
|
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all"><Trash2 size={12} /></button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add item inline */}
|
||||||
|
{addingItemToCatId === cat.id && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2">
|
||||||
|
<input ref={addItemRef} value={newItemName} onChange={e => setNewItemName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
|
||||||
|
placeholder={t('admin.packingTemplates.itemName')}
|
||||||
|
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||||
|
<button onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
|
||||||
|
className="p-1.5 rounded-lg bg-slate-900 text-white disabled:bg-slate-300 hover:bg-slate-700 transition-colors"><Plus size={13} /></button>
|
||||||
|
<button onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
|
||||||
|
className="p-1 text-slate-400 hover:text-slate-600"><X size={13} /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Add category button */}
|
||||||
|
{addingCategory ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input autoFocus value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
|
||||||
|
placeholder={t('admin.packingTemplates.categoryName')}
|
||||||
|
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" />
|
||||||
|
<button onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
|
||||||
|
<button onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setAddingCategory(true)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2.5 w-full text-sm text-slate-400 hover:text-slate-600 border border-dashed border-slate-200 rounded-lg hover:border-slate-400 transition-colors">
|
||||||
|
<FolderPlus size={14} /> {t('admin.packingTemplates.addCategory')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
|||||||
import DOM from 'react-dom'
|
import DOM from 'react-dom'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check } from 'lucide-react'
|
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight } from 'lucide-react'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { budgetApi } from '../../api/client'
|
import { budgetApi } from '../../api/client'
|
||||||
import type { BudgetItem, BudgetMember } from '../../types'
|
import type { BudgetItem, BudgetMember } from '../../types'
|
||||||
@@ -29,8 +29,23 @@ interface PerPersonSummaryEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
|
const CURRENCIES = [
|
||||||
const SYMBOLS = { EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$' }
|
'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
|
||||||
|
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
|
||||||
|
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
|
||||||
|
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
|
||||||
|
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||||
|
]
|
||||||
|
const SYMBOLS = {
|
||||||
|
EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł',
|
||||||
|
SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$',
|
||||||
|
NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM',
|
||||||
|
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
|
||||||
|
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
|
||||||
|
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
|
||||||
|
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
|
||||||
|
PEN: 'S/.', ARS: 'AR$',
|
||||||
|
}
|
||||||
const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
||||||
|
|
||||||
const fmtNum = (v, locale, cur) => {
|
const fmtNum = (v, locale, cur) => {
|
||||||
@@ -145,9 +160,11 @@ interface ChipWithTooltipProps {
|
|||||||
label: string
|
label: string
|
||||||
avatarUrl: string | null
|
avatarUrl: string | null
|
||||||
size?: number
|
size?: number
|
||||||
|
paid?: boolean
|
||||||
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps) {
|
function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
|
||||||
const [hover, setHover] = useState(false)
|
const [hover, setHover] = useState(false)
|
||||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||||
const ref = useRef(null)
|
const ref = useRef(null)
|
||||||
@@ -160,13 +177,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps)
|
|||||||
setHover(true)
|
setHover(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
|
||||||
|
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||||
|
onClick={onClick}
|
||||||
style={{
|
style={{
|
||||||
width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)',
|
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
|
||||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
|
||||||
|
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
|
||||||
|
transition: 'border-color 0.15s, background 0.15s',
|
||||||
}}>
|
}}>
|
||||||
{avatarUrl
|
{avatarUrl
|
||||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
@@ -177,11 +200,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps)
|
|||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5,
|
||||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||||
}}>
|
}}>
|
||||||
{label}
|
{label}
|
||||||
|
{paid && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
|
||||||
|
background: 'rgba(34,197,94,0.15)', color: '#16a34a',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||||
|
}}>Paid</span>
|
||||||
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
@@ -194,10 +225,11 @@ interface BudgetMemberChipsProps {
|
|||||||
members?: BudgetMember[]
|
members?: BudgetMember[]
|
||||||
tripMembers?: TripMember[]
|
tripMembers?: TripMember[]
|
||||||
onSetMembers: (memberIds: number[]) => void
|
onSetMembers: (memberIds: number[]) => void
|
||||||
|
onTogglePaid?: (userId: number, paid: boolean) => void
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }: BudgetMemberChipsProps) {
|
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true }: BudgetMemberChipsProps) {
|
||||||
const chipSize = compact ? 20 : 30
|
const chipSize = compact ? 20 : 30
|
||||||
const btnSize = compact ? 18 : 28
|
const btnSize = compact ? 18 : 28
|
||||||
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
||||||
@@ -237,7 +269,10 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compa
|
|||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||||
{members.map(m => (
|
{members.map(m => (
|
||||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize} />
|
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
|
||||||
|
paid={!!m.paid}
|
||||||
|
onClick={onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
<button ref={btnRef} onClick={openDropdown}
|
<button ref={btnRef} onClick={openDropdown}
|
||||||
style={{
|
style={{
|
||||||
@@ -376,15 +411,23 @@ interface BudgetPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
||||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers } = useTripStore()
|
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
||||||
|
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
|
||||||
|
const [settlementOpen, setSettlementOpen] = useState(false)
|
||||||
const currency = trip?.currency || 'EUR'
|
const currency = trip?.currency || 'EUR'
|
||||||
|
|
||||||
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
||||||
const hasMultipleMembers = tripMembers.length > 1
|
const hasMultipleMembers = tripMembers.length > 1
|
||||||
|
|
||||||
|
// Load settlement data whenever budget items change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasMultipleMembers) return
|
||||||
|
budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
|
||||||
|
}, [tripId, budgetItems, hasMultipleMembers])
|
||||||
|
|
||||||
const setCurrency = (cur) => {
|
const setCurrency = (cur) => {
|
||||||
if (tripId) updateTrip(tripId, { currency: cur })
|
if (tripId) updateTrip(tripId, { currency: cur })
|
||||||
}
|
}
|
||||||
@@ -539,6 +582,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
members={item.members || []}
|
members={item.members || []}
|
||||||
tripMembers={tripMembers}
|
tripMembers={tripMembers}
|
||||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||||
|
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||||
compact={false}
|
compact={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -553,6 +597,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
members={item.members || []}
|
members={item.members || []}
|
||||||
tripMembers={tripMembers}
|
tripMembers={tripMembers}
|
||||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||||
|
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||||
@@ -628,6 +673,91 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
|
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
|
||||||
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
|
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Settlement dropdown inside the total card */}
|
||||||
|
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
|
||||||
|
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 12 }}>
|
||||||
|
<button onClick={() => setSettlementOpen(v => !v)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
|
||||||
|
color: 'rgba(255,255,255,0.6)', fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||||
|
}}>
|
||||||
|
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||||
|
{t('budget.settlement')}
|
||||||
|
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
|
||||||
|
<span style={{ display: 'flex', cursor: 'help' }}
|
||||||
|
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
|
||||||
|
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Info size={11} strokeWidth={2} />
|
||||||
|
</span>
|
||||||
|
<div style={{
|
||||||
|
display: 'none', position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||||
|
marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
||||||
|
fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
|
||||||
|
}}>
|
||||||
|
{t('budget.settlementInfo')}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{settlementOpen && (
|
||||||
|
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{settlement.flows.map((flow, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||||
|
padding: '8px 10px', borderRadius: 10,
|
||||||
|
background: 'rgba(255,255,255,0.06)',
|
||||||
|
}}>
|
||||||
|
<ChipWithTooltip label={flow.from.username} avatarUrl={flow.from.avatar_url} size={28} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>→</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#f87171', whiteSpace: 'nowrap' }}>
|
||||||
|
{fmt(flow.amount, currency)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>→</span>
|
||||||
|
</div>
|
||||||
|
<ChipWithTooltip label={flow.to.username} avatarUrl={flow.to.avatar_url} size={28} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
|
||||||
|
<div style={{ marginTop: 4, borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 8 }}>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'rgba(255,255,255,0.35)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
|
||||||
|
{t('budget.netBalances')}
|
||||||
|
</div>
|
||||||
|
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => (
|
||||||
|
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
|
||||||
|
background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 8, fontWeight: 700, color: 'rgba(255,255,255,0.6)', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{b.avatar_url
|
||||||
|
? <img src={b.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: b.username?.[0]?.toUpperCase()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span style={{ flex: 1, fontSize: 11, color: 'rgba(255,255,255,0.6)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{b.username}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 11, fontWeight: 600, flexShrink: 0,
|
||||||
|
color: b.balance > 0 ? '#4ade80' : '#f87171',
|
||||||
|
}}>
|
||||||
|
{b.balance > 0 ? '+' : ''}{fmt(b.balance, currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pieSegments.length > 0 && (
|
{pieSegments.length > 0 && (
|
||||||
@@ -641,27 +771,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
|
|
||||||
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
||||||
|
|
||||||
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{pieSegments.map(seg => {
|
{pieSegments.map(seg => {
|
||||||
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
|
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
|
||||||
return (
|
return (
|
||||||
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
|
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
|
||||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span>
|
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span>
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap' }}>{pct}%</span>
|
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(seg.value, currency)}</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap', minWidth: 38, textAlign: 'right' }}>{pct}%</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 12, borderTop: '1px solid var(--border-secondary)', paddingTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
{pieSegments.map(seg => (
|
|
||||||
<div key={seg.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{seg.name}</span>
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600 }}>{fmt(seg.value, currency)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const CURRENCIES = [
|
|||||||
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
||||||
|
|
||||||
export default function CurrencyWidget() {
|
export default function CurrencyWidget() {
|
||||||
const { t } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
|
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
|
||||||
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
|
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
|
||||||
const [amount, setAmount] = useState('100')
|
const [amount, setAmount] = useState('100')
|
||||||
@@ -40,7 +40,7 @@ export default function CurrencyWidget() {
|
|||||||
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
|
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
|
||||||
const formatNumber = (num) => {
|
const formatNumber = (num) => {
|
||||||
if (!num || num === '—') return '—'
|
if (!num || num === '—') return '—'
|
||||||
return parseFloat(num).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
}
|
}
|
||||||
const result = rawResult
|
const result = rawResult
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Clock, Plus, X } from 'lucide-react'
|
import { Clock, Plus, X } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
|
||||||
const POPULAR_ZONES = [
|
const POPULAR_ZONES = [
|
||||||
{ label: 'New York', tz: 'America/New_York' },
|
{ label: 'New York', tz: 'America/New_York' },
|
||||||
@@ -23,9 +24,9 @@ const POPULAR_ZONES = [
|
|||||||
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function getTime(tz) {
|
function getTime(tz, locale, is12h) {
|
||||||
try {
|
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', hour12: is12h })
|
||||||
} catch { return '—' }
|
} catch { return '—' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +42,8 @@ function getOffset(tz) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TimezoneWidget() {
|
export default function TimezoneWidget() {
|
||||||
const { t } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
const [zones, setZones] = useState(() => {
|
const [zones, setZones] = useState(() => {
|
||||||
const saved = localStorage.getItem('dashboard_timezones')
|
const saved = localStorage.getItem('dashboard_timezones')
|
||||||
return saved ? JSON.parse(saved) : [
|
return saved ? JSON.parse(saved) : [
|
||||||
@@ -51,6 +53,9 @@ export default function TimezoneWidget() {
|
|||||||
})
|
})
|
||||||
const [now, setNow] = useState(Date.now())
|
const [now, setNow] = useState(Date.now())
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
|
const [customLabel, setCustomLabel] = useState('')
|
||||||
|
const [customTz, setCustomTz] = useState('')
|
||||||
|
const [customError, setCustomError] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const i = setInterval(() => setNow(Date.now()), 10000)
|
const i = setInterval(() => setNow(Date.now()), 10000)
|
||||||
@@ -61,6 +66,20 @@ export default function TimezoneWidget() {
|
|||||||
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
|
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
|
||||||
}, [zones])
|
}, [zones])
|
||||||
|
|
||||||
|
const isValidTz = (tz: string) => {
|
||||||
|
try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomZone = () => {
|
||||||
|
const tz = customTz.trim()
|
||||||
|
if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return }
|
||||||
|
if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return }
|
||||||
|
if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return }
|
||||||
|
const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz
|
||||||
|
setZones([...zones, { label, tz }])
|
||||||
|
setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false)
|
||||||
|
}
|
||||||
|
|
||||||
const addZone = (zone) => {
|
const addZone = (zone) => {
|
||||||
if (!zones.find(z => z.tz === zone.tz)) {
|
if (!zones.find(z => z.tz === zone.tz)) {
|
||||||
setZones([...zones, zone])
|
setZones([...zones, zone])
|
||||||
@@ -70,7 +89,7 @@ export default function TimezoneWidget() {
|
|||||||
|
|
||||||
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
|
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', hour12: is12h })
|
||||||
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
|
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
|
||||||
// Show abbreviated timezone name (e.g. CET, CEST, EST)
|
// Show abbreviated timezone name (e.g. CET, CEST, EST)
|
||||||
@@ -96,7 +115,7 @@ export default function TimezoneWidget() {
|
|||||||
{zones.map(z => (
|
{zones.map(z => (
|
||||||
<div key={z.tz} className="flex items-center justify-between group">
|
<div key={z.tz} className="flex items-center justify-between group">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz)}</p>
|
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale, is12h)}</p>
|
||||||
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
|
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
|
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
|
||||||
@@ -108,7 +127,29 @@ export default function TimezoneWidget() {
|
|||||||
|
|
||||||
{/* Add zone dropdown */}
|
{/* Add zone dropdown */}
|
||||||
{showAdd && (
|
{showAdd && (
|
||||||
<div className="mt-2 rounded-xl p-2 max-h-[200px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
|
<div className="mt-2 rounded-xl p-2 max-h-[280px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
{/* Custom timezone */}
|
||||||
|
<div className="px-2 py-2 mb-2 rounded-lg" style={{ background: 'var(--bg-card)' }}>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezoneCustomTitle')}</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<input value={customLabel} onChange={e => setCustomLabel(e.target.value)}
|
||||||
|
placeholder={t('dashboard.timezoneCustomLabelPlaceholder')}
|
||||||
|
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
||||||
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} />
|
||||||
|
<input value={customTz} onChange={e => { setCustomTz(e.target.value); setCustomError('') }}
|
||||||
|
placeholder={t('dashboard.timezoneCustomTzPlaceholder')}
|
||||||
|
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
||||||
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} />
|
||||||
|
{customError && <p className="text-[10px]" style={{ color: '#ef4444' }}>{customError}</p>}
|
||||||
|
<button onClick={addCustomZone}
|
||||||
|
className="w-full py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||||
|
{t('dashboard.timezoneCustomAdd')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Popular zones */}
|
||||||
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
|
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
|
||||||
<button key={z.tz} onClick={() => addZone(z)}
|
<button key={z.tz} onClick={() => addZone(z)}
|
||||||
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
|
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
|
||||||
@@ -116,7 +157,7 @@ export default function TimezoneWidget() {
|
|||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||||
<span className="font-medium">{z.label}</span>
|
<span className="font-medium">{z.label}</span>
|
||||||
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz)}</span>
|
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -302,10 +302,15 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
|
|
||||||
const renderFileRow = (file: TripFile, isTrash = false) => {
|
const renderFileRow = (file: TripFile, isTrash = false) => {
|
||||||
const FileIcon = getFileIcon(file.mime_type)
|
const FileIcon = getFileIcon(file.mime_type)
|
||||||
const linkedPlace = places?.find(p => p.id === file.place_id)
|
const allLinkedPlaceIds = new Set<number>()
|
||||||
const linkedReservation = file.reservation_id
|
if (file.place_id) allLinkedPlaceIds.add(file.place_id)
|
||||||
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
|
for (const pid of (file.linked_place_ids || [])) allLinkedPlaceIds.add(pid)
|
||||||
: null
|
const linkedPlaces = [...allLinkedPlaceIds].map(pid => places?.find(p => p.id === pid)).filter(Boolean)
|
||||||
|
// All linked reservations (primary + file_links)
|
||||||
|
const allLinkedResIds = new Set<number>()
|
||||||
|
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
|
||||||
|
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
|
||||||
|
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
|
||||||
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
|
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -365,12 +370,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
||||||
|
|
||||||
{linkedPlace && (
|
{linkedPlaces.map(p => (
|
||||||
<SourceBadge icon={MapPin} label={`${t('files.sourcePlan')} · ${linkedPlace.name}`} />
|
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
||||||
)}
|
))}
|
||||||
{linkedReservation && (
|
{linkedReservations.map(r => (
|
||||||
<SourceBadge icon={Ticket} label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`} />
|
<SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
||||||
)}
|
))}
|
||||||
{file.note_id && (
|
{file.note_id && (
|
||||||
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
||||||
)}
|
)}
|
||||||
@@ -477,20 +482,45 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
|
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
|
||||||
const placeBtn = (p: Place) => (
|
const placeBtn = (p: Place) => {
|
||||||
<button key={p.id} onClick={() => handleAssign(file.id, { place_id: file.place_id === p.id ? null : p.id })} style={{
|
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
|
||||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: file.place_id === p.id ? 'var(--bg-hover)' : 'none',
|
return (
|
||||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
<button key={p.id} onClick={async () => {
|
||||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: file.place_id === p.id ? 600 : 400,
|
if (isLinked) {
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
if (file.place_id === p.id) {
|
||||||
}}
|
await handleAssign(file.id, { place_id: null })
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
} else {
|
||||||
onMouseLeave={e => e.currentTarget.style.background = file.place_id === p.id ? 'var(--bg-hover)' : 'transparent'}>
|
try {
|
||||||
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
const link = (linksRes.links || []).find((l: any) => l.place_id === p.id)
|
||||||
{file.place_id === p.id && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||||
</button>
|
refreshFiles()
|
||||||
)
|
} catch {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!file.place_id) {
|
||||||
|
await handleAssign(file.id, { place_id: p.id })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await filesApi.addLink(tripId, file.id, { place_id: p.id })
|
||||||
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} style={{
|
||||||
|
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||||
|
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||||
|
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||||
|
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||||
|
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const placesSection = places.length > 0 && (
|
const placesSection = places.length > 0 && (
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
@@ -519,20 +549,47 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
{t('files.assignBooking')}
|
{t('files.assignBooking')}
|
||||||
</div>
|
</div>
|
||||||
{reservations.map(r => (
|
{reservations.map(r => {
|
||||||
<button key={r.id} onClick={() => handleAssign(file.id, { reservation_id: file.reservation_id === r.id ? null : r.id })} style={{
|
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
||||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: file.reservation_id === r.id ? 'var(--bg-hover)' : 'none',
|
return (
|
||||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
<button key={r.id} onClick={async () => {
|
||||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: file.reservation_id === r.id ? 600 : 400,
|
if (isLinked) {
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
// Unlink: if primary reservation_id, clear it; if via file_links, remove link
|
||||||
}}
|
if (file.reservation_id === r.id) {
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
await handleAssign(file.id, { reservation_id: null })
|
||||||
onMouseLeave={e => e.currentTarget.style.background = file.reservation_id === r.id ? 'var(--bg-hover)' : 'transparent'}>
|
} else {
|
||||||
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
try {
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||||
{file.reservation_id === r.id && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
|
||||||
</button>
|
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||||
))}
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Link: if no primary, set it; otherwise use file_links
|
||||||
|
if (!file.reservation_id) {
|
||||||
|
await handleAssign(file.id, { reservation_id: r.id })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
|
||||||
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} style={{
|
||||||
|
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||||
|
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||||
|
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||||
|
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
||||||
|
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,70 @@ const texts: Record<string, DemoTexts> = {
|
|||||||
selfHostLink: 'self-host it',
|
selfHostLink: 'self-host it',
|
||||||
close: 'Got 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',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
titleBefore: 'مرحبًا بك في ',
|
||||||
|
titleAfter: '',
|
||||||
|
title: 'مرحبًا بك في النسخة التجريبية من TREK',
|
||||||
|
description: 'يمكنك عرض الرحلات وتعديلها وإنشاء رحلات جديدة. تتم إعادة ضبط جميع التغييرات تلقائيًا كل ساعة.',
|
||||||
|
resetIn: 'إعادة الضبط التالية خلال',
|
||||||
|
minutes: 'دقيقة',
|
||||||
|
uploadNote: 'رفع الملفات (الصور والمستندات وصور الغلاف) معطّل في وضع العرض التجريبي.',
|
||||||
|
fullVersionTitle: 'وفي النسخة الكاملة أيضًا:',
|
||||||
|
features: [
|
||||||
|
'رفع الملفات (الصور والمستندات وصور الغلاف)',
|
||||||
|
'إدارة مفاتيح API (خرائط Google والطقس)',
|
||||||
|
'إدارة المستخدمين والصلاحيات',
|
||||||
|
'نسخ احتياطية تلقائية',
|
||||||
|
'إدارة الإضافات (تفعيل/تعطيل)',
|
||||||
|
'تسجيل دخول موحد OIDC / SSO',
|
||||||
|
],
|
||||||
|
addonsTitle: 'إضافات مرنة (يمكن تعطيلها في النسخة الكاملة)',
|
||||||
|
addons: [
|
||||||
|
['Vacay', 'مخطط إجازات مع تقويم وعطل ودمج مستخدمين'],
|
||||||
|
['Atlas', 'خريطة عالمية مع الدول التي تمت زيارتها وإحصاءات السفر'],
|
||||||
|
['Packing', 'قوائم تجهيز لكل رحلة'],
|
||||||
|
['Budget', 'تتبع المصروفات مع التقسيم'],
|
||||||
|
['Documents', 'إرفاق الملفات بالرحلات'],
|
||||||
|
['Widgets', 'محول عملات ومناطق زمنية'],
|
||||||
|
],
|
||||||
|
whatIs: 'ما هو TREK؟',
|
||||||
|
whatIsDesc: 'مخطط رحلات مستضاف ذاتيًا مع تعاون لحظي وخرائط تفاعلية وتسجيل دخول OIDC ووضع داكن.',
|
||||||
|
selfHost: 'مفتوح المصدر — ',
|
||||||
|
selfHostLink: 'استضفه بنفسك',
|
||||||
|
close: 'فهمت',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
|
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
|
||||||
@@ -159,7 +223,7 @@ export default function DemoBanner(): React.ReactElement | null {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
<Map size={14} style={{ color: '#111827' }} />
|
<Map size={14} style={{ color: '#111827' }} />
|
||||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
{language === 'de' ? 'Was ist ' : 'What is '}<img src="/text-dark.svg" alt="TREK" style={{ height: 13, marginRight: -2 }} />?
|
{t.whatIs}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
|
<p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
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 (
|
return (
|
||||||
<nav style={{
|
<nav style={{
|
||||||
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
|
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
|
||||||
@@ -124,7 +130,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent' }}>
|
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent' }}>
|
||||||
<Icon className="w-3.5 h-3.5" />
|
<Icon className="w-3.5 h-3.5" />
|
||||||
<span className="hidden md:inline">{addon.name}</span>
|
<span className="hidden md:inline">{getAddonName(addon)}</span>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState, useMemo } from 'react'
|
import { useEffect, useRef, useState, useMemo, useCallback } from 'react'
|
||||||
import DOM from 'react-dom'
|
import DOM from 'react-dom'
|
||||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
|
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||||
@@ -65,7 +65,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
|||||||
cursor:pointer;flex-shrink:0;position:relative;
|
cursor:pointer;flex-shrink:0;position:relative;
|
||||||
">
|
">
|
||||||
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
||||||
<img src="${escAttr(place.image_url)}" style="width:100%;height:100%;object-fit:cover;" />
|
<img src="${escAttr(place.image_url)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" />
|
||||||
</div>
|
</div>
|
||||||
${badgeHtml}
|
${badgeHtml}
|
||||||
</div>`,
|
</div>`,
|
||||||
@@ -107,20 +107,14 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
||||||
// Fit all day places into view (so you see context), but ensure selected is visible
|
// Pan to the selected place without changing zoom
|
||||||
const toFit = dayPlaces.length > 0 ? dayPlaces : places.filter(p => p.id === selectedPlaceId)
|
const selected = places.find(p => p.id === selectedPlaceId)
|
||||||
const withCoords = toFit.filter(p => p.lat && p.lng)
|
if (selected?.lat && selected?.lng) {
|
||||||
if (withCoords.length > 0) {
|
map.panTo([selected.lat, selected.lng], { animate: true })
|
||||||
try {
|
|
||||||
const bounds = L.latLngBounds(withCoords.map(p => [p.lat, p.lng]))
|
|
||||||
if (bounds.isValid()) {
|
|
||||||
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prev.current = selectedPlaceId
|
prev.current = selectedPlaceId
|
||||||
}, [selectedPlaceId, places, dayPlaces, paddingOpts, map])
|
}, [selectedPlaceId, places, map])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -246,6 +240,96 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
|||||||
const mapPhotoCache = new Map()
|
const mapPhotoCache = new Map()
|
||||||
const mapPhotoInFlight = new Set()
|
const mapPhotoInFlight = new Set()
|
||||||
|
|
||||||
|
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
|
||||||
|
function LocationTracker() {
|
||||||
|
const map = useMap()
|
||||||
|
const [position, setPosition] = useState<[number, number] | null>(null)
|
||||||
|
const [accuracy, setAccuracy] = useState(0)
|
||||||
|
const [tracking, setTracking] = useState(false)
|
||||||
|
const watchId = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const startTracking = useCallback(() => {
|
||||||
|
if (!('geolocation' in navigator)) return
|
||||||
|
setTracking(true)
|
||||||
|
watchId.current = navigator.geolocation.watchPosition(
|
||||||
|
(pos) => {
|
||||||
|
const latlng: [number, number] = [pos.coords.latitude, pos.coords.longitude]
|
||||||
|
setPosition(latlng)
|
||||||
|
setAccuracy(pos.coords.accuracy)
|
||||||
|
},
|
||||||
|
() => setTracking(false),
|
||||||
|
{ enableHighAccuracy: true, maximumAge: 5000 }
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stopTracking = useCallback(() => {
|
||||||
|
if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current)
|
||||||
|
watchId.current = null
|
||||||
|
setTracking(false)
|
||||||
|
setPosition(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleTracking = useCallback(() => {
|
||||||
|
if (tracking) { stopTracking() } else { startTracking() }
|
||||||
|
}, [tracking, startTracking, stopTracking])
|
||||||
|
|
||||||
|
// Center map on position when first acquired
|
||||||
|
const centered = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (position && !centered.current) {
|
||||||
|
map.setView(position, 15)
|
||||||
|
centered.current = true
|
||||||
|
}
|
||||||
|
}, [position, map])
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Location button */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 20, right: 10, zIndex: 1000,
|
||||||
|
}}>
|
||||||
|
<button onClick={toggleTracking} style={{
|
||||||
|
width: 36, height: 36, borderRadius: '50%',
|
||||||
|
border: 'none', cursor: 'pointer',
|
||||||
|
background: tracking ? '#3b82f6' : 'var(--bg-card, white)',
|
||||||
|
color: tracking ? 'white' : 'var(--text-muted, #6b7280)',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'background 0.2s, color 0.2s',
|
||||||
|
}}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Blue dot + accuracy circle */}
|
||||||
|
{position && (
|
||||||
|
<>
|
||||||
|
{accuracy < 500 && (
|
||||||
|
<Circle center={position} radius={accuracy} pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.06, weight: 0.5, opacity: 0.3 }} />
|
||||||
|
)}
|
||||||
|
<CircleMarker center={position} radius={7} pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 2.5 }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pulse animation CSS */}
|
||||||
|
{position && (
|
||||||
|
<style>{`
|
||||||
|
@keyframes location-pulse {
|
||||||
|
0% { transform: scale(1); opacity: 0.6; }
|
||||||
|
100% { transform: scale(2.5); opacity: 0; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function MapView({
|
export function MapView({
|
||||||
places = [],
|
places = [],
|
||||||
dayPlaces = [],
|
dayPlaces = [],
|
||||||
@@ -276,33 +360,48 @@ export function MapView({
|
|||||||
}, [leftWidth, rightWidth, hasInspector])
|
}, [leftWidth, rightWidth, hasInspector])
|
||||||
const [photoUrls, setPhotoUrls] = useState({})
|
const [photoUrls, setPhotoUrls] = useState({})
|
||||||
|
|
||||||
// Fetch photos for places (Google or Wikimedia Commons fallback)
|
// Fetch photos for places with concurrency limit to avoid blocking map rendering
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
places.forEach(place => {
|
const queue = places.filter(place => {
|
||||||
if (place.image_url) return
|
if (place.image_url) return false
|
||||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||||
if (!cacheKey) return
|
if (!cacheKey) return false
|
||||||
if (mapPhotoCache.has(cacheKey)) {
|
if (mapPhotoCache.has(cacheKey)) {
|
||||||
const cached = mapPhotoCache.get(cacheKey)
|
const cached = mapPhotoCache.get(cacheKey)
|
||||||
if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached }))
|
if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached }))
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if (mapPhotoInFlight.has(cacheKey)) return
|
if (mapPhotoInFlight.has(cacheKey)) return false
|
||||||
const photoId = place.google_place_id || place.osm_id
|
const photoId = place.google_place_id || place.osm_id
|
||||||
if (!photoId && !(place.lat && place.lng)) return
|
if (!photoId && !(place.lat && place.lng)) return false
|
||||||
mapPhotoInFlight.add(cacheKey)
|
return true
|
||||||
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
|
||||||
.then(data => {
|
|
||||||
if (data.photoUrl) {
|
|
||||||
mapPhotoCache.set(cacheKey, data.photoUrl)
|
|
||||||
setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl }))
|
|
||||||
} else {
|
|
||||||
mapPhotoCache.set(cacheKey, null)
|
|
||||||
}
|
|
||||||
mapPhotoInFlight.delete(cacheKey)
|
|
||||||
})
|
|
||||||
.catch(() => { mapPhotoCache.set(cacheKey, null); mapPhotoInFlight.delete(cacheKey) })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let active = 0
|
||||||
|
const MAX_CONCURRENT = 3
|
||||||
|
let idx = 0
|
||||||
|
|
||||||
|
const fetchNext = () => {
|
||||||
|
while (active < MAX_CONCURRENT && idx < queue.length) {
|
||||||
|
const place = queue[idx++]
|
||||||
|
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||||
|
const photoId = place.google_place_id || place.osm_id
|
||||||
|
mapPhotoInFlight.add(cacheKey)
|
||||||
|
active++
|
||||||
|
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||||
|
.then(data => {
|
||||||
|
if (data.photoUrl) {
|
||||||
|
mapPhotoCache.set(cacheKey, data.photoUrl)
|
||||||
|
setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl }))
|
||||||
|
} else {
|
||||||
|
mapPhotoCache.set(cacheKey, null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { mapPhotoCache.set(cacheKey, null) })
|
||||||
|
.finally(() => { mapPhotoInFlight.delete(cacheKey); active--; fetchNext() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchNext()
|
||||||
}, [places])
|
}, [places])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -324,6 +423,7 @@ export function MapView({
|
|||||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||||
<MapClickHandler onClick={onMapClick} />
|
<MapClickHandler onClick={onMapClick} />
|
||||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||||
|
<LocationTracker />
|
||||||
|
|
||||||
<MarkerClusterGroup
|
<MarkerClusterGroup
|
||||||
chunkedLoading
|
chunkedLoading
|
||||||
|
|||||||
@@ -0,0 +1,692 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter } from 'lucide-react'
|
||||||
|
import apiClient from '../../api/client'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TripPhoto {
|
||||||
|
immich_asset_id: string
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
shared: number
|
||||||
|
added_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImmichAsset {
|
||||||
|
id: string
|
||||||
|
takenAt: string
|
||||||
|
city: string | null
|
||||||
|
country: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemoriesPanelProps {
|
||||||
|
tripId: number
|
||||||
|
startDate: string | null
|
||||||
|
endDate: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const currentUser = useAuthStore(s => s.user)
|
||||||
|
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// Trip photos (saved selections)
|
||||||
|
const [tripPhotos, setTripPhotos] = useState<TripPhoto[]>([])
|
||||||
|
|
||||||
|
// Photo picker
|
||||||
|
const [showPicker, setShowPicker] = useState(false)
|
||||||
|
const [pickerPhotos, setPickerPhotos] = useState<ImmichAsset[]>([])
|
||||||
|
const [pickerLoading, setPickerLoading] = useState(false)
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Confirm share popup
|
||||||
|
const [showConfirmShare, setShowConfirmShare] = useState(false)
|
||||||
|
|
||||||
|
// Filters & sort
|
||||||
|
const [sortAsc, setSortAsc] = useState(true)
|
||||||
|
const [locationFilter, setLocationFilter] = useState('')
|
||||||
|
|
||||||
|
// Lightbox
|
||||||
|
const [lightboxId, setLightboxId] = useState<string | null>(null)
|
||||||
|
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
|
||||||
|
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
|
||||||
|
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadInitial()
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
|
// WebSocket: reload photos when another user adds/removes/shares
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => loadPhotos()
|
||||||
|
window.addEventListener('memories:updated', handler)
|
||||||
|
return () => window.removeEventListener('memories:updated', handler)
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
|
const loadPhotos = async () => {
|
||||||
|
try {
|
||||||
|
const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`)
|
||||||
|
setTripPhotos(photosRes.data.photos || [])
|
||||||
|
} catch {
|
||||||
|
setTripPhotos([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadInitial = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const statusRes = await apiClient.get('/integrations/immich/status')
|
||||||
|
setConnected(statusRes.data.connected)
|
||||||
|
} catch {
|
||||||
|
setConnected(false)
|
||||||
|
}
|
||||||
|
await loadPhotos()
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo Picker ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [pickerDateFilter, setPickerDateFilter] = useState(true)
|
||||||
|
|
||||||
|
const openPicker = async () => {
|
||||||
|
setShowPicker(true)
|
||||||
|
setPickerLoading(true)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setPickerDateFilter(!!(startDate && endDate))
|
||||||
|
await loadPickerPhotos(!!(startDate && endDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPickerPhotos = async (useDate: boolean) => {
|
||||||
|
setPickerLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post('/integrations/immich/search', {
|
||||||
|
from: useDate && startDate ? startDate : undefined,
|
||||||
|
to: useDate && endDate ? endDate : undefined,
|
||||||
|
})
|
||||||
|
setPickerPhotos(res.data.assets || [])
|
||||||
|
} catch {
|
||||||
|
setPickerPhotos([])
|
||||||
|
} finally {
|
||||||
|
setPickerLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePickerSelect = (id: string) => {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmSelection = () => {
|
||||||
|
if (selectedIds.size === 0) return
|
||||||
|
setShowConfirmShare(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeAddPhotos = async () => {
|
||||||
|
setShowConfirmShare(false)
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, {
|
||||||
|
asset_ids: [...selectedIds],
|
||||||
|
shared: true,
|
||||||
|
})
|
||||||
|
setShowPicker(false)
|
||||||
|
loadInitial()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Remove photo ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const removePhoto = async (assetId: string) => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
|
||||||
|
setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toggle sharing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const toggleSharing = async (assetId: string, shared: boolean) => {
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared })
|
||||||
|
setTripPhotos(prev => prev.map(p =>
|
||||||
|
p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
|
||||||
|
))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const token = useAuthStore(s => s.token)
|
||||||
|
|
||||||
|
const thumbnailUrl = (assetId: string, userId: number) =>
|
||||||
|
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}&token=${token}`
|
||||||
|
|
||||||
|
const originalUrl = (assetId: string, userId: number) =>
|
||||||
|
`/api/integrations/immich/assets/${assetId}/original?userId=${userId}&token=${token}`
|
||||||
|
|
||||||
|
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
|
||||||
|
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
|
||||||
|
const allVisibleRaw = [...ownPhotos, ...othersPhotos]
|
||||||
|
|
||||||
|
// Unique locations for filter
|
||||||
|
const locations = [...new Set(allVisibleRaw.map(p => p.city).filter(Boolean) as string[])].sort()
|
||||||
|
|
||||||
|
// Apply filter + sort
|
||||||
|
const allVisible = allVisibleRaw
|
||||||
|
.filter(p => !locationFilter || p.city === locationFilter)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const da = new Date(a.added_at || 0).getTime()
|
||||||
|
const db = new Date(b.added_at || 0).getTime()
|
||||||
|
return sortAsc ? da - db : db - da
|
||||||
|
})
|
||||||
|
|
||||||
|
const font: React.CSSProperties = {
|
||||||
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', ...font }}>
|
||||||
|
<div className="w-8 h-8 border-2 rounded-full animate-spin"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Not connected ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (!connected && allVisible.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: 40, textAlign: 'center', ...font }}>
|
||||||
|
<Camera size={40} style={{ color: 'var(--text-faint)', marginBottom: 12 }} />
|
||||||
|
<h3 style={{ margin: '0 0 6px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.notConnected')}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)', maxWidth: 300 }}>
|
||||||
|
{t('memories.notConnectedHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo Picker Modal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (showPicker) {
|
||||||
|
const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||||
|
{/* Picker header */}
|
||||||
|
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.selectPhotos')}
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button onClick={() => setShowPicker(false)}
|
||||||
|
style={{ padding: '7px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={confirmSelection} disabled={selectedIds.size === 0}
|
||||||
|
style={{
|
||||||
|
padding: '7px 14px', borderRadius: 10, border: 'none', fontSize: 12, fontWeight: 600,
|
||||||
|
cursor: selectedIds.size > 0 ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||||
|
background: selectedIds.size > 0 ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: selectedIds.size > 0 ? 'var(--bg-primary)' : 'var(--text-faint)',
|
||||||
|
}}>
|
||||||
|
{selectedIds.size > 0 ? t('memories.addSelected', { count: selectedIds.size }) : t('memories.addPhotos')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{startDate && endDate && (
|
||||||
|
<button onClick={() => { if (!pickerDateFilter) { setPickerDateFilter(true); loadPickerPhotos(true) } }}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px', borderRadius: 99, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
border: '1px solid', transition: 'all 0.15s',
|
||||||
|
background: pickerDateFilter ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||||
|
borderColor: pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
{t('memories.tripDates')} ({startDate ? new Date(startDate + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) : ''} — {endDate ? new Date(endDate + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : ''})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => { if (pickerDateFilter || !startDate) { setPickerDateFilter(false); loadPickerPhotos(false) } }}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px', borderRadius: 99, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
border: '1px solid', transition: 'all 0.15s',
|
||||||
|
background: !pickerDateFilter ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||||
|
borderColor: !pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: !pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
{t('memories.allPhotos')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<p style={{ margin: '8px 0 0', fontSize: 12, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
{selectedIds.size} {t('memories.selected')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Picker grid */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||||
|
{pickerLoading ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 60 }}>
|
||||||
|
<div className="w-7 h-7 border-2 rounded-full animate-spin"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
) : pickerPhotos.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||||
|
<Camera size={36} style={{ color: 'var(--text-faint)', margin: '0 auto 10px', display: 'block' }} />
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>{t('memories.noPhotos')}</p>
|
||||||
|
</div>
|
||||||
|
) : (() => {
|
||||||
|
// Group photos by month
|
||||||
|
const byMonth: Record<string, ImmichAsset[]> = {}
|
||||||
|
for (const asset of pickerPhotos) {
|
||||||
|
const d = asset.takenAt ? new Date(asset.takenAt) : null
|
||||||
|
const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown'
|
||||||
|
if (!byMonth[key]) byMonth[key] = []
|
||||||
|
byMonth[key].push(asset)
|
||||||
|
}
|
||||||
|
const sortedMonths = Object.keys(byMonth).sort().reverse()
|
||||||
|
|
||||||
|
return sortedMonths.map(month => (
|
||||||
|
<div key={month} style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-muted)', marginBottom: 6, paddingLeft: 2 }}>
|
||||||
|
{month !== 'unknown'
|
||||||
|
? new Date(month + '-15').toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gap: 4 }}>
|
||||||
|
{byMonth[month].map(asset => {
|
||||||
|
const isSelected = selectedIds.has(asset.id)
|
||||||
|
const isAlready = alreadyAdded.has(asset.id)
|
||||||
|
return (
|
||||||
|
<div key={asset.id}
|
||||||
|
onClick={() => !isAlready && togglePickerSelect(asset.id)}
|
||||||
|
style={{
|
||||||
|
position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden',
|
||||||
|
cursor: isAlready ? 'default' : 'pointer',
|
||||||
|
opacity: isAlready ? 0.3 : 1,
|
||||||
|
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
|
||||||
|
outlineOffset: -3,
|
||||||
|
}}>
|
||||||
|
<img src={thumbnailUrl(asset.id, currentUser!.id)} alt="" loading="lazy"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
{isSelected && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 4, right: 4, width: 22, height: 22, borderRadius: '50%',
|
||||||
|
background: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Check size={13} color="var(--bg-primary)" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isAlready && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'rgba(0,0,0,0.3)', fontSize: 10, color: 'white', fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{t('memories.alreadyAdded')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm share popup (inside picker) */}
|
||||||
|
{showConfirmShare && (
|
||||||
|
<div onClick={() => setShowConfirmShare(false)}
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
||||||
|
<div onClick={e => e.stopPropagation()}
|
||||||
|
style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>
|
||||||
|
<Share2 size={28} style={{ color: 'var(--text-primary)', marginBottom: 12 }} />
|
||||||
|
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.confirmShareTitle')}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||||
|
{t('memories.confirmShareHint', { count: selectedIds.size })}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||||
|
<button onClick={() => setShowConfirmShare(false)}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={executeAddPhotos}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||||
|
{t('memories.confirmShareButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Gallery ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.title')}
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>
|
||||||
|
{allVisible.length} {t('memories.photosFound')}
|
||||||
|
{othersPhotos.length > 0 && ` · ${othersPhotos.length} ${t('memories.fromOthers')}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{connected && (
|
||||||
|
<button onClick={openPicker}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
|
||||||
|
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||||
|
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Plus size={14} /> {t('memories.addPhotos')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter & Sort bar */}
|
||||||
|
{allVisibleRaw.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, padding: '8px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={() => setSortAsc(v => !v)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', borderRadius: 8,
|
||||||
|
border: '1px solid var(--border-primary)', background: 'var(--bg-card)',
|
||||||
|
fontSize: 11, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
<ArrowUpDown size={11} /> {sortAsc ? t('memories.oldest') : t('memories.newest')}
|
||||||
|
</button>
|
||||||
|
{locations.length > 1 && (
|
||||||
|
<select value={locationFilter} onChange={e => setLocationFilter(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||||
|
background: 'var(--bg-card)', fontSize: 11, fontFamily: 'inherit', color: 'var(--text-muted)',
|
||||||
|
cursor: 'pointer', outline: 'none',
|
||||||
|
}}>
|
||||||
|
<option value="">{t('memories.allLocations')}</option>
|
||||||
|
{locations.map(loc => <option key={loc} value={loc}>{loc}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gallery */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||||
|
{allVisible.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||||
|
<Camera size={40} style={{ color: 'var(--text-faint)', margin: '0 auto 12px', display: 'block' }} />
|
||||||
|
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>
|
||||||
|
{t('memories.noPhotos')}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '0 0 16px' }}>
|
||||||
|
{t('memories.noPhotosHint')}
|
||||||
|
</p>
|
||||||
|
<button onClick={openPicker}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '9px 18px', borderRadius: 10,
|
||||||
|
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||||
|
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Plus size={15} /> {t('memories.addPhotos')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: 6 }}>
|
||||||
|
{allVisible.map(photo => {
|
||||||
|
const isOwn = photo.user_id === currentUser?.id
|
||||||
|
return (
|
||||||
|
<div key={photo.immich_asset_id} className="group"
|
||||||
|
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
||||||
|
onClick={() => {
|
||||||
|
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||||
|
setLightboxInfoLoading(true)
|
||||||
|
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
|
||||||
|
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
||||||
|
}}>
|
||||||
|
|
||||||
|
<img src={thumbnailUrl(photo.immich_asset_id, photo.user_id)} alt="" loading="lazy"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
|
||||||
|
|
||||||
|
{/* Other user's avatar */}
|
||||||
|
{!isOwn && (
|
||||||
|
<div className="memories-avatar" style={{ position: 'absolute', bottom: 6, left: 6, zIndex: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 22, height: 22, borderRadius: '50%',
|
||||||
|
background: `hsl(${photo.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
|
||||||
|
border: '2px solid white', boxShadow: '0 1px 4px rgba(0,0,0,0.3)',
|
||||||
|
}}>
|
||||||
|
{photo.username[0]}
|
||||||
|
</div>
|
||||||
|
<div className="memories-avatar-tooltip" style={{
|
||||||
|
position: 'absolute', bottom: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||||
|
marginBottom: 6, padding: '3px 8px', borderRadius: 6,
|
||||||
|
background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||||
|
fontSize: 10, fontWeight: 600, whiteSpace: 'nowrap',
|
||||||
|
pointerEvents: 'none', opacity: 0, transition: 'opacity 0.15s',
|
||||||
|
}}>
|
||||||
|
{photo.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Own photo actions (hover) */}
|
||||||
|
{isOwn && (
|
||||||
|
<div className="opacity-0 group-hover:opacity-100"
|
||||||
|
style={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 3, transition: 'opacity 0.15s' }}>
|
||||||
|
<button onClick={e => { e.stopPropagation(); toggleSharing(photo.immich_asset_id, !photo.shared) }}
|
||||||
|
title={photo.shared ? t('memories.stopSharing') : t('memories.sharePhotos')}
|
||||||
|
style={{
|
||||||
|
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||||
|
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{photo.shared ? <Eye size={12} color="white" /> : <EyeOff size={12} color="white" />}
|
||||||
|
</button>
|
||||||
|
<button onClick={e => { e.stopPropagation(); removePhoto(photo.immich_asset_id) }}
|
||||||
|
style={{
|
||||||
|
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||||
|
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<X size={12} color="white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Not shared indicator */}
|
||||||
|
{isOwn && !photo.shared && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 6, right: 6, padding: '2px 6px', borderRadius: 6,
|
||||||
|
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||||
|
fontSize: 9, color: 'rgba(255,255,255,0.7)', fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
<EyeOff size={9} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
|
||||||
|
{t('memories.private')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.memories-avatar:hover .memories-avatar-tooltip { opacity: 1 !important; }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Confirm share popup */}
|
||||||
|
{showConfirmShare && (
|
||||||
|
<div onClick={() => setShowConfirmShare(false)}
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
||||||
|
<div onClick={e => e.stopPropagation()}
|
||||||
|
style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>
|
||||||
|
<Share2 size={28} style={{ color: 'var(--text-primary)', marginBottom: 12 }} />
|
||||||
|
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.confirmShareTitle')}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||||
|
{t('memories.confirmShareHint', { count: selectedIds.size })}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||||
|
<button onClick={() => setShowConfirmShare(false)}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={executeAddPhotos}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||||
|
{t('memories.confirmShareButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lightbox */}
|
||||||
|
{lightboxId && lightboxUserId && (
|
||||||
|
<div onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', inset: 0, zIndex: 100,
|
||||||
|
background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<button onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 16, right: 16, width: 40, height: 40, borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.1)', border: 'none', cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<X size={20} color="white" />
|
||||||
|
</button>
|
||||||
|
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
|
||||||
|
<img
|
||||||
|
src={originalUrl(lightboxId, lightboxUserId)}
|
||||||
|
alt=""
|
||||||
|
style={{ maxWidth: lightboxInfo ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Info panel — liquid glass */}
|
||||||
|
{lightboxInfo && (
|
||||||
|
<div style={{
|
||||||
|
width: 240, flexShrink: 0, borderRadius: 16, padding: 18,
|
||||||
|
background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)', color: 'white',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 14, maxHeight: '100%', overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{/* Date */}
|
||||||
|
{lightboxInfo.takenAt && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>Date</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>{new Date(lightboxInfo.takenAt).toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)' }}>{new Date(lightboxInfo.takenAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
{(lightboxInfo.city || lightboxInfo.country) && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>
|
||||||
|
<MapPin size={9} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />Location
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{[lightboxInfo.city, lightboxInfo.state, lightboxInfo.country].filter(Boolean).join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Camera */}
|
||||||
|
{lightboxInfo.camera && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>Camera</div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 500 }}>{lightboxInfo.camera}</div>
|
||||||
|
{lightboxInfo.lens && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', marginTop: 2 }}>{lightboxInfo.lens}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
{(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||||
|
{lightboxInfo.focalLength && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Focal</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.focalLength}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lightboxInfo.aperture && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Aperture</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.aperture}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lightboxInfo.shutter && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Shutter</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.shutter}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lightboxInfo.iso && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>ISO</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.iso}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resolution & File */}
|
||||||
|
{(lightboxInfo.width || lightboxInfo.fileName) && (
|
||||||
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 10 }}>
|
||||||
|
{lightboxInfo.width && lightboxInfo.height && (
|
||||||
|
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>{lightboxInfo.width} × {lightboxInfo.height}</div>
|
||||||
|
)}
|
||||||
|
{lightboxInfo.fileSize && (
|
||||||
|
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)' }}>{(lightboxInfo.fileSize / 1024 / 1024).toFixed(1)} MB</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lightboxInfoLoading && (
|
||||||
|
<div style={{ width: 240, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div className="w-6 h-6 border-2 rounded-full animate-spin" style={{ borderColor: 'rgba(255,255,255,0.2)', borderTopColor: 'white' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,13 @@ function noteIconSvg(iconId) {
|
|||||||
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }))
|
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||||
|
function transportIconSvg(type) {
|
||||||
|
if (!_renderToStaticMarkup) return ''
|
||||||
|
const Icon = TRANSPORT_ICON_MAP[type] || Ticket
|
||||||
|
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' }))
|
||||||
|
}
|
||||||
|
|
||||||
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
|
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
|
||||||
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
|
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
|
||||||
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
|
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
|
||||||
@@ -96,13 +103,14 @@ interface downloadTripPDFProps {
|
|||||||
assignments: AssignmentsMap
|
assignments: AssignmentsMap
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
dayNotes: DayNotesMap
|
dayNotes: DayNotesMap
|
||||||
|
reservations?: any[]
|
||||||
t: (key: string, params?: Record<string, string | number>) => string
|
t: (key: string, params?: Record<string, string | number>) => string
|
||||||
locale: string
|
locale: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }: downloadTripPDFProps) {
|
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, reservations = [], t: _t, locale: _locale }: downloadTripPDFProps) {
|
||||||
await ensureRenderer()
|
await ensureRenderer()
|
||||||
const loc = _locale || 'de-DE'
|
const loc = _locale || undefined
|
||||||
const tr = _t || (k => k)
|
const tr = _t || (k => k)
|
||||||
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
|
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
|
||||||
const range = longDateRange(sorted, loc)
|
const range = longDateRange(sorted, loc)
|
||||||
@@ -123,15 +131,46 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||||
const cost = dayCost(assignments, day.id, loc)
|
const cost = dayCost(assignments, day.id, loc)
|
||||||
|
|
||||||
|
// Transport bookings for this day
|
||||||
|
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||||
|
const dayTransport = (reservations || []).filter(r => {
|
||||||
|
if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false
|
||||||
|
return day.date && r.reservation_time.split('T')[0] === day.date
|
||||||
|
})
|
||||||
|
|
||||||
const merged = []
|
const merged = []
|
||||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||||
|
dayTransport.forEach(r => {
|
||||||
|
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||||
|
merged.push({ type: 'transport', k: pos, data: r })
|
||||||
|
})
|
||||||
merged.sort((a, b) => a.k - b.k)
|
merged.sort((a, b) => a.k - b.k)
|
||||||
|
|
||||||
let pi = 0
|
let pi = 0
|
||||||
const itemsHtml = merged.length === 0
|
const itemsHtml = merged.length === 0
|
||||||
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
|
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
|
||||||
: merged.map(item => {
|
: merged.map(item => {
|
||||||
|
if (item.type === 'transport') {
|
||||||
|
const r = item.data
|
||||||
|
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||||
|
const icon = transportIconSvg(r.type)
|
||||||
|
let subtitle = ''
|
||||||
|
if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||||
|
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||||
|
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||||
|
return `
|
||||||
|
<div class="note-card" style="border-left: 3px solid #3b82f6;">
|
||||||
|
<div class="note-line" style="background: #3b82f6;"></div>
|
||||||
|
<span class="note-icon">${icon}</span>
|
||||||
|
<div class="note-body">
|
||||||
|
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||||
|
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
||||||
|
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
if (item.type === 'note') {
|
if (item.type === 'note') {
|
||||||
const note = item.data
|
const note = item.data
|
||||||
return `
|
return `
|
||||||
@@ -165,7 +204,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
|
|
||||||
const chips = [
|
const chips = [
|
||||||
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
|
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
|
||||||
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString('de-DE')} EUR</span>` : '',
|
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString(loc)} EUR</span>` : '',
|
||||||
].filter(Boolean).join('')
|
].filter(Boolean).join('')
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -377,7 +416,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
|
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
|
||||||
</div>
|
</div>
|
||||||
${totalCost > 0 ? `<div>
|
${totalCost > 0 ? `<div>
|
||||||
<div class="cover-stat-num">${totalCost.toLocaleString('de-DE')}</div>
|
<div class="cover-stat-num">${totalCost.toLocaleString(loc)}</div>
|
||||||
<div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div>
|
<div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, useMemo, useRef } from 'react'
|
import { useState, useMemo, useRef, useEffect } from 'react'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { packingApi, tripsApi, adminApi } from '../../api/client'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
import {
|
import {
|
||||||
CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
|
CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
|
||||||
Sparkles, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage,
|
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, Upload,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { PackingItem } from '../../types'
|
import type { PackingItem } from '../../types'
|
||||||
|
|
||||||
@@ -64,19 +66,27 @@ function katColor(kat, allCategories) {
|
|||||||
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
|
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null }
|
||||||
|
|
||||||
// ── Artikel-Zeile ──────────────────────────────────────────────────────────
|
// ── Artikel-Zeile ──────────────────────────────────────────────────────────
|
||||||
interface ArtikelZeileProps {
|
interface ArtikelZeileProps {
|
||||||
item: PackingItem
|
item: PackingItem
|
||||||
tripId: number
|
tripId: number
|
||||||
categories: string[]
|
categories: string[]
|
||||||
onCategoryChange: () => void
|
onCategoryChange: () => void
|
||||||
|
bagTrackingEnabled?: boolean
|
||||||
|
bags?: PackingBag[]
|
||||||
|
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArtikelZeile({ item, tripId, categories, onCategoryChange }: ArtikelZeileProps) {
|
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag }: ArtikelZeileProps) {
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [editName, setEditName] = useState(item.name)
|
const [editName, setEditName] = useState(item.name)
|
||||||
const [hovered, setHovered] = useState(false)
|
const [hovered, setHovered] = useState(false)
|
||||||
const [showCatPicker, setShowCatPicker] = useState(false)
|
const [showCatPicker, setShowCatPicker] = useState(false)
|
||||||
|
const [showBagPicker, setShowBagPicker] = useState(false)
|
||||||
|
const [bagInlineCreate, setBagInlineCreate] = useState(false)
|
||||||
|
const [bagInlineName, setBagInlineName] = useState('')
|
||||||
const { togglePackingItem, updatePackingItem, deletePackingItem } = useTripStore()
|
const { togglePackingItem, updatePackingItem, deletePackingItem } = useTripStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -103,8 +113,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }: ArtikelZei
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="group"
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => { setHovered(false); setShowCatPicker(false) }}
|
onMouseLeave={() => { setHovered(false); setShowCatPicker(false); setShowBagPicker(false) }}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
padding: '6px 10px', borderRadius: 10, position: 'relative',
|
padding: '6px 10px', borderRadius: 10, position: 'relative',
|
||||||
@@ -141,7 +152,102 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }: ArtikelZei
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 2, alignItems: 'center', opacity: hovered ? 1 : 0, transition: 'opacity 0.12s', flexShrink: 0 }}>
|
{/* Weight + Bag (when enabled) */}
|
||||||
|
{bagTrackingEnabled && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent' }}>
|
||||||
|
<input
|
||||||
|
type="text" inputMode="numeric"
|
||||||
|
value={item.weight_grams ?? ''}
|
||||||
|
onChange={async e => {
|
||||||
|
const raw = e.target.value.replace(/[^0-9]/g, '')
|
||||||
|
const v = raw === '' ? null : parseInt(raw)
|
||||||
|
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch {}
|
||||||
|
}}
|
||||||
|
placeholder="—"
|
||||||
|
style={{ width: 36, border: 'none', fontSize: 12, textAlign: 'right', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--text-faint)', userSelect: 'none' }}>g</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBagPicker(p => !p)}
|
||||||
|
style={{
|
||||||
|
width: 22, height: 22, borderRadius: '50%', cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
border: item.bag_id ? `2.5px solid ${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}` : '2px dashed var(--border-primary)',
|
||||||
|
background: item.bag_id ? `${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}30` : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!item.bag_id && <Package size={9} style={{ color: 'var(--text-faint)' }} />}
|
||||||
|
</button>
|
||||||
|
{showBagPicker && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', right: 0, top: '100%', marginTop: 4, zIndex: 50,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 160,
|
||||||
|
}}>
|
||||||
|
{item.bag_id && (
|
||||||
|
<button onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: null }) } catch {} }}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '6px 10px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit', color: 'var(--text-faint)', borderRadius: 7 }}>
|
||||||
|
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)' }} />
|
||||||
|
{t('packing.noBag')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{bags.map(b => (
|
||||||
|
<button key={b.id} onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: b.id }) } catch {} }}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '6px 10px',
|
||||||
|
background: item.bag_id === b.id ? 'var(--bg-tertiary)' : 'none',
|
||||||
|
border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit', color: 'var(--text-secondary)', borderRadius: 7,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (item.bag_id !== b.id) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
||||||
|
onMouseLeave={e => { if (item.bag_id !== b.id) e.currentTarget.style.background = 'none' }}>
|
||||||
|
<span style={{ width: 10, height: 10, borderRadius: '50%', background: b.color, flexShrink: 0 }} />
|
||||||
|
{b.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{bags.length > 0 && <div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />}
|
||||||
|
<div style={{ padding: '4px 6px' }}>
|
||||||
|
{bagInlineCreate ? (
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<input autoFocus value={bagInlineName} onChange={e => setBagInlineName(e.target.value)}
|
||||||
|
onKeyDown={async e => {
|
||||||
|
if (e.key === 'Enter' && bagInlineName.trim()) {
|
||||||
|
const newBag = await onCreateBag(bagInlineName.trim())
|
||||||
|
if (newBag) { try { await updatePackingItem(tripId, item.id, { bag_id: newBag.id }) } catch {} }
|
||||||
|
setBagInlineName(''); setBagInlineCreate(false); setShowBagPicker(false)
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') { setBagInlineCreate(false); setBagInlineName('') }
|
||||||
|
}}
|
||||||
|
placeholder={t('packing.bagName')}
|
||||||
|
style={{ flex: 1, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', fontSize: 11, fontFamily: 'inherit', outline: 'none' }} />
|
||||||
|
<button onClick={async () => {
|
||||||
|
if (bagInlineName.trim()) {
|
||||||
|
const newBag = await onCreateBag(bagInlineName.trim())
|
||||||
|
if (newBag) { try { await updatePackingItem(tripId, item.id, { bag_id: newBag.id }) } catch {} }
|
||||||
|
setBagInlineName(''); setBagInlineCreate(false); setShowBagPicker(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ padding: '3px 6px', borderRadius: 6, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Plus size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setBagInlineCreate(true)}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 5, width: '100%', padding: '5px 6px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, fontFamily: 'inherit', color: 'var(--text-faint)', borderRadius: 7 }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<Plus size={11} /> {t('packing.addBag')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="sm:opacity-0 sm:group-hover:opacity-100" style={{ display: 'flex', gap: 2, alignItems: 'center', transition: 'opacity 0.12s', flexShrink: 0 }}>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCatPicker(p => !p)}
|
onClick={() => setShowCatPicker(p => !p)}
|
||||||
@@ -186,6 +292,19 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }: ArtikelZei
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Kategorie-Gruppe ───────────────────────────────────────────────────────
|
// ── Kategorie-Gruppe ───────────────────────────────────────────────────────
|
||||||
|
interface TripMember {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
avatar?: string | null
|
||||||
|
avatar_url?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryAssignee {
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
avatar?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
interface KategorieGruppeProps {
|
interface KategorieGruppeProps {
|
||||||
kategorie: string
|
kategorie: string
|
||||||
items: PackingItem[]
|
items: PackingItem[]
|
||||||
@@ -193,16 +312,39 @@ interface KategorieGruppeProps {
|
|||||||
allCategories: string[]
|
allCategories: string[]
|
||||||
onRename: (oldName: string, newName: string) => Promise<void>
|
onRename: (oldName: string, newName: string) => Promise<void>
|
||||||
onDeleteAll: (items: PackingItem[]) => Promise<void>
|
onDeleteAll: (items: PackingItem[]) => Promise<void>
|
||||||
|
onAddItem: (category: string, name: string) => Promise<void>
|
||||||
|
assignees: CategoryAssignee[]
|
||||||
|
tripMembers: TripMember[]
|
||||||
|
onSetAssignees: (category: string, userIds: number[]) => Promise<void>
|
||||||
|
bagTrackingEnabled?: boolean
|
||||||
|
bags?: PackingBag[]
|
||||||
|
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll }: KategorieGruppeProps) {
|
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag }: KategorieGruppeProps) {
|
||||||
const [offen, setOffen] = useState(true)
|
const [offen, setOffen] = useState(true)
|
||||||
const [editingName, setEditingName] = useState(false)
|
const [editingName, setEditingName] = useState(false)
|
||||||
const [editKatName, setEditKatName] = useState(kategorie)
|
const [editKatName, setEditKatName] = useState(kategorie)
|
||||||
const [showMenu, setShowMenu] = useState(false)
|
const [showMenu, setShowMenu] = useState(false)
|
||||||
|
const [showAssigneeDropdown, setShowAssigneeDropdown] = useState(false)
|
||||||
|
const [showAddItem, setShowAddItem] = useState(false)
|
||||||
|
const [newItemName, setNewItemName] = useState('')
|
||||||
|
const addItemRef = useRef<HTMLInputElement>(null)
|
||||||
|
const assigneeDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const { togglePackingItem } = useTripStore()
|
const { togglePackingItem } = useTripStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showAssigneeDropdown) return
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (assigneeDropdownRef.current && !assigneeDropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setShowAssigneeDropdown(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [showAssigneeDropdown])
|
||||||
|
|
||||||
const abgehakt = items.filter(i => i.checked).length
|
const abgehakt = items.filter(i => i.checked).length
|
||||||
const alleAbgehakt = abgehakt === items.length
|
const alleAbgehakt = abgehakt === items.length
|
||||||
const dot = katColor(kategorie, allCategories)
|
const dot = katColor(kategorie, allCategories)
|
||||||
@@ -247,11 +389,98 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
|||||||
style={{ flex: 1, fontSize: 12.5, fontWeight: 600, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }}
|
style={{ flex: 1, fontSize: 12.5, fontWeight: 600, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em', flex: 1 }}>
|
<span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||||
{kategorie}
|
{kategorie}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Assignee chips */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 3, flex: 1, minWidth: 0, marginLeft: 4 }}>
|
||||||
|
{assignees.map(a => (
|
||||||
|
<div key={a.user_id} style={{ position: 'relative' }}
|
||||||
|
onClick={e => { e.stopPropagation(); onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }}
|
||||||
|
>
|
||||||
|
<div className="assignee-chip"
|
||||||
|
style={{
|
||||||
|
width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: 'pointer',
|
||||||
|
background: `hsl(${a.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
|
||||||
|
border: '2px solid var(--bg-card)', transition: 'opacity 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{a.username[0]}
|
||||||
|
</div>
|
||||||
|
<div className="assignee-tooltip" style={{
|
||||||
|
position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||||
|
marginTop: 6, padding: '3px 8px', borderRadius: 6, zIndex: 60,
|
||||||
|
background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||||
|
fontSize: 10, fontWeight: 600, whiteSpace: 'nowrap',
|
||||||
|
pointerEvents: 'none', opacity: 0, transition: 'opacity 0.15s',
|
||||||
|
}}>
|
||||||
|
{a.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={assigneeDropdownRef} style={{ position: 'relative' }}>
|
||||||
|
<button onClick={e => { e.stopPropagation(); setShowAssigneeDropdown(v => !v) }}
|
||||||
|
style={{
|
||||||
|
width: 20, height: 20, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||||
|
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: 'var(--text-faint)', flexShrink: 0, padding: 0, transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-muted)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
<UserPlus size={10} />
|
||||||
|
</button>
|
||||||
|
{showAssigneeDropdown && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: 0, top: '100%', marginTop: 4, zIndex: 50,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 160,
|
||||||
|
}}>
|
||||||
|
{tripMembers.map(m => {
|
||||||
|
const isAssigned = assignees.some(a => a.user_id === m.id)
|
||||||
|
return (
|
||||||
|
<button key={m.id} onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const newIds = isAssigned
|
||||||
|
? assignees.filter(a => a.user_id !== m.id).map(a => a.user_id)
|
||||||
|
: [...assignees.map(a => a.user_id), m.id]
|
||||||
|
onSetAssignees(kategorie, newIds)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||||
|
padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer',
|
||||||
|
background: isAssigned ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isAssigned) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
||||||
|
onMouseLeave={e => { if (!isAssigned) e.currentTarget.style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
|
||||||
|
background: `hsl(${m.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
|
||||||
|
}}>
|
||||||
|
{m.username[0]}
|
||||||
|
</div>
|
||||||
|
<span style={{ flex: 1 }}>{m.username}</span>
|
||||||
|
{isAssigned && <Check size={12} style={{ color: 'var(--text-muted)' }} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{tripMembers.length === 0 && (
|
||||||
|
<div style={{ padding: '8px 10px', fontSize: 11, color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
|
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
|
||||||
background: alleAbgehakt ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)',
|
background: alleAbgehakt ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)',
|
||||||
@@ -281,8 +510,45 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
|||||||
{offen && (
|
{offen && (
|
||||||
<div style={{ padding: '4px 4px 6px' }}>
|
<div style={{ padding: '4px 4px 6px' }}>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} />
|
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} />
|
||||||
))}
|
))}
|
||||||
|
{/* Inline add item */}
|
||||||
|
{showAddItem ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
|
||||||
|
<input
|
||||||
|
ref={addItemRef}
|
||||||
|
autoFocus
|
||||||
|
value={newItemName}
|
||||||
|
onChange={e => setNewItemName(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' && newItemName.trim()) {
|
||||||
|
onAddItem(kategorie, newItemName.trim())
|
||||||
|
setNewItemName('')
|
||||||
|
setTimeout(() => addItemRef.current?.focus(), 30)
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') { setShowAddItem(false); setNewItemName('') }
|
||||||
|
}}
|
||||||
|
placeholder={t('packing.addItemPlaceholder')}
|
||||||
|
style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 12.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'var(--bg-input)' }}
|
||||||
|
/>
|
||||||
|
<button onClick={() => { if (newItemName.trim()) { onAddItem(kategorie, newItemName.trim()); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) } }}
|
||||||
|
disabled={!newItemName.trim()}
|
||||||
|
style={{ padding: '5px 8px', borderRadius: 8, border: 'none', background: newItemName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newItemName.trim() ? 'pointer' : 'default', display: 'flex' }}>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setShowAddItem(false); setNewItemName('') }}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: 'var(--text-faint)' }}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px', margin: '2px 4px', borderRadius: 8, border: 'none', background: 'none', cursor: 'pointer', fontSize: 12, color: 'var(--text-faint)', fontFamily: 'inherit' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<Plus size={12} /> {t('packing.addItem')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -319,19 +585,45 @@ interface PackingListPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PackingListPanel({ tripId, items }: PackingListPanelProps) {
|
export default function PackingListPanel({ tripId, items }: PackingListPanelProps) {
|
||||||
const [neuerName, setNeuerName] = useState('')
|
|
||||||
const [neueKategorie, setNeueKategorie] = useState('')
|
|
||||||
const [zeigeVorschlaege, setZeigeVorschlaege] = useState(false)
|
|
||||||
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
||||||
const [showKatDropdown, setShowKatDropdown] = useState(false)
|
const [addingCategory, setAddingCategory] = useState(false)
|
||||||
const katInputRef = useRef(null)
|
const [newCatName, setNewCatName] = useState('')
|
||||||
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
|
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
// Trip members & category assignees
|
||||||
|
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
|
||||||
|
const [categoryAssignees, setCategoryAssignees] = useState<Record<string, CategoryAssignee[]>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tripsApi.getMembers(tripId).then(data => {
|
||||||
|
const all: TripMember[] = []
|
||||||
|
if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url })
|
||||||
|
if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url })))
|
||||||
|
setTripMembers(all)
|
||||||
|
}).catch(() => {})
|
||||||
|
packingApi.getCategoryAssignees(tripId).then(data => {
|
||||||
|
setCategoryAssignees(data.assignees || {})
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
|
const handleSetAssignees = async (category: string, userIds: number[]) => {
|
||||||
|
try {
|
||||||
|
const data = await packingApi.setCategoryAssignees(tripId, category, userIds)
|
||||||
|
setCategoryAssignees(prev => ({ ...prev, [category]: data.assignees || [] }))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('packing.toast.saveError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const allCategories = useMemo(() => {
|
const allCategories = useMemo(() => {
|
||||||
const cats = new Set(items.map(i => i.category || t('packing.defaultCategory')))
|
const seen: string[] = []
|
||||||
return Array.from(cats).sort()
|
for (const item of items) {
|
||||||
|
const cat = item.category || t('packing.defaultCategory')
|
||||||
|
if (!seen.includes(cat)) seen.push(cat)
|
||||||
|
}
|
||||||
|
return seen
|
||||||
}, [items, t])
|
}, [items, t])
|
||||||
|
|
||||||
const gruppiert = useMemo(() => {
|
const gruppiert = useMemo(() => {
|
||||||
@@ -352,21 +644,24 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
const abgehakt = items.filter(i => i.checked).length
|
const abgehakt = items.filter(i => i.checked).length
|
||||||
const fortschritt = items.length > 0 ? Math.round((abgehakt / items.length) * 100) : 0
|
const fortschritt = items.length > 0 ? Math.round((abgehakt / items.length) * 100) : 0
|
||||||
|
|
||||||
const handleAdd = async (e) => {
|
const handleAddItemToCategory = async (category: string, name: string) => {
|
||||||
e.preventDefault()
|
|
||||||
if (!neuerName.trim()) return
|
|
||||||
const kat = neueKategorie.trim() || (allCategories[0] || t('packing.defaultCategory'))
|
|
||||||
try {
|
try {
|
||||||
await addPackingItem(tripId, { name: neuerName.trim(), category: kat })
|
await addPackingItem(tripId, { name, category })
|
||||||
setNeuerName('')
|
|
||||||
} catch { toast.error(t('packing.toast.addError')) }
|
} catch { toast.error(t('packing.toast.addError')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const vorschlaege = t('packing.suggestions.items') || VORSCHLAEGE
|
const handleAddNewCategory = async () => {
|
||||||
|
if (!newCatName.trim()) return
|
||||||
const handleVorschlag = async (v) => {
|
let catName = newCatName.trim()
|
||||||
try { await addPackingItem(tripId, { name: v.name, category: v.category || v.kategorie }) }
|
// Allow duplicate display names — append invisible zero-width spaces to make unique internally
|
||||||
catch { toast.error(t('packing.toast.addError')) }
|
while (allCategories.includes(catName)) {
|
||||||
|
catName += '\u200B'
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await addPackingItem(tripId, { name: '...', category: catName })
|
||||||
|
setNewCatName('')
|
||||||
|
setAddingCategory(false)
|
||||||
|
} catch { toast.error(t('packing.toast.addError')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRenameCategory = async (oldName, newName) => {
|
const handleRenameCategory = async (oldName, newName) => {
|
||||||
@@ -389,8 +684,120 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const vorhandeneNamen = new Set(items.map(i => i.name.toLowerCase()))
|
// Bag tracking
|
||||||
const verfuegbareVorschlaege = vorschlaege.filter(v => !vorhandeneNamen.has(v.name.toLowerCase()))
|
const [bagTrackingEnabled, setBagTrackingEnabled] = useState(false)
|
||||||
|
const [bags, setBags] = useState<PackingBag[]>([])
|
||||||
|
const [newBagName, setNewBagName] = useState('')
|
||||||
|
const [showAddBag, setShowAddBag] = useState(false)
|
||||||
|
const [showBagModal, setShowBagModal] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminApi.getBagTracking().then(d => {
|
||||||
|
setBagTrackingEnabled(d.enabled)
|
||||||
|
if (d.enabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
|
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b']
|
||||||
|
|
||||||
|
const handleCreateBag = async () => {
|
||||||
|
if (!newBagName.trim()) return
|
||||||
|
try {
|
||||||
|
const data = await packingApi.createBag(tripId, { name: newBagName.trim(), color: BAG_COLORS[bags.length % BAG_COLORS.length] })
|
||||||
|
setBags(prev => [...prev, data.bag])
|
||||||
|
setNewBagName(''); setShowAddBag(false)
|
||||||
|
} catch { toast.error(t('packing.toast.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateBagByName = async (name: string): Promise<PackingBag | undefined> => {
|
||||||
|
try {
|
||||||
|
const data = await packingApi.createBag(tripId, { name, color: BAG_COLORS[bags.length % BAG_COLORS.length] })
|
||||||
|
setBags(prev => [...prev, data.bag])
|
||||||
|
return data.bag
|
||||||
|
} catch { toast.error(t('packing.toast.saveError')); return undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteBag = async (bagId: number) => {
|
||||||
|
try {
|
||||||
|
await packingApi.deleteBag(tripId, bagId)
|
||||||
|
setBags(prev => prev.filter(b => b.id !== bagId))
|
||||||
|
} catch { toast.error(t('packing.toast.deleteError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([])
|
||||||
|
const [showTemplateDropdown, setShowTemplateDropdown] = useState(false)
|
||||||
|
const [applyingTemplate, setApplyingTemplate] = useState(false)
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false)
|
||||||
|
const [importText, setImportText] = useState('')
|
||||||
|
const csvInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showTemplateDropdown) return
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (templateDropdownRef.current && !templateDropdownRef.current.contains(e.target as Node)) setShowTemplateDropdown(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [showTemplateDropdown])
|
||||||
|
|
||||||
|
const handleApplyTemplate = async (templateId: number) => {
|
||||||
|
setApplyingTemplate(true)
|
||||||
|
try {
|
||||||
|
const data = await packingApi.applyTemplate(tripId, templateId)
|
||||||
|
toast.success(t('packing.templateApplied', { count: data.count }))
|
||||||
|
setShowTemplateDropdown(false)
|
||||||
|
// Reload packing items
|
||||||
|
window.location.reload()
|
||||||
|
} catch {
|
||||||
|
toast.error(t('packing.templateError'))
|
||||||
|
} finally {
|
||||||
|
setApplyingTemplate(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseImportLines = (text: string) => {
|
||||||
|
return text.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
|
||||||
|
// Format: Category, Name, Weight (optional), Bag (optional), checked/unchecked (optional)
|
||||||
|
const parts = line.split(/[,;\t]/).map(s => s.trim())
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const category = parts[0]
|
||||||
|
const name = parts[1]
|
||||||
|
const weight_grams = parts[2] || undefined
|
||||||
|
const bag = parts[3] || undefined
|
||||||
|
const checked = parts[4]?.toLowerCase() === 'checked' || parts[4] === '1'
|
||||||
|
return { name, category, weight_grams, bag, checked }
|
||||||
|
}
|
||||||
|
// Single value = just a name
|
||||||
|
return { name: parts[0], category: undefined, weight_grams: undefined, bag: undefined, checked: false }
|
||||||
|
}).filter(i => i.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkImport = async () => {
|
||||||
|
const parsed = parseImportLines(importText)
|
||||||
|
if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return }
|
||||||
|
try {
|
||||||
|
const result = await packingApi.bulkImport(tripId, parsed)
|
||||||
|
toast.success(t('packing.importSuccess', { count: result.count }))
|
||||||
|
setImportText('')
|
||||||
|
setShowImportModal(false)
|
||||||
|
window.location.reload()
|
||||||
|
} catch { toast.error(t('packing.importError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCsvFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
e.target.value = ''
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => { if (typeof reader.result === 'string') setImportText(reader.result) }
|
||||||
|
reader.readAsText(file)
|
||||||
|
}
|
||||||
|
|
||||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||||
|
|
||||||
@@ -416,15 +823,64 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => setZeigeVorschlaege(v => !v)} style={{
|
<button onClick={() => setShowImportModal(true)} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||||
border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
||||||
background: zeigeVorschlaege ? 'var(--text-primary)' : 'var(--bg-card)',
|
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
|
||||||
borderColor: zeigeVorschlaege ? 'var(--text-primary)' : 'var(--border-primary)',
|
|
||||||
color: zeigeVorschlaege ? 'var(--bg-primary)' : 'var(--text-muted)',
|
|
||||||
}}>
|
}}>
|
||||||
<Sparkles size={12} /> {t('packing.suggestions')}
|
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
{availableTemplates.length > 0 && (
|
||||||
|
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
|
||||||
|
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||||
|
border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
background: showTemplateDropdown ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||||
|
borderColor: showTemplateDropdown ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: showTemplateDropdown ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
<Package size={12} /> <span className="hidden sm:inline">{t('packing.applyTemplate')}</span><span className="sm:hidden">{t('packing.template')}</span>
|
||||||
|
</button>
|
||||||
|
{showTemplateDropdown && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', right: 0, top: '100%', marginTop: 6, zIndex: 50,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 200,
|
||||||
|
}}>
|
||||||
|
{availableTemplates.map(tmpl => (
|
||||||
|
<button key={tmpl.id} onClick={() => handleApplyTemplate(tmpl.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||||
|
padding: '8px 12px', borderRadius: 8, border: 'none', cursor: 'pointer',
|
||||||
|
background: 'transparent', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||||
|
>
|
||||||
|
<Package size={13} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>{tmpl.name}</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-faint)' }}>{tmpl.item_count} {t('admin.packingTemplates.items')}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bagTrackingEnabled && (
|
||||||
|
<button onClick={() => setShowBagModal(true)} className="xl:!hidden"
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||||
|
border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
background: showBagModal ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||||
|
borderColor: showBagModal ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: showBagModal ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
<Luggage size={12} /> {t('packing.bags')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -443,71 +899,33 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleAdd} style={{ display: 'flex', gap: 6 }}>
|
{addingCategory ? (
|
||||||
<input
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
type="text" value={neuerName} onChange={e => setNeuerName(e.target.value)}
|
|
||||||
placeholder={t('packing.addPlaceholder')}
|
|
||||||
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }}
|
|
||||||
/>
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<input
|
<input
|
||||||
ref={katInputRef}
|
autoFocus
|
||||||
type="text" value={neueKategorie}
|
type="text" value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||||
onChange={e => { setNeueKategorie(e.target.value); setShowKatDropdown(true) }}
|
onKeyDown={e => { if (e.key === 'Enter') handleAddNewCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
|
||||||
onFocus={() => setShowKatDropdown(true)}
|
placeholder={t('packing.newCategoryPlaceholder')}
|
||||||
onBlur={() => setTimeout(() => setShowKatDropdown(false), 150)}
|
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }}
|
||||||
placeholder={allCategories[0] || t('packing.categoryPlaceholder')}
|
|
||||||
style={{ width: 120, padding: '8px 10px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)' }}
|
|
||||||
/>
|
/>
|
||||||
{showKatDropdown && allCategories.length > 0 && (
|
<button onClick={handleAddNewCategory} disabled={!newCatName.trim()}
|
||||||
<div style={{ position: 'absolute', top: '100%', left: 0, right: 0, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', zIndex: 50, padding: 4, marginTop: 2 }}>
|
style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: newCatName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newCatName.trim() ? 'pointer' : 'default', display: 'flex', alignItems: 'center' }}>
|
||||||
{allCategories.filter(c => !neueKategorie || c.toLowerCase().includes(neueKategorie.toLowerCase())).map(cat => (
|
<Check size={16} />
|
||||||
<button key={cat} type="button" onMouseDown={() => setNeueKategorie(cat)} style={{
|
</button>
|
||||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
<button onClick={() => { setAddingCategory(false); setNewCatName('') }}
|
||||||
padding: '6px 10px', background: 'none', border: 'none', cursor: 'pointer',
|
style={{ padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
|
||||||
fontSize: 12.5, fontFamily: 'inherit', color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
|
<X size={16} />
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
|
||||||
>
|
|
||||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, allCategories), flexShrink: 0 }} />
|
|
||||||
{cat}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button type="submit" style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
|
||||||
<Plus size={16} />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Vorschläge ── */}
|
|
||||||
{zeigeVorschlaege && (
|
|
||||||
<div style={{ borderBottom: '1px solid rgba(0,0,0,0.06)', background: 'var(--bg-secondary)', padding: '10px 20px', flexShrink: 0 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('packing.suggestionsTitle')}</span>
|
|
||||||
<button onClick={() => setZeigeVorschlaege(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex' }}>
|
|
||||||
<X size={14} style={{ color: 'var(--text-faint)' }} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, maxHeight: 110, overflowY: 'auto' }}>
|
) : (
|
||||||
{verfuegbareVorschlaege.map((v, i) => (
|
<button onClick={() => setAddingCategory(true)}
|
||||||
<button key={i} onClick={() => handleVorschlag(v)} style={{
|
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '9px 14px', borderRadius: 10, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-faint)', fontFamily: 'inherit', transition: 'all 0.15s' }}
|
||||||
fontSize: 12, padding: '4px 10px', borderRadius: 99, border: '1px solid var(--border-primary)',
|
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-secondary)' }}
|
||||||
background: 'var(--bg-card)', cursor: 'pointer', color: 'var(--text-secondary)', fontFamily: 'inherit', transition: 'all 0.1s',
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||||
}}
|
<FolderPlus size={14} /> {t('packing.addCategory')}
|
||||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--text-primary)'; e.currentTarget.style.color = 'white'; e.currentTarget.style.borderColor = 'var(--text-primary)' }}
|
</button>
|
||||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.color = 'var(--text-secondary)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
)}
|
||||||
>
|
</div>
|
||||||
+ {v.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{verfuegbareVorschlaege.length === 0 && <p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>{t('packing.allSuggested')}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Filter-Tabs ── */}
|
{/* ── Filter-Tabs ── */}
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
@@ -523,7 +941,8 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Liste ── */}
|
{/* ── Liste + Bags Sidebar ── */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px 16px' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px 16px' }}>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||||
@@ -546,11 +965,246 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
allCategories={allCategories}
|
allCategories={allCategories}
|
||||||
onRename={handleRenameCategory}
|
onRename={handleRenameCategory}
|
||||||
onDeleteAll={handleDeleteCategory}
|
onDeleteAll={handleDeleteCategory}
|
||||||
|
onAddItem={handleAddItemToCategory}
|
||||||
|
assignees={categoryAssignees[kat] || []}
|
||||||
|
tripMembers={tripMembers}
|
||||||
|
onSetAssignees={handleSetAssignees}
|
||||||
|
bagTrackingEnabled={bagTrackingEnabled}
|
||||||
|
bags={bags}
|
||||||
|
onCreateBag={handleCreateBagByName}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Bag Weight Sidebar ── */}
|
||||||
|
{bagTrackingEnabled && bags.length > 0 && (
|
||||||
|
<div className="hidden xl:block" style={{ width: 260, borderLeft: '1px solid var(--border-secondary)', overflowY: 'auto', padding: 16, flexShrink: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-faint)', marginBottom: 12 }}>
|
||||||
|
{t('packing.bags')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bags.map(bag => {
|
||||||
|
const bagItems = items.filter(i => i.bag_id === bag.id)
|
||||||
|
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0)
|
||||||
|
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
|
||||||
|
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
||||||
|
return (
|
||||||
|
<div key={bag.id} style={{ marginBottom: 14 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||||
|
<span style={{ width: 10, height: 10, borderRadius: '50%', background: bag.color, flexShrink: 0 }} />
|
||||||
|
<span style={{ flex: 1, fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{bag.name}</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 500 }}>
|
||||||
|
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => handleDeleteBag(bag.id)}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 6, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||||
|
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2 }}>{bagItems.length} {t('admin.packingTemplates.items')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Unassigned */}
|
||||||
|
{(() => {
|
||||||
|
const unassigned = items.filter(i => !i.bag_id)
|
||||||
|
const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0)
|
||||||
|
if (unassigned.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 14, opacity: 0.6 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||||
|
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} />
|
||||||
|
<span style={{ flex: 1, fontSize: 12, fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>
|
||||||
|
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
<span>{t('packing.totalWeight')}</span>
|
||||||
|
<span>{(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add bag */}
|
||||||
|
{showAddBag ? (
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginTop: 12 }}>
|
||||||
|
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
|
||||||
|
placeholder={t('packing.bagName')}
|
||||||
|
style={{ flex: 1, padding: '5px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 11, fontFamily: 'inherit', outline: 'none' }} />
|
||||||
|
<button onClick={handleCreateBag} style={{ padding: '4px 8px', borderRadius: 8, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setShowAddBag(true)}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}>
|
||||||
|
<Plus size={11} /> {t('packing.addBag')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Bag Modal (mobile + click) ── */}
|
||||||
|
{showBagModal && bagTrackingEnabled && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
|
||||||
|
onClick={() => setShowBagModal(false)}>
|
||||||
|
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: '80vh', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)' }}
|
||||||
|
onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.bags')}</h3>
|
||||||
|
<button onClick={() => setShowBagModal(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><X size={18} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bags.map(bag => {
|
||||||
|
const bagItems = items.filter(i => i.bag_id === bag.id)
|
||||||
|
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0)
|
||||||
|
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
|
||||||
|
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
||||||
|
return (
|
||||||
|
<div key={bag.id} style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
|
<span style={{ width: 12, height: 12, borderRadius: '50%', background: bag.color, flexShrink: 0 }} />
|
||||||
|
<span style={{ flex: 1, fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>{bag.name}</span>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-muted)', fontWeight: 500 }}>
|
||||||
|
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => handleDeleteBag(bag.id)}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 8, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||||
|
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>{bagItems.length} {t('admin.packingTemplates.items')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Unassigned */}
|
||||||
|
{(() => {
|
||||||
|
const unassigned = items.filter(i => !i.bag_id)
|
||||||
|
const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0)
|
||||||
|
if (unassigned.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 16, opacity: 0.6 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
|
<span style={{ width: 12, height: 12, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} />
|
||||||
|
<span style={{ flex: 1, fontSize: 14, fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
|
||||||
|
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
<span>{t('packing.totalWeight')}</span>
|
||||||
|
<span>{(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add bag */}
|
||||||
|
{showAddBag ? (
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginTop: 14 }}>
|
||||||
|
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
|
||||||
|
placeholder={t('packing.bagName')}
|
||||||
|
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none' }} />
|
||||||
|
<button onClick={handleCreateBag} disabled={!newBagName.trim()}
|
||||||
|
style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: newBagName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newBagName.trim() ? 'pointer' : 'default', display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setShowAddBag(true)}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 14, padding: '9px 14px', borderRadius: 10, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%', transition: 'all 0.15s' }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-secondary)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||||
|
<Plus size={14} /> {t('packing.addBag')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.assignee-chip:hover + .assignee-tooltip { opacity: 1 !important; }
|
||||||
|
.assignee-chip:hover { opacity: 0.7; }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Bulk Import Modal */}
|
||||||
|
{showImportModal && ReactDOM.createPortal(
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 1000,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||||
|
}} onClick={() => setShowImportModal(false)}>
|
||||||
|
<div style={{
|
||||||
|
width: 420, maxHeight: '80vh', background: 'var(--bg-card)', borderRadius: 16,
|
||||||
|
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 14,
|
||||||
|
}} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('packing.importTitle')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-faint)', lineHeight: 1.5 }}>{t('packing.importHint')}</div>
|
||||||
|
<textarea
|
||||||
|
value={importText}
|
||||||
|
onChange={e => setImportText(e.target.value)}
|
||||||
|
rows={10}
|
||||||
|
placeholder={t('packing.importPlaceholder')}
|
||||||
|
style={{
|
||||||
|
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
|
padding: '10px 12px', fontSize: 13, fontFamily: 'monospace',
|
||||||
|
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)',
|
||||||
|
background: 'var(--bg-input)', resize: 'vertical', lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<input ref={csvInputRef} type="file" accept=".csv,.txt" style={{ display: 'none' }} onChange={handleCsvFile} />
|
||||||
|
<button onClick={() => csvInputRef.current?.click()} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px',
|
||||||
|
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||||
|
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Upload size={11} /> {t('packing.importCsv')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button onClick={() => setShowImportModal(false)} style={{
|
||||||
|
fontSize: 12, background: 'none', border: '1px solid var(--border-primary)',
|
||||||
|
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit',
|
||||||
|
}}>{t('common.cancel')}</button>
|
||||||
|
<button onClick={handleBulkImport} disabled={!importText.trim()} style={{
|
||||||
|
fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)',
|
||||||
|
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600,
|
||||||
|
fontFamily: 'inherit', opacity: importText.trim() ? 1 : 0.5,
|
||||||
|
}}>{t('packing.importAction', { count: parseImportLines(importText).length })}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { PhotoLightbox } from './PhotoLightbox'
|
|||||||
import { PhotoUpload } from './PhotoUpload'
|
import { PhotoUpload } from './PhotoUpload'
|
||||||
import { Upload, Camera } from 'lucide-react'
|
import { Upload, Camera } from 'lucide-react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import { useTranslation } from '../../i18n'
|
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||||
import type { Photo, Place, Day } from '../../types'
|
import type { Photo, Place, Day } from '../../types'
|
||||||
|
|
||||||
interface PhotoGalleryProps {
|
interface PhotoGalleryProps {
|
||||||
@@ -17,7 +17,7 @@ interface PhotoGalleryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }: 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 [lightboxIndex, setLightboxIndex] = useState(null)
|
||||||
const [showUpload, setShowUpload] = useState(false)
|
const [showUpload, setShowUpload] = useState(false)
|
||||||
const [filterDayId, setFilterDayId] = useState('')
|
const [filterDayId, setFilterDayId] = useState('')
|
||||||
@@ -53,7 +53,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
<div style={{ marginRight: 'auto' }}>
|
<div style={{ marginRight: 'auto' }}>
|
||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>Fotos</h2>
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>Fotos</h2>
|
||||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: '#9ca3af' }}>
|
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: '#9ca3af' }}>
|
||||||
{photos.length} Foto{photos.length !== 1 ? 's' : ''}
|
{photos.length} {photos.length !== 1 ? 'Fotos' : 'Foto'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
<option value="">{t('photos.allDays')}</option>
|
<option value="">{t('photos.allDays')}</option>
|
||||||
{(days || []).map(day => (
|
{(days || []).map(day => (
|
||||||
<option key={day.id} value={day.id}>
|
<option key={day.id} value={day.id}>
|
||||||
Tag {day.day_number}{day.date ? ` · ${formatDate(day.date)}` : ''}
|
{t('planner.dayN', { n: day.day_number })}{day.date ? ` · ${formatDate(day.date, getLocaleForLanguage(language))}` : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -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"
|
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"
|
||||||
>
|
>
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
Fotos hochladen
|
{t('common.upload')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
style={{ display: 'inline-flex', margin: '0 auto' }}
|
style={{ display: 'inline-flex', margin: '0 auto' }}
|
||||||
>
|
>
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
Fotos hochladen
|
{t('common.upload')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -146,7 +146,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={showUpload}
|
isOpen={showUpload}
|
||||||
onClose={() => setShowUpload(false)}
|
onClose={() => setShowUpload(false)}
|
||||||
title="Fotos hochladen"
|
title={t('common.upload')}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<PhotoUpload
|
<PhotoUpload
|
||||||
@@ -211,7 +211,7 @@ function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr, locale) {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return ''
|
||||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })
|
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,10 +227,10 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr, locale = 'en-US') {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return ''
|
||||||
try {
|
try {
|
||||||
return new Date(dateStr).toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
|
return new Date(dateStr).toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
} catch { return '' }
|
} catch { return '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { weatherApi, accommodationsApi } from '../../api/client'
|
|||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||||
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
||||||
|
|
||||||
const WEATHER_ICON_MAP = {
|
const WEATHER_ICON_MAP = {
|
||||||
@@ -50,12 +50,15 @@ interface DayDetailPanelProps {
|
|||||||
lng: number | null
|
lng: number | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onAccommodationChange: () => void
|
onAccommodationChange: () => void
|
||||||
|
leftWidth?: number
|
||||||
|
rightWidth?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }: DayDetailPanelProps) {
|
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0 }: DayDetailPanelProps) {
|
||||||
const { t, language } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
|
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||||
const fmtTime = (v) => formatTime12(v, is12h)
|
const fmtTime = (v) => formatTime12(v, is12h)
|
||||||
const unit = isFahrenheit ? '°F' : '°C'
|
const unit = isFahrenheit ? '°F' : '°C'
|
||||||
const [weather, setWeather] = useState(null)
|
const [weather, setWeather] = useState(null)
|
||||||
@@ -138,7 +141,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
if (!day) return null
|
if (!day) return null
|
||||||
|
|
||||||
const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString(
|
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' }
|
{ weekday: 'long', day: 'numeric', month: 'long' }
|
||||||
) : null
|
) : null
|
||||||
|
|
||||||
@@ -146,7 +149,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'fixed', bottom: 20, left: '50%', transform: 'translateX(-50%)', width: 'min(800px, calc(100vw - 32px))', zIndex: 50, ...font }}>
|
<div style={{ position: 'fixed', bottom: 20, left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, zIndex: 50, ...font }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--bg-elevated)',
|
background: 'var(--bg-elevated)',
|
||||||
backdropFilter: 'blur(40px) saturate(180%)',
|
backdropFilter: 'blur(40px) saturate(180%)',
|
||||||
@@ -270,7 +273,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
</div>
|
</div>
|
||||||
{r.reservation_time?.includes('T') && (
|
{r.reservation_time?.includes('T') && (
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
{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)}`}
|
{r.reservation_end_time && ` – ${fmtTime(r.reservation_end_time)}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -368,7 +371,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.title}</div>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.title}</div>
|
||||||
<div style={{ fontSize: 9, color: 'var(--text-faint)', display: 'flex', gap: 6, marginTop: 1 }}>
|
<div style={{ fontSize: 9, color: 'var(--text-faint)', display: 'flex', gap: 6, marginTop: 1 }}>
|
||||||
<span>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
<span>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
||||||
{linked.confirmation_number && <span>#{linked.confirmation_number}</span>}
|
{linked.confirmation_number && <span
|
||||||
|
onMouseEnter={e => { if (blurCodes) e.currentTarget.style.filter = 'none' }}
|
||||||
|
onMouseLeave={e => { if (blurCodes) e.currentTarget.style.filter = 'blur(4px)' }}
|
||||||
|
onClick={e => { if (blurCodes) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(4px)' : 'none' } }}
|
||||||
|
style={{ filter: blurCodes ? 'blur(4px)' : 'none', transition: 'filter 0.2s', cursor: blurCodes ? 'pointer' : 'default' }}
|
||||||
|
>#{linked.confirmation_number}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -423,7 +431,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
||||||
options={days.map((d, i) => ({
|
options={days.map((d, i) => ({
|
||||||
value: d.id,
|
value: d.id,
|
||||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short' })}` : ''}`,
|
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
|
||||||
}))}
|
}))}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
@@ -435,7 +443,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
||||||
options={days.map((d, i) => ({
|
options={days.map((d, i) => ({
|
||||||
value: d.id,
|
value: d.id,
|
||||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short' })}` : ''}`,
|
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
|
||||||
}))}
|
}))}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ReactDOM from 'react-dom'
|
|||||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
|
||||||
|
|
||||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||||
|
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
@@ -74,6 +75,7 @@ interface DayPlanSidebarProps {
|
|||||||
onDeletePlace: (placeId: number) => void
|
onDeletePlace: (placeId: number) => void
|
||||||
reservations?: Reservation[]
|
reservations?: Reservation[]
|
||||||
onAddReservation: () => void
|
onAddReservation: () => void
|
||||||
|
onNavigateToFiles?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DayPlanSidebar({
|
export default function DayPlanSidebar({
|
||||||
@@ -85,6 +87,7 @@ export default function DayPlanSidebar({
|
|||||||
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||||
reservations = [],
|
reservations = [],
|
||||||
onAddReservation,
|
onAddReservation,
|
||||||
|
onNavigateToFiles,
|
||||||
}: DayPlanSidebarProps) {
|
}: DayPlanSidebarProps) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
@@ -108,11 +111,22 @@ export default function DayPlanSidebar({
|
|||||||
const [draggingId, setDraggingId] = useState(null)
|
const [draggingId, setDraggingId] = useState(null)
|
||||||
const [lockedIds, setLockedIds] = useState(new Set())
|
const [lockedIds, setLockedIds] = useState(new Set())
|
||||||
const [lockHoverId, setLockHoverId] = useState(null)
|
const [lockHoverId, setLockHoverId] = useState(null)
|
||||||
const [dropTargetKey, setDropTargetKey] = useState(null)
|
const [dropTargetKey, _setDropTargetKey] = useState(null)
|
||||||
|
const dropTargetRef = useRef(null)
|
||||||
|
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
|
||||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||||
const [hoveredId, setHoveredId] = useState(null)
|
const [hoveredId, setHoveredId] = useState(null)
|
||||||
|
const [transportDetail, setTransportDetail] = useState(null)
|
||||||
|
const [timeConfirm, setTimeConfirm] = useState<{
|
||||||
|
dayId: number; fromId: number; time: string;
|
||||||
|
// For drag & drop reorder
|
||||||
|
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean;
|
||||||
|
// For arrow reorder
|
||||||
|
reorderIds?: number[];
|
||||||
|
} | null>(null)
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
const dragDataRef = useRef(null) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
|
const dragDataRef = useRef(null)
|
||||||
|
const initedTransportIds = useRef(new Set<number>()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
|
||||||
|
|
||||||
const currency = trip?.currency || 'EUR'
|
const currency = trip?.currency || 'EUR'
|
||||||
|
|
||||||
@@ -176,16 +190,137 @@ export default function DayPlanSidebar({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||||
|
|
||||||
|
const getTransportForDay = (dayId: number) => {
|
||||||
|
const day = days.find(d => d.id === dayId)
|
||||||
|
if (!day?.date) return []
|
||||||
|
return reservations.filter(r => {
|
||||||
|
if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false
|
||||||
|
const resDate = r.reservation_time.split('T')[0]
|
||||||
|
return resDate === day.date
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const getDayAssignments = (dayId) =>
|
const getDayAssignments = (dayId) =>
|
||||||
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||||
|
|
||||||
|
// Helper: parse time string ("HH:MM" or ISO) to minutes since midnight, or null
|
||||||
|
const parseTimeToMinutes = (time?: string | null): number | null => {
|
||||||
|
if (!time) return null
|
||||||
|
// ISO-Format "2025-03-30T09:00:00"
|
||||||
|
if (time.includes('T')) {
|
||||||
|
const [h, m] = time.split('T')[1].split(':').map(Number)
|
||||||
|
return h * 60 + m
|
||||||
|
}
|
||||||
|
// Einfaches "HH:MM" Format
|
||||||
|
const parts = time.split(':').map(Number)
|
||||||
|
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute initial day_plan_position for a transport based on time
|
||||||
|
const computeTransportPosition = (r, da) => {
|
||||||
|
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
|
||||||
|
// Find the last place with time <= transport time
|
||||||
|
let afterIdx = -1
|
||||||
|
for (const a of da) {
|
||||||
|
const pm = parseTimeToMinutes(a.place?.place_time)
|
||||||
|
if (pm !== null && pm <= minutes) afterIdx = a.order_index
|
||||||
|
}
|
||||||
|
// Position: midpoint between afterIdx and afterIdx+1 (leaves room for other items)
|
||||||
|
return afterIdx >= 0 ? afterIdx + 0.5 : da.length + 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-initialize transport positions on first render if not set
|
||||||
|
const initTransportPositions = (dayId) => {
|
||||||
|
const da = getDayAssignments(dayId)
|
||||||
|
const transport = getTransportForDay(dayId)
|
||||||
|
const needsInit = transport.filter(r => r.day_plan_position == null && !initedTransportIds.current.has(r.id))
|
||||||
|
if (needsInit.length === 0) return
|
||||||
|
|
||||||
|
const sorted = [...needsInit].sort((a, b) =>
|
||||||
|
(parseTimeToMinutes(a.reservation_time) ?? 0) - (parseTimeToMinutes(b.reservation_time) ?? 0)
|
||||||
|
)
|
||||||
|
const positions = sorted.map((r, idx) => ({
|
||||||
|
id: r.id,
|
||||||
|
day_plan_position: computeTransportPosition(r, da) + idx * 0.01,
|
||||||
|
}))
|
||||||
|
// Mark as initialized immediately to prevent re-entry
|
||||||
|
for (const p of positions) {
|
||||||
|
initedTransportIds.current.add(p.id)
|
||||||
|
const res = reservations.find(x => x.id === p.id)
|
||||||
|
if (res) res.day_plan_position = p.day_plan_position
|
||||||
|
}
|
||||||
|
// Persist to server (fire and forget)
|
||||||
|
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
const getMergedItems = (dayId) => {
|
const getMergedItems = (dayId) => {
|
||||||
const da = getDayAssignments(dayId)
|
const da = getDayAssignments(dayId)
|
||||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||||
return [
|
const transport = getTransportForDay(dayId)
|
||||||
...da.map(a => ({ type: 'place', sortKey: a.order_index, data: a })),
|
|
||||||
...dn.map(n => ({ type: 'note', sortKey: n.sort_order, data: n })),
|
// Initialize positions for transports that don't have one yet
|
||||||
|
if (transport.some(r => r.day_plan_position == null)) {
|
||||||
|
initTransportPositions(dayId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build base list: untimed places + notes sorted by order_index/sort_order
|
||||||
|
const timedPlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) !== null)
|
||||||
|
const freePlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) === null)
|
||||||
|
|
||||||
|
const baseItems = [
|
||||||
|
...freePlaces.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||||
|
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
|
||||||
].sort((a, b) => a.sortKey - b.sortKey)
|
].sort((a, b) => a.sortKey - b.sortKey)
|
||||||
|
|
||||||
|
// Timed places + transports: compute sortKeys based on time, inserted among base items
|
||||||
|
const allTimed = [
|
||||||
|
...timedPlaces.map(a => ({ type: 'place' as const, data: a, minutes: parseTimeToMinutes(a.place?.place_time)! })),
|
||||||
|
...transport.map(r => ({ type: 'transport' as const, data: r, minutes: parseTimeToMinutes(r.reservation_time) ?? 0 })),
|
||||||
|
].sort((a, b) => a.minutes - b.minutes)
|
||||||
|
|
||||||
|
if (allTimed.length === 0) return baseItems
|
||||||
|
if (baseItems.length === 0) {
|
||||||
|
return allTimed.map((item, i) => ({ ...item, sortKey: i }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert timed items among base items using time-to-position mapping.
|
||||||
|
// Each timed item finds the last base place whose order_index corresponds
|
||||||
|
// to a reasonable position, then gets a fractional sortKey after it.
|
||||||
|
const result = [...baseItems]
|
||||||
|
for (let ti = 0; ti < allTimed.length; ti++) {
|
||||||
|
const timed = allTimed[ti]
|
||||||
|
const minutes = timed.minutes
|
||||||
|
|
||||||
|
// For transports, use persisted position if available
|
||||||
|
if (timed.type === 'transport' && timed.data.day_plan_position != null) {
|
||||||
|
result.push({ type: timed.type, sortKey: timed.data.day_plan_position, data: timed.data })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find insertion position: after the last base item with time <= this item's time
|
||||||
|
let insertAfterKey = -Infinity
|
||||||
|
for (const item of result) {
|
||||||
|
if (item.type === 'place') {
|
||||||
|
const pm = parseTimeToMinutes(item.data?.place?.place_time)
|
||||||
|
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
|
||||||
|
} else if (item.type === 'transport') {
|
||||||
|
const tm = parseTimeToMinutes(item.data?.reservation_time)
|
||||||
|
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
|
||||||
|
const sortKey = insertAfterKey === -Infinity
|
||||||
|
? lastKey + 0.5 + ti * 0.01
|
||||||
|
: insertAfterKey + 0.01 + ti * 0.001
|
||||||
|
|
||||||
|
result.push({ type: timed.type, sortKey, data: timed.data })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sort((a, b) => a.sortKey - b.sortKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openAddNote = (dayId, e) => {
|
const openAddNote = (dayId, e) => {
|
||||||
@@ -195,6 +330,41 @@ export default function DayPlanSidebar({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if a proposed reorder of place IDs would break chronological order
|
||||||
|
// of ALL timed items (places with time + transport bookings)
|
||||||
|
const wouldBreakChronology = (dayId: number, newPlaceIds: number[]) => {
|
||||||
|
const da = getDayAssignments(dayId)
|
||||||
|
const transport = getTransportForDay(dayId)
|
||||||
|
|
||||||
|
// Simulate the merged list with places in new order + transports at their positions
|
||||||
|
// Places get sequential integer positions
|
||||||
|
const simItems: { pos: number; minutes: number }[] = []
|
||||||
|
newPlaceIds.forEach((id, idx) => {
|
||||||
|
const a = da.find(x => x.id === id)
|
||||||
|
const m = parseTimeToMinutes(a?.place?.place_time)
|
||||||
|
if (m !== null) simItems.push({ pos: idx, minutes: m })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transports: compute where they'd go with the new place order
|
||||||
|
for (const r of transport) {
|
||||||
|
const rMin = parseTimeToMinutes(r.reservation_time)
|
||||||
|
if (rMin === null) continue
|
||||||
|
// Find the last place (in new order) with time <= transport time
|
||||||
|
let afterIdx = -1
|
||||||
|
newPlaceIds.forEach((id, idx) => {
|
||||||
|
const a = da.find(x => x.id === id)
|
||||||
|
const pm = parseTimeToMinutes(a?.place?.place_time)
|
||||||
|
if (pm !== null && pm <= rMin) afterIdx = idx
|
||||||
|
})
|
||||||
|
const pos = afterIdx >= 0 ? afterIdx + 0.5 : newPlaceIds.length + 0.5
|
||||||
|
simItems.push({ pos, minutes: rMin })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by position and check chronological order
|
||||||
|
simItems.sort((a, b) => a.pos - b.pos)
|
||||||
|
return !simItems.every((item, i) => i === 0 || item.minutes >= simItems[i - 1].minutes)
|
||||||
|
}
|
||||||
|
|
||||||
const openEditNote = (dayId, note, e) => {
|
const openEditNote = (dayId, note, e) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
_openEditNote(dayId, note)
|
_openEditNote(dayId, note)
|
||||||
@@ -205,49 +375,180 @@ export default function DayPlanSidebar({
|
|||||||
await _deleteNote(dayId, noteId)
|
await _deleteNote(dayId, noteId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
// Unified reorder: assigns positions to ALL item types based on new visual order
|
||||||
const m = getMergedItems(dayId)
|
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
|
||||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
// Places get sequential integer positions (0, 1, 2, ...)
|
||||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
// Non-place items between place N-1 and place N get fractional positions
|
||||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
const assignmentIds: number[] = []
|
||||||
|
const noteUpdates: { id: number; sort_order: number }[] = []
|
||||||
|
const transportUpdates: { id: number; day_plan_position: number }[] = []
|
||||||
|
|
||||||
// Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standard), oder NACH dem Ziel wenn insertAfter
|
let placeCount = 0
|
||||||
const newOrder = [...m]
|
let i = 0
|
||||||
const [moved] = newOrder.splice(fromIdx, 1)
|
while (i < newOrder.length) {
|
||||||
let adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx
|
if (newOrder[i].type === 'place') {
|
||||||
if (insertAfter) adjustedTo += 1
|
assignmentIds.push(newOrder[i].data.id)
|
||||||
newOrder.splice(adjustedTo, 0, moved)
|
placeCount++
|
||||||
|
i++
|
||||||
// Orte: neuer order_index über onReorder
|
} else {
|
||||||
const assignmentIds = newOrder.filter(i => i.type === 'place').map(i => i.data.id)
|
// Collect consecutive non-place items
|
||||||
|
const group: { type: string; data: any }[] = []
|
||||||
// Notizen: sort_order muss ZWISCHEN den umgebenden order_indices der Orte liegen, niemals gleich sein.
|
while (i < newOrder.length && newOrder[i].type !== 'place') {
|
||||||
// Formel: Notiz zwischen placesBefore-1 und placesBefore ergibt (placesBefore - 1) + rank/(count+1)
|
group.push(newOrder[i])
|
||||||
// z.B. einzelne Notiz nach 2 Orten → (2-1) + 0.5 = 1.5 (zwischen order_index 1 und 2)
|
i++
|
||||||
const groups = {}
|
}
|
||||||
let pc = 0
|
// Fractional positions between (placeCount-1) and placeCount
|
||||||
newOrder.forEach(item => {
|
const base = placeCount > 0 ? placeCount - 1 : -1
|
||||||
if (item.type === 'place') { pc++ }
|
group.forEach((g, idx) => {
|
||||||
else { if (!groups[pc]) groups[pc] = []; groups[pc].push(item.data.id) }
|
const pos = base + (idx + 1) / (group.length + 1)
|
||||||
})
|
if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos })
|
||||||
const noteChanges = []
|
else if (g.type === 'transport') transportUpdates.push({ id: g.data.id, day_plan_position: pos })
|
||||||
Object.entries(groups).forEach(([pb, ids]) => {
|
})
|
||||||
ids.forEach((id, i) => {
|
}
|
||||||
noteChanges.push({ id, sort_order: (Number(pb) - 1) + (i + 1) / (ids.length + 1) })
|
}
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
||||||
for (const n of noteChanges) {
|
for (const n of noteUpdates) {
|
||||||
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
||||||
}
|
}
|
||||||
|
if (transportUpdates.length) {
|
||||||
|
for (const tu of transportUpdates) {
|
||||||
|
const res = reservations.find(r => r.id === tu.id)
|
||||||
|
if (res) res.day_plan_position = tu.day_plan_position
|
||||||
|
}
|
||||||
|
await reservationsApi.updatePositions(tripId, transportUpdates)
|
||||||
|
}
|
||||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
||||||
|
// Transport bookings themselves cannot be dragged
|
||||||
|
if (fromType === 'transport') {
|
||||||
|
toast.error(t('dayplan.cannotReorderTransport'))
|
||||||
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = getMergedItems(dayId)
|
||||||
|
|
||||||
|
// Check if a timed place is being moved → would it break chronological order?
|
||||||
|
if (fromType === 'place') {
|
||||||
|
const fromItem = m.find(i => i.type === 'place' && i.data.id === fromId)
|
||||||
|
const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time)
|
||||||
|
if (fromItem && fromMinutes !== null) {
|
||||||
|
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||||
|
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||||
|
if (fromIdx !== -1 && toIdx !== -1) {
|
||||||
|
const simulated = [...m]
|
||||||
|
const [moved] = simulated.splice(fromIdx, 1)
|
||||||
|
let insertIdx = simulated.findIndex(i => i.type === toType && i.data.id === toId)
|
||||||
|
if (insertIdx === -1) insertIdx = simulated.length
|
||||||
|
if (insertAfter) insertIdx += 1
|
||||||
|
simulated.splice(insertIdx, 0, moved)
|
||||||
|
|
||||||
|
const timedInOrder = simulated
|
||||||
|
.map(i => {
|
||||||
|
if (i.type === 'transport') return parseTimeToMinutes(i.data?.reservation_time)
|
||||||
|
if (i.type === 'place') return parseTimeToMinutes(i.data?.place?.place_time)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.filter(t => t !== null)
|
||||||
|
const isChronological = timedInOrder.every((t, i) => i === 0 || t >= timedInOrder[i - 1])
|
||||||
|
|
||||||
|
if (!isChronological) {
|
||||||
|
const placeTime = fromItem.data.place.place_time
|
||||||
|
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
|
||||||
|
setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, time: timeStr })
|
||||||
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new order: remove the dragged item, insert at target position
|
||||||
|
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||||
|
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||||
|
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) {
|
||||||
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOrder = [...m]
|
||||||
|
const [moved] = newOrder.splice(fromIdx, 1)
|
||||||
|
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
|
||||||
|
if (adjustedTo === -1) adjustedTo = newOrder.length
|
||||||
|
if (insertAfter) adjustedTo += 1
|
||||||
|
newOrder.splice(adjustedTo, 0, moved)
|
||||||
|
|
||||||
|
await applyMergedOrder(dayId, newOrder)
|
||||||
setDraggingId(null)
|
setDraggingId(null)
|
||||||
setDropTargetKey(null)
|
setDropTargetKey(null)
|
||||||
dragDataRef.current = null
|
dragDataRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const confirmTimeRemoval = async () => {
|
||||||
|
if (!timeConfirm) return
|
||||||
|
const saved = { ...timeConfirm }
|
||||||
|
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter } = saved
|
||||||
|
setTimeConfirm(null)
|
||||||
|
|
||||||
|
// Remove time from assignment
|
||||||
|
try {
|
||||||
|
await assignmentsApi.updateTime(tripId, fromId, { place_time: null, end_time: null })
|
||||||
|
const key = String(dayId)
|
||||||
|
const currentAssignments = { ...assignments }
|
||||||
|
if (currentAssignments[key]) {
|
||||||
|
currentAssignments[key] = currentAssignments[key].map(a =>
|
||||||
|
a.id === fromId ? { ...a, place: { ...a.place, place_time: null, end_time: null } } : a
|
||||||
|
)
|
||||||
|
tripStore.setAssignments(currentAssignments)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Unknown error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new merged order from either arrow reorderIds or drag & drop params
|
||||||
|
const m = getMergedItems(dayId)
|
||||||
|
|
||||||
|
if (reorderIds) {
|
||||||
|
// Arrow reorder: rebuild merged list with places in the new order,
|
||||||
|
// keeping transports and notes at their relative positions
|
||||||
|
const newMerged: typeof m = []
|
||||||
|
let rIdx = 0
|
||||||
|
for (const item of m) {
|
||||||
|
if (item.type === 'place') {
|
||||||
|
// Replace with the place from reorderIds at this position
|
||||||
|
const nextId = reorderIds[rIdx++]
|
||||||
|
const replacement = m.find(i => i.type === 'place' && i.data.id === nextId)
|
||||||
|
if (replacement) newMerged.push(replacement)
|
||||||
|
} else {
|
||||||
|
newMerged.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await applyMergedOrder(dayId, newMerged)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag & drop reorder
|
||||||
|
if (fromType && toType) {
|
||||||
|
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||||
|
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||||
|
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
||||||
|
|
||||||
|
const newOrder = [...m]
|
||||||
|
const [moved] = newOrder.splice(fromIdx, 1)
|
||||||
|
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
|
||||||
|
if (adjustedTo === -1) adjustedTo = newOrder.length
|
||||||
|
if (insertAfter) adjustedTo += 1
|
||||||
|
newOrder.splice(adjustedTo, 0, moved)
|
||||||
|
|
||||||
|
await applyMergedOrder(dayId, newOrder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const moveNote = async (dayId, noteId, direction) => {
|
const moveNote = async (dayId, noteId, direction) => {
|
||||||
await _moveNote(dayId, noteId, direction, getMergedItems)
|
await _moveNote(dayId, noteId, direction, getMergedItems)
|
||||||
}
|
}
|
||||||
@@ -396,7 +697,7 @@ export default function DayPlanSidebar({
|
|||||||
notes.map(n => ({ ...n, day_id: Number(dayId) }))
|
notes.map(n => ({ ...n, day_id: Number(dayId) }))
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, t, locale })
|
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('PDF error:', e)
|
console.error('PDF error:', e)
|
||||||
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
|
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
|
||||||
@@ -413,6 +714,34 @@ export default function DayPlanSidebar({
|
|||||||
<FileDown size={13} strokeWidth={2} />
|
<FileDown size={13} strokeWidth={2} />
|
||||||
{t('dayplan.pdf')}
|
{t('dayplan.pdf')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` },
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${trip?.title || 'trip'}.ics`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch { toast.error('ICS export failed') }
|
||||||
|
}}
|
||||||
|
title={t('dayplan.icsTooltip')}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 5,
|
||||||
|
padding: '5px 10px', borderRadius: 8,
|
||||||
|
border: '1px solid var(--border-primary)', background: 'none',
|
||||||
|
color: 'var(--text-muted)', fontSize: 11, fontWeight: 500,
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileDown size={13} strokeWidth={2} />
|
||||||
|
ICS
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -492,6 +821,18 @@ export default function DayPlanSidebar({
|
|||||||
</button>
|
</button>
|
||||||
{(() => {
|
{(() => {
|
||||||
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
||||||
|
// Sort: check-out first, then ongoing stays, then check-in last
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id
|
||||||
|
const bIsOut = b.end_day_id === day.id && b.start_day_id !== day.id
|
||||||
|
const aIsIn = a.start_day_id === day.id
|
||||||
|
const bIsIn = b.start_day_id === day.id
|
||||||
|
if (aIsOut && !bIsOut) return -1
|
||||||
|
if (!aIsOut && bIsOut) return 1
|
||||||
|
if (aIsIn && !bIsIn) return 1
|
||||||
|
if (!aIsIn && bIsIn) return -1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
if (dayAccs.length === 0) return null
|
if (dayAccs.length === 0) return null
|
||||||
return dayAccs.map(acc => {
|
return dayAccs.map(acc => {
|
||||||
const isCheckIn = acc.start_day_id === day.id
|
const isCheckIn = acc.start_day_id === day.id
|
||||||
@@ -542,11 +883,34 @@ export default function DayPlanSidebar({
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div
|
<div
|
||||||
style={{ background: 'var(--bg-hover)', paddingTop: 6 }}
|
style={{ background: 'var(--bg-hover)', paddingTop: 6 }}
|
||||||
onDragOver={e => { e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }}
|
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
|
||||||
onDrop={e => {
|
onDrop={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const { assignmentId, noteId, fromDayId } = getDragData(e)
|
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
// Drop on transport card (detected via dropTargetRef for sync accuracy)
|
||||||
|
if (dropTargetRef.current?.startsWith('transport-')) {
|
||||||
|
const transportId = Number(dropTargetRef.current.replace('transport-', ''))
|
||||||
|
|
||||||
|
if (placeId) {
|
||||||
|
onAssignToDay?.(parseInt(placeId), day.id)
|
||||||
|
} else if (assignmentId && fromDayId !== day.id) {
|
||||||
|
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
|
} else if (assignmentId) {
|
||||||
|
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId)
|
||||||
|
} else if (noteId && fromDayId !== day.id) {
|
||||||
|
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
|
} else if (noteId) {
|
||||||
|
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId)
|
||||||
|
}
|
||||||
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assignmentId && !noteId && !placeId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||||
|
if (placeId) {
|
||||||
|
onAssignToDay?.(parseInt(placeId), day.id)
|
||||||
|
setDropTargetKey(null); window.__dragData = null; return
|
||||||
|
}
|
||||||
if (assignmentId && fromDayId !== day.id) {
|
if (assignmentId && fromDayId !== day.id) {
|
||||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||||
@@ -577,7 +941,7 @@ export default function DayPlanSidebar({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
merged.map((item, idx) => {
|
merged.map((item, idx) => {
|
||||||
const itemKey = item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`
|
const itemKey = item.type === 'transport' ? `transport-${item.data.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
|
||||||
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
|
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
|
||||||
|
|
||||||
if (item.type === 'place') {
|
if (item.type === 'place') {
|
||||||
@@ -590,20 +954,39 @@ export default function DayPlanSidebar({
|
|||||||
const isHovered = hoveredId === assignment.id
|
const isHovered = hoveredId === assignment.id
|
||||||
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
|
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||||
|
|
||||||
const moveUp = (e) => {
|
const arrowMove = (direction: 'up' | 'down') => {
|
||||||
e.stopPropagation()
|
const m = getMergedItems(day.id)
|
||||||
if (placeIdx === 0) return
|
const myIdx = m.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
|
||||||
const ids = placeItems.map(i => i.data.id)
|
if (myIdx === -1) return
|
||||||
;[ids[placeIdx - 1], ids[placeIdx]] = [ids[placeIdx], ids[placeIdx - 1]]
|
const targetIdx = direction === 'up' ? myIdx - 1 : myIdx + 1
|
||||||
onReorder(day.id, ids)
|
if (targetIdx < 0 || targetIdx >= m.length) return
|
||||||
}
|
|
||||||
const moveDown = (e) => {
|
// Build new order: swap this item with its neighbor in the merged list
|
||||||
e.stopPropagation()
|
const newOrder = [...m]
|
||||||
if (placeIdx === placeItems.length - 1) return
|
;[newOrder[myIdx], newOrder[targetIdx]] = [newOrder[targetIdx], newOrder[myIdx]]
|
||||||
const ids = placeItems.map(i => i.data.id)
|
|
||||||
;[ids[placeIdx], ids[placeIdx + 1]] = [ids[placeIdx + 1], ids[placeIdx]]
|
// Check chronological order of all timed items in the new order
|
||||||
onReorder(day.id, ids)
|
const placeTime = place.place_time
|
||||||
|
if (parseTimeToMinutes(placeTime) !== null) {
|
||||||
|
const timedInNewOrder = newOrder
|
||||||
|
.map(i => {
|
||||||
|
if (i.type === 'transport') return parseTimeToMinutes(i.data?.reservation_time)
|
||||||
|
if (i.type === 'place') return parseTimeToMinutes(i.data?.place?.place_time)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.filter(t => t !== null)
|
||||||
|
const isChronological = timedInNewOrder.every((t, i) => i === 0 || t >= timedInNewOrder[i - 1])
|
||||||
|
if (!isChronological) {
|
||||||
|
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
|
||||||
|
// Store the new merged order for confirm action
|
||||||
|
setTimeConfirm({ dayId: day.id, fromId: assignment.id, time: timeStr, reorderIds: newOrder.filter(i => i.type === 'place').map(i => i.data.id) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyMergedOrder(day.id, newOrder)
|
||||||
}
|
}
|
||||||
|
const moveUp = (e) => { e.stopPropagation(); arrowMove('up') }
|
||||||
|
const moveDown = (e) => { e.stopPropagation(); arrowMove('down') }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={`place-${assignment.id}`}>
|
<React.Fragment key={`place-${assignment.id}`}>
|
||||||
@@ -763,7 +1146,7 @@ export default function DayPlanSidebar({
|
|||||||
marginLeft: pi > 0 ? -4 : 0, flexShrink: 0,
|
marginLeft: pi > 0 ? -4 : 0, flexShrink: 0,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
{p.avatar ? <img src={p.avatar} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : p.username?.[0]?.toUpperCase()}
|
{p.avatar ? <img src={`/uploads/avatars/${p.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : p.username?.[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{assignment.participants.length > 5 && (
|
{assignment.participants.length > 5 && (
|
||||||
@@ -773,10 +1156,10 @@ export default function DayPlanSidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||||
<button onClick={moveUp} disabled={placeIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === 0 ? 'default' : 'pointer', color: placeIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
<button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||||
<ChevronUp size={12} strokeWidth={2} />
|
<ChevronUp size={12} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={moveDown} disabled={placeIdx === placeItems.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === placeItems.length - 1 ? 'default' : 'pointer', color: placeIdx === placeItems.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
<button onClick={moveDown} disabled={idx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === merged.length - 1 ? 'default' : 'pointer', color: idx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||||
<ChevronDown size={12} strokeWidth={2} />
|
<ChevronDown size={12} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -785,6 +1168,90 @@ export default function DayPlanSidebar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Transport booking (flight, train, bus, car, cruise)
|
||||||
|
if (item.type === 'transport') {
|
||||||
|
const res = item.data
|
||||||
|
const TransportIcon = RES_ICONS[res.type] || Ticket
|
||||||
|
const color = '#3b82f6'
|
||||||
|
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||||
|
const isTransportHovered = hoveredId === `transport-${res.id}`
|
||||||
|
|
||||||
|
// Subtitle aus Metadaten zusammensetzen
|
||||||
|
let subtitle = ''
|
||||||
|
if (res.type === 'flight') {
|
||||||
|
const parts = [meta.airline, meta.flight_number].filter(Boolean)
|
||||||
|
if (meta.departure_airport || meta.arrival_airport)
|
||||||
|
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
||||||
|
subtitle = parts.join(' · ')
|
||||||
|
} else if (res.type === 'train') {
|
||||||
|
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={`transport-${res.id}`}>
|
||||||
|
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||||
|
<div
|
||||||
|
onClick={() => setTransportDetail(res)}
|
||||||
|
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`transport-${res.id}`) }}
|
||||||
|
onDrop={e => {
|
||||||
|
e.preventDefault(); e.stopPropagation()
|
||||||
|
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
|
||||||
|
if (placeId) {
|
||||||
|
onAssignToDay?.(parseInt(placeId), day.id)
|
||||||
|
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||||
|
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
|
} else if (fromAssignmentId) {
|
||||||
|
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id)
|
||||||
|
} else if (noteId && fromDayId !== day.id) {
|
||||||
|
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||||
|
} else if (noteId) {
|
||||||
|
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id)
|
||||||
|
}
|
||||||
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredId(`transport-${res.id}`)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '7px 8px 7px 10px',
|
||||||
|
margin: '1px 8px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: `1px solid ${color}33`,
|
||||||
|
background: isTransportHovered ? `${color}12` : `${color}08`,
|
||||||
|
cursor: 'pointer', userSelect: 'none',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
borderRadius: '50%', background: `${color}18`,
|
||||||
|
}}>
|
||||||
|
<TransportIcon size={14} strokeWidth={1.8} color={color} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{res.title}
|
||||||
|
</span>
|
||||||
|
{res.reservation_time?.includes('T') && (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
||||||
|
<Clock size={9} strokeWidth={2} />
|
||||||
|
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||||
|
{res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Notizkarte
|
// Notizkarte
|
||||||
const note = item.data
|
const note = item.data
|
||||||
const isNoteHovered = hoveredId === `note-${note.id}`
|
const isNoteHovered = hoveredId === `note-${note.id}`
|
||||||
@@ -991,6 +1458,199 @@ export default function DayPlanSidebar({
|
|||||||
document.body
|
document.body
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Confirm: remove time when reordering a timed place */}
|
||||||
|
{timeConfirm && ReactDOM.createPortal(
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 1000,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||||
|
}} onClick={() => setTimeConfirm(null)}>
|
||||||
|
<div style={{
|
||||||
|
width: 340, background: 'var(--bg-card)', borderRadius: 16,
|
||||||
|
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 12,
|
||||||
|
}} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
borderRadius: '50%', background: 'rgba(239,68,68,0.12)',
|
||||||
|
}}>
|
||||||
|
<Clock size={18} strokeWidth={1.8} color="#ef4444" />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
{t('dayplan.confirmRemoveTimeTitle')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||||
|
{t('dayplan.confirmRemoveTimeBody', { time: timeConfirm.time })}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||||
|
<button onClick={() => setTimeConfirm(null)} style={{
|
||||||
|
fontSize: 12, background: 'none', border: '1px solid var(--border-primary)',
|
||||||
|
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit',
|
||||||
|
}}>{t('common.cancel')}</button>
|
||||||
|
<button onClick={confirmTimeRemoval} style={{
|
||||||
|
fontSize: 12, background: '#ef4444', color: 'white',
|
||||||
|
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||||
|
}}>{t('common.confirm')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transport-Detail-Modal */}
|
||||||
|
{transportDetail && ReactDOM.createPortal(
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 1000,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||||
|
}} onClick={() => setTransportDetail(null)}>
|
||||||
|
<div style={{
|
||||||
|
width: 380, maxHeight: '80vh', overflowY: 'auto',
|
||||||
|
background: 'var(--bg-card)', borderRadius: 16,
|
||||||
|
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 14,
|
||||||
|
}} onClick={e => e.stopPropagation()}>
|
||||||
|
{(() => {
|
||||||
|
const res = transportDetail
|
||||||
|
const TransportIcon = RES_ICONS[res.type] || Ticket
|
||||||
|
const TRANSPORT_COLORS = { flight: '#3b82f6', train: '#06b6d4', bus: '#f59e0b', car: '#6b7280', cruise: '#0ea5e9' }
|
||||||
|
const color = TRANSPORT_COLORS[res.type] || 'var(--text-muted)'
|
||||||
|
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||||
|
|
||||||
|
const detailFields = []
|
||||||
|
if (res.type === 'flight') {
|
||||||
|
if (meta.airline) detailFields.push({ label: t('reservations.meta.airline'), value: meta.airline })
|
||||||
|
if (meta.flight_number) detailFields.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
|
||||||
|
if (meta.departure_airport) detailFields.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
|
||||||
|
if (meta.arrival_airport) detailFields.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
|
||||||
|
if (meta.seat) detailFields.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||||
|
} else if (res.type === 'train') {
|
||||||
|
if (meta.train_number) detailFields.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||||
|
if (meta.platform) detailFields.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||||
|
if (meta.seat) detailFields.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||||
|
}
|
||||||
|
if (res.confirmation_number) detailFields.push({ label: t('reservations.confirmationCode'), value: res.confirmation_number, sensitive: true })
|
||||||
|
if (res.location) detailFields.push({ label: t('reservations.locationAddress'), value: res.location })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
borderRadius: '50%', background: `${color}18`,
|
||||||
|
}}>
|
||||||
|
<TransportIcon size={18} strokeWidth={1.8} color={color} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2 }}>
|
||||||
|
{res.reservation_time?.includes('T')
|
||||||
|
? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||||
|
: res.reservation_time
|
||||||
|
? new Date(res.reservation_time + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
{res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '3px 8px', borderRadius: 6, fontSize: 10, fontWeight: 600,
|
||||||
|
background: res.status === 'confirmed' ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
|
||||||
|
color: res.status === 'confirmed' ? '#16a34a' : '#d97706',
|
||||||
|
}}>
|
||||||
|
{(res.status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')).replace(/\s*·\s*$/, '')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail-Felder */}
|
||||||
|
{detailFields.length > 0 && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||||
|
{detailFields.map((f, i) => {
|
||||||
|
const shouldBlur = f.sensitive && useSettingsStore.getState().settings.blur_booking_codes
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{f.label}</div>
|
||||||
|
<div
|
||||||
|
onMouseEnter={e => { if (shouldBlur) e.currentTarget.style.filter = 'none' }}
|
||||||
|
onMouseLeave={e => { if (shouldBlur) e.currentTarget.style.filter = 'blur(5px)' }}
|
||||||
|
onClick={e => { if (shouldBlur) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(5px)' : 'none' } }}
|
||||||
|
style={{
|
||||||
|
fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', wordBreak: 'break-word',
|
||||||
|
filter: shouldBlur ? 'blur(5px)' : 'none', transition: 'filter 0.2s',
|
||||||
|
cursor: shouldBlur ? 'pointer' : 'default',
|
||||||
|
userSelect: shouldBlur ? 'none' : 'auto',
|
||||||
|
}}
|
||||||
|
>{f.value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notizen */}
|
||||||
|
{res.notes && (
|
||||||
|
<div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{res.notes}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dateien */}
|
||||||
|
{(() => {
|
||||||
|
const resFiles = (tripStore.files || []).filter(f =>
|
||||||
|
!f.deleted_at && (
|
||||||
|
f.reservation_id === res.id ||
|
||||||
|
(f.linked_reservation_ids && f.linked_reservation_ids.includes(res.id))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (resFiles.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 6 }}>{t('files.title')}</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{resFiles.map(f => (
|
||||||
|
<div key={f.id}
|
||||||
|
onClick={() => { setTransportDetail(null); onNavigateToFiles?.() }}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px',
|
||||||
|
background: 'var(--bg-tertiary)', borderRadius: 8, cursor: 'pointer',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||||
|
>
|
||||||
|
<FileText size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
|
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{f.original_name}
|
||||||
|
</span>
|
||||||
|
<ExternalLink size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Schließen */}
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<button onClick={() => setTransportDetail(null)} style={{
|
||||||
|
fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)',
|
||||||
|
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
{t('common.close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Budget-Fußzeile */}
|
{/* Budget-Fußzeile */}
|
||||||
{totalCost > 0 && (
|
{totalCost > 0 && (
|
||||||
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
|||||||
@@ -104,6 +104,24 @@ export default function PlaceFormModal({
|
|||||||
if (!mapsSearch.trim()) return
|
if (!mapsSearch.trim()) return
|
||||||
setIsSearchingMaps(true)
|
setIsSearchingMaps(true)
|
||||||
try {
|
try {
|
||||||
|
// Detect Google Maps URLs and resolve them directly
|
||||||
|
const trimmed = mapsSearch.trim()
|
||||||
|
if (trimmed.match(/^https?:\/\/(www\.)?(google\.[a-z.]+\/maps|maps\.google\.[a-z.]+|maps\.app\.goo\.gl|goo\.gl)/i)) {
|
||||||
|
const resolved = await mapsApi.resolveUrl(trimmed)
|
||||||
|
if (resolved.lat && resolved.lng) {
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
name: resolved.name || prev.name,
|
||||||
|
address: resolved.address || prev.address,
|
||||||
|
lat: String(resolved.lat),
|
||||||
|
lng: String(resolved.lng),
|
||||||
|
}))
|
||||||
|
setMapsResults([])
|
||||||
|
setMapsSearch('')
|
||||||
|
toast.success(t('places.urlResolved'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
const result = await mapsApi.search(mapsSearch, language)
|
const result = await mapsApi.search(mapsSearch, language)
|
||||||
setMapsResults(result.places || [])
|
setMapsResults(result.places || [])
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -281,6 +299,15 @@ export default function PlaceFormModal({
|
|||||||
step="any"
|
step="any"
|
||||||
value={form.lat}
|
value={form.lat}
|
||||||
onChange={e => handleChange('lat', e.target.value)}
|
onChange={e => handleChange('lat', e.target.value)}
|
||||||
|
onPaste={e => {
|
||||||
|
const text = e.clipboardData.getData('text').trim()
|
||||||
|
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
|
||||||
|
if (match) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleChange('lat', match[1])
|
||||||
|
handleChange('lng', match[2])
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder={t('places.formLat')}
|
placeholder={t('places.formLat')}
|
||||||
className="form-input"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -120,12 +120,15 @@ interface PlaceInspectorProps {
|
|||||||
tripMembers?: TripMember[]
|
tripMembers?: TripMember[]
|
||||||
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
||||||
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
|
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
|
||||||
|
leftWidth?: number
|
||||||
|
rightWidth?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlaceInspector({
|
export default function PlaceInspector({
|
||||||
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
|
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
|
||||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||||
files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace,
|
files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace,
|
||||||
|
leftWidth = 0, rightWidth = 0,
|
||||||
}: PlaceInspectorProps) {
|
}: PlaceInspectorProps) {
|
||||||
const { t, locale, language } = useTranslation()
|
const { t, locale, language } = useTranslation()
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||||
@@ -169,7 +172,7 @@ export default function PlaceInspector({
|
|||||||
const selectedDay = days?.find(d => d.id === selectedDayId)
|
const selectedDay = days?.find(d => d.id === selectedDayId)
|
||||||
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
|
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
|
||||||
|
|
||||||
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id))
|
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id) || (f.linked_place_ids || []).includes(place.id))
|
||||||
|
|
||||||
const handleFileUpload = useCallback(async (e) => {
|
const handleFileUpload = useCallback(async (e) => {
|
||||||
const selectedFiles = Array.from((e.target as HTMLInputElement).files || [])
|
const selectedFiles = Array.from((e.target as HTMLInputElement).files || [])
|
||||||
@@ -196,9 +199,9 @@ export default function PlaceInspector({
|
|||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 20,
|
bottom: 20,
|
||||||
left: '50%',
|
left: `calc(${leftWidth}px + (100% - ${leftWidth}px - ${rightWidth}px) / 2)`,
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
width: 'min(800px, calc(100vw - 32px))',
|
width: `min(800px, calc(100% - ${leftWidth}px - ${rightWidth}px - 32px))`,
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
}}
|
}}
|
||||||
@@ -312,7 +315,7 @@ export default function PlaceInspector({
|
|||||||
icon={<Star size={12} fill="#facc15" color="#facc15" />}
|
icon={<Star size={12} fill="#facc15" color="#facc15" />}
|
||||||
text={<>
|
text={<>
|
||||||
{googleDetails.rating.toFixed(1)}
|
{googleDetails.rating.toFixed(1)}
|
||||||
{googleDetails.rating_count ? <span style={{ opacity: 0.5 }}> ({googleDetails.rating_count.toLocaleString('de-DE')})</span> : ''}
|
{googleDetails.rating_count ? <span style={{ opacity: 0.5 }}> ({googleDetails.rating_count.toLocaleString(locale)})</span> : ''}
|
||||||
{shortReview && <span className="hidden md:inline" style={{ opacity: 0.6, fontWeight: 400, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> · „{shortReview.text}"</span>}
|
{shortReview && <span className="hidden md:inline" style={{ opacity: 0.6, fontWeight: 400, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> · „{shortReview.text}"</span>}
|
||||||
</>}
|
</>}
|
||||||
color="var(--text-secondary)" bg="var(--bg-hover)"
|
color="var(--text-secondary)" bg="var(--bg-hover)"
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useState } from 'react'
|
import { useState, useRef, useMemo, useCallback } from 'react'
|
||||||
import DOM from 'react-dom'
|
import DOM from 'react-dom'
|
||||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation } from 'lucide-react'
|
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check } from 'lucide-react'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||||
|
import { placesApi } from '../../api/client'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||||
|
|
||||||
interface PlacesSidebarProps {
|
interface PlacesSidebarProps {
|
||||||
|
tripId: number
|
||||||
places: Place[]
|
places: Place[]
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
assignments: AssignmentsMap
|
assignments: AssignmentsMap
|
||||||
@@ -22,31 +27,59 @@ interface PlacesSidebarProps {
|
|||||||
onDeletePlace: (placeId: number) => void
|
onDeletePlace: (placeId: number) => void
|
||||||
days: Day[]
|
days: Day[]
|
||||||
isMobile: boolean
|
isMobile: boolean
|
||||||
|
onCategoryFilterChange?: (categoryId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlacesSidebar({
|
export default function PlacesSidebar({
|
||||||
places, categories, assignments, selectedDayId, selectedPlaceId,
|
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile,
|
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange,
|
||||||
}: PlacesSidebarProps) {
|
}: PlacesSidebarProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
const ctxMenu = useContextMenu()
|
const ctxMenu = useContextMenu()
|
||||||
|
const gpxInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const tripStore = useTripStore()
|
||||||
|
|
||||||
|
const handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
e.target.value = ''
|
||||||
|
try {
|
||||||
|
const result = await placesApi.importGpx(tripId, file)
|
||||||
|
await tripStore.loadTrip(tripId)
|
||||||
|
toast.success(t('places.gpxImported', { count: result.count }))
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.error || t('places.gpxError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [filter, setFilter] = useState('all')
|
const [filter, setFilter] = useState('all')
|
||||||
const [categoryFilter, setCategoryFilter] = useState('')
|
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const toggleCategoryFilter = (catId: string) => {
|
||||||
|
setCategoryFiltersLocal(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(catId)) next.delete(catId); else next.add(catId)
|
||||||
|
// Notify parent with first selected or empty
|
||||||
|
onCategoryFilterChange?.(next.size === 1 ? [...next][0] : '')
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
const [dayPickerPlace, setDayPickerPlace] = useState(null)
|
const [dayPickerPlace, setDayPickerPlace] = useState(null)
|
||||||
|
const [catDropOpen, setCatDropOpen] = useState(false)
|
||||||
|
|
||||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||||
const plannedIds = new Set(
|
const plannedIds = new Set(
|
||||||
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
||||||
)
|
)
|
||||||
|
|
||||||
const filtered = places.filter(p => {
|
const filtered = useMemo(() => places.filter(p => {
|
||||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||||
if (categoryFilter && String(p.category_id) !== String(categoryFilter)) return false
|
if (categoryFilters.size > 0 && !categoryFilters.has(String(p.category_id))) return false
|
||||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||||
return true
|
return true
|
||||||
})
|
}), [places, filter, categoryFilters, search, plannedIds.size])
|
||||||
|
|
||||||
const isAssignedToSelectedDay = (placeId) =>
|
const isAssignedToSelectedDay = (placeId) =>
|
||||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||||
@@ -66,6 +99,19 @@ export default function PlacesSidebar({
|
|||||||
>
|
>
|
||||||
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
||||||
</button>
|
</button>
|
||||||
|
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
|
||||||
|
<button
|
||||||
|
onClick={() => gpxInputRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
|
width: '100%', padding: '5px 12px', borderRadius: 8, marginBottom: 10,
|
||||||
|
border: '1px dashed var(--border-primary)', background: 'none',
|
||||||
|
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Filter-Tabs */}
|
{/* Filter-Tabs */}
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||||
@@ -100,21 +146,69 @@ export default function PlacesSidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Kategoriefilter */}
|
{/* Category multi-select dropdown */}
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (() => {
|
||||||
<div style={{ marginTop: 6 }}>
|
const label = categoryFilters.size === 0
|
||||||
<CustomSelect
|
? t('places.allCategories')
|
||||||
value={categoryFilter}
|
: categoryFilters.size === 1
|
||||||
onChange={setCategoryFilter}
|
? categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories')
|
||||||
placeholder={t('places.allCategories')}
|
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
||||||
size="sm"
|
return (
|
||||||
options={[
|
<div style={{ marginTop: 6, position: 'relative' }}>
|
||||||
{ value: '', label: t('places.allCategories') },
|
<button onClick={() => setCatDropOpen(v => !v)} style={{
|
||||||
...categories.map(c => ({ value: String(c.id), label: c.name }))
|
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
]}
|
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||||
/>
|
background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
|
||||||
</div>
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
)}
|
}}>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||||
|
<ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||||
|
</button>
|
||||||
|
{catDropOpen && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, maxHeight: 200, overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{categories.map(c => {
|
||||||
|
const active = categoryFilters.has(String(c.id))
|
||||||
|
const CatIcon = getCategoryIcon(c.icon)
|
||||||
|
return (
|
||||||
|
<button key={c.id} onClick={() => toggleCategoryFilter(String(c.id))} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||||
|
padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||||
|
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||||
|
border: active ? 'none' : '1.5px solid var(--border-primary)',
|
||||||
|
background: active ? (c.color || 'var(--accent)') : 'transparent',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{active && <Check size={10} strokeWidth={3} color="white" />}
|
||||||
|
</div>
|
||||||
|
<CatIcon size={12} strokeWidth={2} color={c.color || 'var(--text-muted)'} />
|
||||||
|
<span style={{ flex: 1 }}>{c.name}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{categoryFilters.size > 0 && (
|
||||||
|
<button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.('') }} style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||||
|
width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||||
|
background: 'transparent', fontFamily: 'inherit', fontSize: 11, color: 'var(--text-faint)',
|
||||||
|
marginTop: 2, borderTop: '1px solid var(--border-faint)',
|
||||||
|
}}>
|
||||||
|
<X size={10} /> {t('places.clearFilter')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Anzahl */}
|
{/* Anzahl */}
|
||||||
@@ -172,6 +266,8 @@ export default function PlacesSidebar({
|
|||||||
background: isSelected ? 'var(--border-faint)' : 'transparent',
|
background: isSelected ? 'var(--border-faint)' : 'transparent',
|
||||||
borderBottom: '1px solid var(--border-faint)',
|
borderBottom: '1px solid var(--border-faint)',
|
||||||
transition: 'background 0.1s',
|
transition: 'background 0.1s',
|
||||||
|
contentVisibility: 'auto',
|
||||||
|
containIntrinsicSize: '0 52px',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import apiClient from '../../api/client'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||||
@@ -62,6 +65,8 @@ interface ReservationModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) {
|
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) {
|
||||||
|
const { id: tripId } = useParams<{ id: string }>()
|
||||||
|
const loadFiles = useTripStore(s => s.loadFiles)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
@@ -78,6 +83,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [uploadingFile, setUploadingFile] = useState(false)
|
const [uploadingFile, setUploadingFile] = useState(false)
|
||||||
const [pendingFiles, setPendingFiles] = useState([])
|
const [pendingFiles, setPendingFiles] = useState([])
|
||||||
|
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||||
|
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||||
|
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
|
||||||
|
|
||||||
const assignmentOptions = useMemo(
|
const assignmentOptions = useMemo(
|
||||||
() => buildAssignmentOptions(days, assignments, t, locale),
|
() => buildAssignmentOptions(days, assignments, t, locale),
|
||||||
@@ -204,7 +212,13 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachedFiles = reservation?.id ? files.filter(f => f.reservation_id === reservation.id) : []
|
const attachedFiles = reservation?.id
|
||||||
|
? files.filter(f =>
|
||||||
|
f.reservation_id === reservation.id ||
|
||||||
|
linkedFileIds.includes(f.id) ||
|
||||||
|
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
|
||||||
const inputStyle = {
|
const inputStyle = {
|
||||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
@@ -459,11 +473,23 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}><ExternalLink size={11} /></a>
|
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}><ExternalLink size={11} /></a>
|
||||||
{onFileDelete && (
|
<button type="button" onClick={async () => {
|
||||||
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
// Always unlink, never delete the file
|
||||||
<X size={11} />
|
// Clear primary reservation_id if it points to this reservation
|
||||||
</button>
|
if (f.reservation_id === reservation?.id) {
|
||||||
)}
|
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
|
||||||
|
}
|
||||||
|
// Remove from file_links if linked there
|
||||||
|
try {
|
||||||
|
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
||||||
|
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
|
||||||
|
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
|
||||||
|
} catch {}
|
||||||
|
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
|
||||||
|
if (tripId) loadFiles(tripId)
|
||||||
|
}} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{pendingFiles.map((f, i) => (
|
{pendingFiles.map((f, i) => (
|
||||||
@@ -477,14 +503,56 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||||
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||||
}}>
|
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||||
<Paperclip size={11} />
|
}}>
|
||||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
<Paperclip size={11} />
|
||||||
</button>
|
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||||
|
</button>
|
||||||
|
{/* Link existing file picker */}
|
||||||
|
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||||
|
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||||
|
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Link2 size={11} /> {t('reservations.linkExisting')}
|
||||||
|
</button>
|
||||||
|
{showFilePicker && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220, maxHeight: 200, overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
|
||||||
|
<button key={f.id} type="button" onClick={async () => {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
|
||||||
|
setLinkedFileIds(prev => [...prev, f.id])
|
||||||
|
setShowFilePicker(false)
|
||||||
|
if (tripId) loadFiles(tripId)
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit',
|
||||||
|
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||||
|
<FileText size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -505,5 +573,5 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
function formatDate(dateStr, locale) {
|
function formatDate(dateStr, locale) {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return ''
|
||||||
const d = new Date(dateStr + 'T00:00:00')
|
const d = new Date(dateStr + 'T00:00:00')
|
||||||
return d.toLocaleDateString(locale || 'de-DE', { day: 'numeric', month: 'short' })
|
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
@@ -62,18 +63,21 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||||
|
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||||
|
const [codeRevealed, setCodeRevealed] = useState(false)
|
||||||
const typeInfo = getType(r.type)
|
const typeInfo = getType(r.type)
|
||||||
const TypeIcon = typeInfo.Icon
|
const TypeIcon = typeInfo.Icon
|
||||||
const confirmed = r.status === 'confirmed'
|
const confirmed = r.status === 'confirmed'
|
||||||
const attachedFiles = files.filter(f => f.reservation_id === r.id)
|
const attachedFiles = files.filter(f => f.reservation_id === r.id || (f.linked_reservation_ids || []).includes(r.id))
|
||||||
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
|
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
|
||||||
const handleToggle = async () => {
|
const handleToggle = async () => {
|
||||||
try { await toggleReservationStatus(tripId, r.id) }
|
try { await toggleReservationStatus(tripId, r.id) }
|
||||||
catch { toast.error(t('reservations.toast.updateError')) }
|
catch { toast.error(t('reservations.toast.updateError')) }
|
||||||
}
|
}
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!confirm(t('reservations.confirm.delete', { name: r.title }))) return
|
setShowDeleteConfirm(false)
|
||||||
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
|
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +108,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Pencil size={11} />
|
<Pencil size={11} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleDelete} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Trash2 size={11} />
|
<Trash2 size={11} />
|
||||||
@@ -134,7 +138,19 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
{r.confirmation_number && (
|
{r.confirmation_number && (
|
||||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center' }}>
|
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center' }}>
|
||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{r.confirmation_number}</div>
|
<div
|
||||||
|
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
|
||||||
|
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
|
||||||
|
onClick={() => blurCodes && setCodeRevealed(v => !v)}
|
||||||
|
style={{
|
||||||
|
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1,
|
||||||
|
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
|
||||||
|
cursor: blurCodes ? 'pointer' : 'default',
|
||||||
|
transition: 'filter 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.confirmation_number}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -227,6 +243,46 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Delete confirmation popup */}
|
||||||
|
{showDeleteConfirm && ReactDOM.createPortal(
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 1000,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||||
|
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||||
|
<div style={{
|
||||||
|
width: 340, background: 'var(--bg-card)', borderRadius: 16,
|
||||||
|
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 12,
|
||||||
|
}} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
borderRadius: '50%', background: 'rgba(239,68,68,0.12)',
|
||||||
|
}}>
|
||||||
|
<Trash2 size={18} strokeWidth={1.8} color="#ef4444" />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
{t('reservations.confirm.deleteTitle')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||||
|
{t('reservations.confirm.deleteBody', { name: r.title })}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||||
|
<button onClick={() => setShowDeleteConfirm(false)} style={{
|
||||||
|
fontSize: 12, background: 'none', border: '1px solid var(--border-primary)',
|
||||||
|
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit',
|
||||||
|
}}>{t('common.cancel')}</button>
|
||||||
|
<button onClick={handleDelete} style={{
|
||||||
|
fontSize: 12, background: '#ef4444', color: 'white',
|
||||||
|
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||||
|
}}>{t('common.confirm')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import { Calendar, Camera, X, Clipboard } from 'lucide-react'
|
import { Calendar, Camera, X, Clipboard, UserPlus } from 'lucide-react'
|
||||||
import { tripsApi } from '../../api/client'
|
import { tripsApi, authApi } from '../../api/client'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
@@ -20,6 +22,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
const fileRef = useRef(null)
|
const fileRef = useRef(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const currentUser = useAuthStore(s => s.user)
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
@@ -32,6 +35,9 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
const [coverPreview, setCoverPreview] = useState(null)
|
const [coverPreview, setCoverPreview] = useState(null)
|
||||||
const [pendingCoverFile, setPendingCoverFile] = useState(null)
|
const [pendingCoverFile, setPendingCoverFile] = useState(null)
|
||||||
const [uploadingCover, setUploadingCover] = useState(false)
|
const [uploadingCover, setUploadingCover] = useState(false)
|
||||||
|
const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([])
|
||||||
|
const [selectedMembers, setSelectedMembers] = useState<number[]>([])
|
||||||
|
const [memberSelectValue, setMemberSelectValue] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (trip) {
|
if (trip) {
|
||||||
@@ -47,7 +53,11 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
setCoverPreview(null)
|
setCoverPreview(null)
|
||||||
}
|
}
|
||||||
setPendingCoverFile(null)
|
setPendingCoverFile(null)
|
||||||
|
setSelectedMembers([])
|
||||||
setError('')
|
setError('')
|
||||||
|
if (!trip) {
|
||||||
|
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
|
||||||
|
}
|
||||||
}, [trip, isOpen])
|
}, [trip, isOpen])
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
@@ -65,6 +75,15 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
start_date: formData.start_date || null,
|
start_date: formData.start_date || null,
|
||||||
end_date: formData.end_date || null,
|
end_date: formData.end_date || null,
|
||||||
})
|
})
|
||||||
|
// Add selected members for newly created trips
|
||||||
|
if (selectedMembers.length > 0 && result?.trip?.id) {
|
||||||
|
for (const userId of selectedMembers) {
|
||||||
|
const user = allUsers.find(u => u.id === userId)
|
||||||
|
if (user) {
|
||||||
|
try { await tripsApi.addMember(result.trip.id, user.username) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Upload pending cover for newly created trips
|
// Upload pending cover for newly created trips
|
||||||
if (pendingCoverFile && result?.trip?.id) {
|
if (pendingCoverFile && result?.trip?.id) {
|
||||||
try {
|
try {
|
||||||
@@ -212,7 +231,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
|
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
|
||||||
style={{ width: '100%', padding: '18px', border: '2px dashed #e5e7eb', borderRadius: 10, background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: '#9ca3af', fontFamily: 'inherit' }}
|
onDragOver={e => { e.preventDefault(); e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.background = 'rgba(99,102,241,0.04)' }}
|
||||||
|
onDragLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.background = 'none' }}
|
||||||
|
onDrop={e => { e.preventDefault(); e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.background = 'none'; const file = e.dataTransfer.files?.[0]; if (file?.type.startsWith('image/')) handleCoverSelect(file) }}
|
||||||
|
style={{ width: '100%', padding: '18px', border: '2px dashed #e5e7eb', borderRadius: 10, background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: '#9ca3af', fontFamily: 'inherit', transition: 'all 0.15s' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
|
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
|
||||||
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
||||||
@@ -250,6 +272,46 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Members — only for new trips */}
|
||||||
|
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||||
|
<UserPlus className="inline w-4 h-4 mr-1" />{t('dashboard.addMembers')}
|
||||||
|
</label>
|
||||||
|
{selectedMembers.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
|
||||||
|
{selectedMembers.map(uid => {
|
||||||
|
const user = allUsers.find(u => u.id === uid)
|
||||||
|
if (!user) return null
|
||||||
|
return (
|
||||||
|
<span key={uid} onClick={() => setSelectedMembers(prev => prev.filter(id => id !== uid))}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 99,
|
||||||
|
background: 'var(--bg-secondary)', fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', cursor: 'pointer',
|
||||||
|
border: '1px solid var(--border-primary)',
|
||||||
|
}}>
|
||||||
|
{user.username}
|
||||||
|
<X size={11} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<CustomSelect
|
||||||
|
value={memberSelectValue}
|
||||||
|
onChange={value => {
|
||||||
|
if (value) { setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)]); setMemberSelectValue('') }
|
||||||
|
}}
|
||||||
|
placeholder={t('dashboard.addMember')}
|
||||||
|
options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id)).map(u => ({ value: u.id, label: u.username }))}
|
||||||
|
searchable
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!formData.start_date && !formData.end_date && (
|
{!formData.start_date && !formData.end_date && (
|
||||||
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
|
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
|
||||||
{t('dashboard.noDateHint')}
|
{t('dashboard.noDateHint')}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import { tripsApi, authApi } from '../../api/client'
|
import { tripsApi, authApi, shareApi } from '../../api/client'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { Crown, UserMinus, UserPlus, Users, LogOut } from 'lucide-react'
|
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { getApiErrorMessage } from '../../types'
|
import { getApiErrorMessage } from '../../types'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
@@ -32,6 +32,129 @@ function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ShareLinkSection({ tripId, t }: { tripId: number; t: any }) {
|
||||||
|
const [shareToken, setShareToken] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [perms, setPerms] = useState({ share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false })
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
shareApi.getLink(tripId).then(d => {
|
||||||
|
setShareToken(d.token)
|
||||||
|
if (d.token) setPerms({ share_map: d.share_map ?? true, share_bookings: d.share_bookings ?? true, share_packing: d.share_packing ?? false, share_budget: d.share_budget ?? false, share_collab: d.share_collab ?? false })
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(() => setLoading(false))
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
|
const shareUrl = shareToken ? `${window.location.origin}/shared/${shareToken}` : null
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
const d = await shareApi.createLink(tripId, perms)
|
||||||
|
setShareToken(d.token)
|
||||||
|
} catch { toast.error(t('share.createError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdatePerms = async (key: string, val: boolean) => {
|
||||||
|
const newPerms = { ...perms, [key]: val }
|
||||||
|
setPerms(newPerms)
|
||||||
|
if (shareToken) {
|
||||||
|
try { await shareApi.createLink(tripId, newPerms) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await shareApi.deleteLink(tripId)
|
||||||
|
setShareToken(null)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (shareUrl) {
|
||||||
|
navigator.clipboard.writeText(shareUrl)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||||
|
<Link2 size={14} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('share.linkTitle')}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 10, lineHeight: 1.5 }}>{t('share.linkHint')}</p>
|
||||||
|
|
||||||
|
{/* Permission checkboxes */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||||
|
{[
|
||||||
|
{ key: 'share_map', label: t('share.permMap'), always: true },
|
||||||
|
{ key: 'share_bookings', label: t('share.permBookings') },
|
||||||
|
{ key: 'share_packing', label: t('share.permPacking') },
|
||||||
|
{ key: 'share_budget', label: t('share.permBudget') },
|
||||||
|
{ key: 'share_collab', label: t('share.permCollab') },
|
||||||
|
].map(opt => (
|
||||||
|
<button key={opt.key} onClick={() => !opt.always && handleUpdatePerms(opt.key, !perms[opt.key])}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 20,
|
||||||
|
border: '1.5px solid', fontSize: 11, fontWeight: 500, cursor: opt.always ? 'default' : 'pointer',
|
||||||
|
fontFamily: 'inherit', transition: 'all 0.12s',
|
||||||
|
background: perms[opt.key] ? 'var(--text-primary)' : 'transparent',
|
||||||
|
borderColor: perms[opt.key] ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: perms[opt.key] ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
|
opacity: opt.always ? 0.7 : 1,
|
||||||
|
}}>
|
||||||
|
{perms[opt.key] ? <Check size={10} /> : null}
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shareUrl ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px',
|
||||||
|
background: 'var(--bg-tertiary)', borderRadius: 8, border: '1px solid var(--border-faint)',
|
||||||
|
}}>
|
||||||
|
<input type="text" value={shareUrl} readOnly style={{
|
||||||
|
flex: 1, border: 'none', background: 'none', fontSize: 11, color: 'var(--text-primary)',
|
||||||
|
outline: 'none', fontFamily: 'monospace',
|
||||||
|
}} />
|
||||||
|
<button onClick={handleCopy} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 6,
|
||||||
|
border: 'none', background: copied ? '#16a34a' : 'var(--accent)', color: copied ? 'white' : 'var(--accent-text)',
|
||||||
|
fontSize: 10, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', transition: 'background 0.2s',
|
||||||
|
}}>
|
||||||
|
{copied ? <><Check size={10} /> {t('common.copied')}</> : <><Copy size={10} /> {t('common.copy')}</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleDelete} style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
|
padding: '6px 0', borderRadius: 8, border: '1px solid rgba(239,68,68,0.3)',
|
||||||
|
background: 'rgba(239,68,68,0.06)', color: '#ef4444', fontSize: 11, fontWeight: 500,
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Trash2 size={11} /> {t('share.deleteLink')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleCreate} style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||||
|
width: '100%', padding: '8px 0', borderRadius: 8, border: '1px dashed var(--border-primary)',
|
||||||
|
background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500,
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Link2 size={12} /> {t('share.createLink')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface TripMembersModalProps {
|
interface TripMembersModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -123,8 +246,12 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
|||||||
] : []
|
] : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="sm">
|
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} className="share-modal-grid">
|
||||||
|
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
||||||
|
|
||||||
|
{/* Left column: Members */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
|
||||||
{/* Trip name */}
|
{/* Trip name */}
|
||||||
<div style={{ padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-secondary)' }}>
|
<div style={{ padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-secondary)' }}>
|
||||||
@@ -228,6 +355,13 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column: Share Link */}
|
||||||
|
<div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
|
||||||
|
<ShareLinkSection tripId={tripId} t={t} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default function VacayCalendar() {
|
|||||||
}, [entries])
|
}, [entries])
|
||||||
|
|
||||||
const blockWeekends = plan?.block_weekends !== false
|
const blockWeekends = plan?.block_weekends !== false
|
||||||
|
const weekendDays: number[] = plan?.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
|
||||||
const companyHolidaysEnabled = plan?.company_holidays_enabled !== false
|
const companyHolidaysEnabled = plan?.company_holidays_enabled !== false
|
||||||
|
|
||||||
const handleCellClick = useCallback(async (dateStr) => {
|
const handleCellClick = useCallback(async (dateStr) => {
|
||||||
@@ -35,7 +36,7 @@ export default function VacayCalendar() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (holidays[dateStr]) return
|
if (holidays[dateStr]) return
|
||||||
if (blockWeekends && isWeekend(dateStr)) return
|
if (blockWeekends && isWeekend(dateStr, weekendDays)) return
|
||||||
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
|
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
|
||||||
await toggleEntry(dateStr, selectedUserId || undefined)
|
await toggleEntry(dateStr, selectedUserId || undefined)
|
||||||
}, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
|
}, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
|
||||||
@@ -57,6 +58,7 @@ export default function VacayCalendar() {
|
|||||||
onCellClick={handleCellClick}
|
onCellClick={handleCellClick}
|
||||||
companyMode={companyMode}
|
companyMode={companyMode}
|
||||||
blockWeekends={blockWeekends}
|
blockWeekends={blockWeekends}
|
||||||
|
weekendDays={weekendDays}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { isWeekend } from './holidays'
|
import { isWeekend } from './holidays'
|
||||||
import type { HolidaysMap, VacayEntry } from '../../types'
|
import type { HolidaysMap, VacayEntry } from '../../types'
|
||||||
|
|
||||||
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
const WEEKDAY_KEYS = ['vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat', 'vacay.sun'] as const
|
||||||
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
|
||||||
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
function hexToRgba(hex: string, alpha: number): string {
|
||||||
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
const r = parseInt(hex.slice(1, 3), 16)
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16)
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16)
|
||||||
|
return `rgba(${r},${g},${b},${alpha})`
|
||||||
|
}
|
||||||
|
|
||||||
interface VacayMonthCardProps {
|
interface VacayMonthCardProps {
|
||||||
year: number
|
year: number
|
||||||
@@ -18,15 +22,17 @@ interface VacayMonthCardProps {
|
|||||||
onCellClick: (date: string) => void
|
onCellClick: (date: string) => void
|
||||||
companyMode: boolean
|
companyMode: boolean
|
||||||
blockWeekends: boolean
|
blockWeekends: boolean
|
||||||
|
weekendDays?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VacayMonthCard({
|
export default function VacayMonthCard({
|
||||||
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
|
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
|
||||||
onCellClick, companyMode, blockWeekends
|
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6]
|
||||||
}: VacayMonthCardProps) {
|
}: VacayMonthCardProps) {
|
||||||
const { language } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
|
|
||||||
const monthNames = language === 'de' ? MONTHS_DE : MONTHS_EN
|
const weekdays = WEEKDAY_KEYS.map(k => t(k))
|
||||||
|
const monthName = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(year, month, 1)), [locale, year, month])
|
||||||
|
|
||||||
const weeks = useMemo(() => {
|
const weeks = useMemo(() => {
|
||||||
const firstDay = new Date(year, month, 1)
|
const firstDay = new Date(year, month, 1)
|
||||||
@@ -47,7 +53,7 @@ export default function VacayMonthCard({
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
<div className="px-3 py-2 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
<div className="px-3 py-2 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
<span className="text-xs font-semibold" style={{ color: 'var(--text-primary)' }}>{monthNames[month]}</span>
|
<span className="text-xs font-semibold" style={{ color: 'var(--text-primary)', textTransform: 'capitalize' }}>{monthName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
<div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
@@ -65,7 +71,8 @@ export default function VacayMonthCard({
|
|||||||
if (day === null) return <div key={di} style={{ height: 28 }} />
|
if (day === null) return <div key={di} style={{ height: 28 }} />
|
||||||
|
|
||||||
const dateStr = `${year}-${pad(month + 1)}-${pad(day)}`
|
const dateStr = `${year}-${pad(month + 1)}-${pad(day)}`
|
||||||
const weekend = di >= 5
|
const dayOfWeek = new Date(year, month, day).getDay()
|
||||||
|
const weekend = weekendDays.includes(dayOfWeek)
|
||||||
const holiday = holidays[dateStr]
|
const holiday = holidays[dateStr]
|
||||||
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
|
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
|
||||||
const dayEntries = entryMap[dateStr] || []
|
const dayEntries = entryMap[dateStr] || []
|
||||||
@@ -86,7 +93,7 @@ export default function VacayMonthCard({
|
|||||||
onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }}
|
onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }}
|
||||||
>
|
>
|
||||||
{holiday && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(239,68,68,0.12)' }} />}
|
{holiday && <div className="absolute inset-0.5 rounded" style={{ background: hexToRgba(holiday.color, 0.12) }} />}
|
||||||
{isCompany && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(245,158,11,0.15)' }} />}
|
{isCompany && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(245,158,11,0.15)' }} />}
|
||||||
|
|
||||||
{dayEntries.length === 1 && (
|
{dayEntries.length === 1 && (
|
||||||
@@ -115,7 +122,7 @@ export default function VacayMonthCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="relative z-[1] text-[11px] font-medium" style={{
|
<span className="relative z-[1] text-[11px] font-medium" style={{
|
||||||
color: holiday ? '#dc2626' : weekend ? 'var(--text-faint)' : 'var(--text-primary)',
|
color: holiday ? holiday.color : weekend ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||||
fontWeight: dayEntries.length > 0 ? 700 : 500,
|
fontWeight: dayEntries.length > 0 ? 700 : 500,
|
||||||
}}>
|
}}>
|
||||||
{day}
|
{day}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react'
|
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2 } from 'lucide-react'
|
||||||
import { useVacayStore } from '../../store/vacayStore'
|
import { useVacayStore } from '../../store/vacayStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { getIntlLanguage, useTranslation } from '../../i18n'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
|
import type { VacayHolidayCalendar } from '../../types'
|
||||||
|
|
||||||
interface VacaySettingsProps {
|
interface VacaySettingsProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -13,10 +14,9 @@ interface VacaySettingsProps {
|
|||||||
export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { plan, updatePlan, isFused, dissolve, users } = useVacayStore()
|
const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore()
|
||||||
const [countries, setCountries] = useState([])
|
const [countries, setCountries] = useState<{ value: string; label: string }[]>([])
|
||||||
const [regions, setRegions] = useState([])
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
const [loadingRegions, setLoadingRegions] = useState(false)
|
|
||||||
|
|
||||||
const { language } = useTranslation()
|
const { language } = useTranslation()
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient.get('/addons/vacay/holidays/countries').then(r => {
|
apiClient.get('/addons/vacay/holidays/countries').then(r => {
|
||||||
let displayNames
|
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 => ({
|
const list = r.data.map(c => ({
|
||||||
value: c.countryCode,
|
value: c.countryCode,
|
||||||
label: displayNames ? (displayNames.of(c.countryCode) || c.name) : c.name,
|
label: displayNames ? (displayNames.of(c.countryCode) || c.name) : c.name,
|
||||||
@@ -34,57 +34,9 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [language])
|
}, [language])
|
||||||
|
|
||||||
// When country changes, check if it has regions
|
|
||||||
const selectedCountry = plan?.holidays_region?.split('-')[0] || ''
|
|
||||||
const selectedRegion = plan?.holidays_region?.includes('-') ? plan.holidays_region : ''
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedCountry || !plan?.holidays_enabled) { setRegions([]); return }
|
|
||||||
setLoadingRegions(true)
|
|
||||||
const year = new Date().getFullYear()
|
|
||||||
apiClient.get(`/addons/vacay/holidays/${year}/${selectedCountry}`).then(r => {
|
|
||||||
const allCounties = new Set()
|
|
||||||
r.data.forEach(h => {
|
|
||||||
if (h.counties) h.counties.forEach(c => allCounties.add(c))
|
|
||||||
})
|
|
||||||
if (allCounties.size > 0) {
|
|
||||||
let subdivisionNames
|
|
||||||
try { subdivisionNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { 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)
|
|
||||||
// Fallback: use known mappings for DE
|
|
||||||
if (c.startsWith('DE-')) {
|
|
||||||
const deRegions = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' }
|
|
||||||
label = deRegions[c.split('-')[1]] || label
|
|
||||||
} else if (c.startsWith('CH-')) {
|
|
||||||
const chRegions = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' }
|
|
||||||
label = chRegions[c.split('-')[1]] || label
|
|
||||||
}
|
|
||||||
return { value: c, label }
|
|
||||||
})
|
|
||||||
setRegions(regionList)
|
|
||||||
} else {
|
|
||||||
setRegions([])
|
|
||||||
// If no regions, just set country code as region
|
|
||||||
if (plan.holidays_region !== selectedCountry) {
|
|
||||||
updatePlan({ holidays_region: selectedCountry })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).catch(() => setRegions([])).finally(() => setLoadingRegions(false))
|
|
||||||
}, [selectedCountry, plan?.holidays_enabled])
|
|
||||||
|
|
||||||
if (!plan) return null
|
if (!plan) return null
|
||||||
|
|
||||||
const toggle = (key) => updatePlan({ [key]: !plan[key] })
|
const toggle = (key: string) => updatePlan({ [key]: !plan[key] })
|
||||||
|
|
||||||
const handleCountryChange = (countryCode) => {
|
|
||||||
updatePlan({ holidays_region: countryCode })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRegionChange = (regionCode) => {
|
|
||||||
updatePlan({ holidays_region: regionCode })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -97,6 +49,42 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
|||||||
onChange={() => toggle('block_weekends')}
|
onChange={() => toggle('block_weekends')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Weekend days selector */}
|
||||||
|
{plan.block_weekends !== false && (
|
||||||
|
<div style={{ paddingLeft: 36 }}>
|
||||||
|
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>{t('vacay.weekendDays')}</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{[
|
||||||
|
{ day: 1, label: t('vacay.mon') },
|
||||||
|
{ day: 2, label: t('vacay.tue') },
|
||||||
|
{ day: 3, label: t('vacay.wed') },
|
||||||
|
{ day: 4, label: t('vacay.thu') },
|
||||||
|
{ day: 5, label: t('vacay.fri') },
|
||||||
|
{ day: 6, label: t('vacay.sat') },
|
||||||
|
{ day: 0, label: t('vacay.sun') },
|
||||||
|
].map(({ day, label }) => {
|
||||||
|
const current: number[] = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
|
||||||
|
const active = current.includes(day)
|
||||||
|
return (
|
||||||
|
<button key={day} onClick={() => {
|
||||||
|
const next = active ? current.filter(d => d !== day) : [...current, day]
|
||||||
|
updatePlan({ weekend_days: next.join(',') })
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit', border: '1px solid', transition: 'all 0.12s',
|
||||||
|
background: active ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||||
|
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Carry-over */}
|
{/* Carry-over */}
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
icon={ArrowRightLeft}
|
icon={ArrowRightLeft}
|
||||||
@@ -136,21 +124,35 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
|||||||
/>
|
/>
|
||||||
{plan.holidays_enabled && (
|
{plan.holidays_enabled && (
|
||||||
<div className="ml-7 mt-2 space-y-2">
|
<div className="ml-7 mt-2 space-y-2">
|
||||||
<CustomSelect
|
{(plan.holiday_calendars ?? []).length === 0 && (
|
||||||
value={selectedCountry}
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('vacay.noCalendars')}</p>
|
||||||
onChange={handleCountryChange}
|
)}
|
||||||
options={countries}
|
{(plan.holiday_calendars ?? []).map(cal => (
|
||||||
placeholder={t('vacay.selectCountry')}
|
<CalendarRow
|
||||||
searchable
|
key={cal.id}
|
||||||
/>
|
cal={cal}
|
||||||
{regions.length > 0 && (
|
countries={countries}
|
||||||
<CustomSelect
|
language={language}
|
||||||
value={selectedRegion}
|
onUpdate={(data) => updateHolidayCalendar(cal.id, data)}
|
||||||
onChange={handleRegionChange}
|
onDelete={() => deleteHolidayCalendar(cal.id)}
|
||||||
options={regions}
|
|
||||||
placeholder={t('vacay.selectRegion')}
|
|
||||||
searchable
|
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
{showAddForm ? (
|
||||||
|
<AddCalendarForm
|
||||||
|
countries={countries}
|
||||||
|
language={language}
|
||||||
|
onAdd={async (data) => { await addHolidayCalendar(data); setShowAddForm(false) }}
|
||||||
|
onCancel={() => setShowAddForm(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddForm(true)}
|
||||||
|
className="flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md transition-colors"
|
||||||
|
style={{ color: 'var(--text-muted)', background: 'var(--bg-secondary)' }}
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
{t('vacay.addCalendar')}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -197,11 +199,11 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SettingToggleProps {
|
interface SettingToggleProps {
|
||||||
icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>
|
icon: LucideIcon
|
||||||
label: string
|
label: string
|
||||||
hint: string
|
hint: string
|
||||||
value: boolean
|
value: boolean
|
||||||
onChange: (value: boolean) => void
|
onChange: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingToggleProps) {
|
function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingToggleProps) {
|
||||||
@@ -223,3 +225,202 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── shared region-loading helper ─────────────────────────────────────────────
|
||||||
|
async function fetchRegionOptions(country: string): Promise<{ value: string; label: string }[]> {
|
||||||
|
try {
|
||||||
|
const year = new Date().getFullYear()
|
||||||
|
const r = await apiClient.get(`/addons/vacay/holidays/${year}/${country}`)
|
||||||
|
const allCounties = new Set<string>()
|
||||||
|
r.data.forEach(h => { if (h.counties) h.counties.forEach(c => allCounties.add(c)) })
|
||||||
|
if (allCounties.size === 0) return []
|
||||||
|
return [...allCounties].sort().map(c => {
|
||||||
|
let label = c.split('-')[1] || c
|
||||||
|
if (c.startsWith('DE-')) {
|
||||||
|
const m: Record<string, string> = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' }
|
||||||
|
label = m[c.split('-')[1]] || label
|
||||||
|
} else if (c.startsWith('CH-')) {
|
||||||
|
const m: Record<string, string> = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' }
|
||||||
|
label = m[c.split('-')[1]] || label
|
||||||
|
}
|
||||||
|
return { value: c, label }
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Existing calendar row (inline edit) ──────────────────────────────────────
|
||||||
|
function CalendarRow({ cal, countries, onUpdate, onDelete }: {
|
||||||
|
cal: VacayHolidayCalendar
|
||||||
|
countries: { value: string; label: string }[]
|
||||||
|
language: string
|
||||||
|
onUpdate: (data: { region?: string; color?: string; label?: string | null }) => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [localColor, setLocalColor] = useState(cal.color)
|
||||||
|
const [localLabel, setLocalLabel] = useState(cal.label || '')
|
||||||
|
const [regions, setRegions] = useState<{ value: string; label: string }[]>([])
|
||||||
|
|
||||||
|
const selectedCountry = cal.region.split('-')[0]
|
||||||
|
const selectedRegion = cal.region.includes('-') ? cal.region : ''
|
||||||
|
|
||||||
|
useEffect(() => { setLocalColor(cal.color) }, [cal.color])
|
||||||
|
useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCountry) { setRegions([]); return }
|
||||||
|
fetchRegionOptions(selectedCountry).then(setRegions)
|
||||||
|
}, [selectedCountry])
|
||||||
|
|
||||||
|
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
|
||||||
|
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 items-start p-3 rounded-xl" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||||
|
style={{ width: 28, height: 28, borderRadius: 8, background: localColor, border: '2px solid var(--border-primary)', cursor: 'pointer' }}
|
||||||
|
title={t('vacay.calendarColor')}
|
||||||
|
/>
|
||||||
|
{showColorPicker && (
|
||||||
|
<div style={{ position: 'absolute', top: 34, left: 0, zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12, padding: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.12)', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4, width: 120 }}>
|
||||||
|
{PRESET_COLORS.map(c => (
|
||||||
|
<button key={c} onClick={() => { setLocalColor(c); setShowColorPicker(false); if (c !== cal.color) onUpdate({ color: c }) }}
|
||||||
|
style={{ width: 24, height: 24, borderRadius: 6, background: c, border: localColor === c ? '2px solid var(--text-primary)' : '2px solid transparent', cursor: 'pointer' }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 space-y-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={localLabel}
|
||||||
|
onChange={e => setLocalLabel(e.target.value)}
|
||||||
|
onBlur={() => { const v = localLabel.trim() || null; if (v !== cal.label) onUpdate({ label: v }) }}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||||
|
placeholder={t('vacay.calendarLabel')}
|
||||||
|
style={{ width: '100%', fontSize: 12, padding: '6px 10px', borderRadius: 8, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
||||||
|
/>
|
||||||
|
<CustomSelect
|
||||||
|
value={selectedCountry}
|
||||||
|
onChange={v => onUpdate({ region: v })}
|
||||||
|
options={countries}
|
||||||
|
placeholder={t('vacay.selectCountry')}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
{regions.length > 0 && (
|
||||||
|
<CustomSelect
|
||||||
|
value={selectedRegion}
|
||||||
|
onChange={v => onUpdate({ region: v })}
|
||||||
|
options={regions}
|
||||||
|
placeholder={t('vacay.selectRegion')}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="shrink-0 p-1.5 rounded-md transition-colors"
|
||||||
|
style={{ color: 'var(--text-faint)' }}
|
||||||
|
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'rgba(239,68,68,0.1)' }}
|
||||||
|
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add-new-calendar form ─────────────────────────────────────────────────────
|
||||||
|
function AddCalendarForm({ countries, onAdd, onCancel }: {
|
||||||
|
countries: { value: string; label: string }[]
|
||||||
|
language: string
|
||||||
|
onAdd: (data: { region: string; color: string; label: string | null }) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [region, setRegion] = useState('')
|
||||||
|
const [color, setColor] = useState('#fecaca')
|
||||||
|
const [label, setLabel] = useState('')
|
||||||
|
const [regions, setRegions] = useState<{ value: string; label: string }[]>([])
|
||||||
|
const [loadingRegions, setLoadingRegions] = useState(false)
|
||||||
|
|
||||||
|
const selectedCountry = region.split('-')[0] || ''
|
||||||
|
const selectedRegion = region.includes('-') ? region : ''
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCountry) { setRegions([]); return }
|
||||||
|
setLoadingRegions(true)
|
||||||
|
fetchRegionOptions(selectedCountry).then(list => { setRegions(list) }).finally(() => setLoadingRegions(false))
|
||||||
|
}, [selectedCountry])
|
||||||
|
|
||||||
|
const canAdd = selectedCountry && (regions.length === 0 || selectedRegion !== '')
|
||||||
|
|
||||||
|
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
|
||||||
|
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 items-start p-3 rounded-xl border border-dashed" style={{ borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||||
|
style={{ width: 28, height: 28, borderRadius: 8, background: color, border: '2px solid var(--border-primary)', cursor: 'pointer' }}
|
||||||
|
title={t('vacay.calendarColor')}
|
||||||
|
/>
|
||||||
|
{showColorPicker && (
|
||||||
|
<div style={{ position: 'absolute', top: 34, left: 0, zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12, padding: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.12)', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4, width: 120 }}>
|
||||||
|
{PRESET_COLORS.map(c => (
|
||||||
|
<button key={c} onClick={() => { setColor(c); setShowColorPicker(false) }}
|
||||||
|
style={{ width: 24, height: 24, borderRadius: 6, background: c, border: color === c ? '2px solid var(--text-primary)' : '2px solid transparent', cursor: 'pointer' }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 space-y-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={e => setLabel(e.target.value)}
|
||||||
|
placeholder={t('vacay.calendarLabel')}
|
||||||
|
style={{ width: '100%', fontSize: 12, padding: '6px 10px', borderRadius: 8, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
||||||
|
/>
|
||||||
|
<CustomSelect
|
||||||
|
value={selectedCountry}
|
||||||
|
onChange={v => { setRegion(v); setRegions([]) }}
|
||||||
|
options={countries}
|
||||||
|
placeholder={t('vacay.selectCountry')}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
{regions.length > 0 && (
|
||||||
|
<CustomSelect
|
||||||
|
value={selectedRegion}
|
||||||
|
onChange={v => setRegion(v)}
|
||||||
|
options={regions}
|
||||||
|
placeholder={t('vacay.selectRegion')}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1.5 pt-0.5">
|
||||||
|
<button
|
||||||
|
disabled={!canAdd}
|
||||||
|
onClick={() => onAdd({ region: region || selectedCountry, color, label: label.trim() || null })}
|
||||||
|
className="flex-1 text-xs px-2 py-1.5 rounded-md font-medium transition-colors disabled:opacity-40"
|
||||||
|
style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}
|
||||||
|
>
|
||||||
|
{t('vacay.add')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-xs px-2 py-1.5 rounded-md transition-colors"
|
||||||
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,10 +103,9 @@ export function getHolidays(year: number, bundesland: string = 'NW'): Record<str
|
|||||||
return holidays
|
return holidays
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isWeekend(dateStr: string): boolean {
|
export function isWeekend(dateStr: string, weekendDays: number[] = [0, 6]): boolean {
|
||||||
const d = new Date(dateStr + 'T00:00:00')
|
const d = new Date(dateStr + 'T00:00:00')
|
||||||
const day = d.getDay()
|
return weekendDays.includes(d.getDay())
|
||||||
return day === 0 || day === 6
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWeekday(dateStr: string): string {
|
export function getWeekday(dateStr: string): string {
|
||||||
@@ -123,9 +122,9 @@ export function daysInMonth(year: number, month: number): number {
|
|||||||
return new Date(year, month, 0).getDate()
|
return new Date(year, month, 0).getDate()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(dateStr: string): string {
|
export function formatDate(dateStr: string, locale?: string): string {
|
||||||
const d = new Date(dateStr + 'T00:00:00')
|
const d = new Date(dateStr + 'T00:00:00')
|
||||||
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
|
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export { BUNDESLAENDER }
|
export { BUNDESLAENDER }
|
||||||
|
|||||||
@@ -59,9 +59,43 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
|||||||
const today = new Date()
|
const today = new Date()
|
||||||
const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
|
const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
|
||||||
|
|
||||||
|
const [textInput, setTextInput] = useState('')
|
||||||
|
const [isTyping, setIsTyping] = useState(false)
|
||||||
|
|
||||||
|
const handleTextSubmit = () => {
|
||||||
|
setIsTyping(false)
|
||||||
|
if (!textInput.trim()) return
|
||||||
|
// Try to parse various date formats
|
||||||
|
const input = textInput.trim()
|
||||||
|
// ISO: 2026-03-29
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { onChange(input); return }
|
||||||
|
// EU: 29.03.2026 or 29/03/2026
|
||||||
|
const euMatch = input.match(/^(\d{1,2})[./](\d{1,2})[./](\d{2,4})$/)
|
||||||
|
if (euMatch) {
|
||||||
|
const y = euMatch[3].length === 2 ? 2000 + parseInt(euMatch[3]) : parseInt(euMatch[3])
|
||||||
|
onChange(`${y}-${String(euMatch[2]).padStart(2, '0')}-${String(euMatch[1]).padStart(2, '0')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Try native Date parse as fallback
|
||||||
|
const d = new Date(input)
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
onChange(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
<div ref={ref} style={{ position: 'relative', ...style }}>
|
||||||
<button type="button" onClick={() => setOpen(o => !o)}
|
{isTyping ? (
|
||||||
|
<input autoFocus type="text" value={textInput} onChange={e => setTextInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleTextSubmit(); if (e.key === 'Escape') setIsTyping(false) }}
|
||||||
|
onBlur={handleTextSubmit}
|
||||||
|
placeholder="DD.MM.YYYY"
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '8px 14px', borderRadius: 10, border: '1px solid var(--text-faint)',
|
||||||
|
background: 'var(--bg-input)', color: 'var(--text-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none',
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => setOpen(o => !o)} onDoubleClick={() => { setTextInput(value || ''); setIsTyping(true) }}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||||
padding: '8px 14px', borderRadius: 10,
|
padding: '8px 14px', borderRadius: 10,
|
||||||
@@ -75,6 +109,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
|||||||
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayValue || placeholder || t('common.date')}</span>
|
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayValue || placeholder || t('common.date')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{open && ReactDOM.createPortal(
|
{open && ReactDOM.createPortal(
|
||||||
<div ref={dropRef} style={{
|
<div ref={dropRef} style={{
|
||||||
|
|||||||
@@ -107,9 +107,15 @@ export default function CustomSelect({
|
|||||||
{open && ReactDOM.createPortal(
|
{open && ReactDOM.createPortal(
|
||||||
<div ref={dropRef} style={{
|
<div ref={dropRef} style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
|
...(() => {
|
||||||
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
|
const r = ref.current?.getBoundingClientRect()
|
||||||
width: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.width : 200 })(),
|
if (!r) return { top: 0, left: 0, width: 200 }
|
||||||
|
const spaceBelow = window.innerHeight - r.bottom
|
||||||
|
const openUp = spaceBelow < 220 && r.top > spaceBelow
|
||||||
|
return openUp
|
||||||
|
? { bottom: window.innerHeight - r.top + 4, left: r.left, width: r.width }
|
||||||
|
: { top: r.bottom + 4, left: r.left, width: r.width }
|
||||||
|
})(),
|
||||||
zIndex: 99999,
|
zIndex: 99999,
|
||||||
background: 'var(--bg-card)',
|
background: 'var(--bg-card)',
|
||||||
backdropFilter: 'blur(24px) saturate(180%)',
|
backdropFilter: 'blur(24px) saturate(180%)',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const sizeClasses: Record<string, string> = {
|
|||||||
lg: 'max-w-lg',
|
lg: 'max-w-lg',
|
||||||
xl: 'max-w-2xl',
|
xl: 'max-w-2xl',
|
||||||
'2xl': 'max-w-4xl',
|
'2xl': 'max-w-4xl',
|
||||||
|
'3xl': 'max-w-5xl',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
|
|||||||
@@ -16,8 +16,18 @@ interface PlaceAvatarProps {
|
|||||||
|
|
||||||
const photoCache = new Map<string, string | null>()
|
const photoCache = new Map<string, string | null>()
|
||||||
const photoInFlight = new Set<string>()
|
const photoInFlight = new Set<string>()
|
||||||
|
// Event-based notification instead of polling intervals
|
||||||
|
const photoListeners = new Map<string, Set<(url: string | null) => void>>()
|
||||||
|
|
||||||
export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
function notifyListeners(key: string, url: string | null) {
|
||||||
|
const listeners = photoListeners.get(key)
|
||||||
|
if (listeners) {
|
||||||
|
listeners.forEach(fn => fn(url))
|
||||||
|
photoListeners.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -33,28 +43,27 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (photoInFlight.has(cacheKey)) {
|
if (photoInFlight.has(cacheKey)) {
|
||||||
// Another instance is already fetching, wait for it
|
// Subscribe to notification instead of polling
|
||||||
const check = setInterval(() => {
|
if (!photoListeners.has(cacheKey)) photoListeners.set(cacheKey, new Set())
|
||||||
if (photoCache.has(cacheKey)) {
|
const handler = (url: string | null) => { if (url) setPhotoSrc(url) }
|
||||||
clearInterval(check)
|
photoListeners.get(cacheKey)!.add(handler)
|
||||||
const cached = photoCache.get(cacheKey)
|
return () => { photoListeners.get(cacheKey)?.delete(handler) }
|
||||||
if (cached) setPhotoSrc(cached)
|
|
||||||
}
|
|
||||||
}, 200)
|
|
||||||
return () => clearInterval(check)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
photoInFlight.add(cacheKey)
|
photoInFlight.add(cacheKey)
|
||||||
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||||
.then((data: { photoUrl?: string }) => {
|
.then((data: { photoUrl?: string }) => {
|
||||||
if (data.photoUrl) {
|
const url = data.photoUrl || null
|
||||||
photoCache.set(cacheKey, data.photoUrl)
|
photoCache.set(cacheKey, url)
|
||||||
setPhotoSrc(data.photoUrl)
|
if (url) setPhotoSrc(url)
|
||||||
} else {
|
notifyListeners(cacheKey, url)
|
||||||
photoCache.set(cacheKey, null)
|
photoInFlight.delete(cacheKey)
|
||||||
}
|
})
|
||||||
|
.catch(() => {
|
||||||
|
photoCache.set(cacheKey, null)
|
||||||
|
notifyListeners(cacheKey, null)
|
||||||
photoInFlight.delete(cacheKey)
|
photoInFlight.delete(cacheKey)
|
||||||
})
|
})
|
||||||
.catch(() => { photoCache.set(cacheKey, null); photoInFlight.delete(cacheKey) })
|
|
||||||
}, [place.id, place.image_url, place.google_place_id, place.osm_id])
|
}, [place.id, place.image_url, place.google_place_id, place.osm_id])
|
||||||
|
|
||||||
const bgColor = category?.color || '#6366f1'
|
const bgColor = category?.color || '#6366f1'
|
||||||
@@ -76,6 +85,7 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
|
|||||||
<img
|
<img
|
||||||
src={photoSrc}
|
src={photoSrc}
|
||||||
alt={place.name}
|
alt={place.name}
|
||||||
|
loading="lazy"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
onError={() => setPhotoSrc(null)}
|
onError={() => setPhotoSrc(null)}
|
||||||
/>
|
/>
|
||||||
@@ -88,4 +98,4 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
|
|||||||
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
|
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -1,11 +1,51 @@
|
|||||||
import React, { createContext, useContext, useMemo, ReactNode } from 'react'
|
import React, { createContext, useContext, useEffect, useMemo, ReactNode } from 'react'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import de from './translations/de'
|
import de from './translations/de'
|
||||||
import en from './translations/en'
|
import en from './translations/en'
|
||||||
|
import es from './translations/es'
|
||||||
|
import fr from './translations/fr'
|
||||||
|
import hu from './translations/hu'
|
||||||
|
import it from './translations/it'
|
||||||
|
import ru from './translations/ru'
|
||||||
|
import zh from './translations/zh'
|
||||||
|
import nl from './translations/nl'
|
||||||
|
import ar from './translations/ar'
|
||||||
|
import br from './translations/br'
|
||||||
|
import cs from './translations/cs'
|
||||||
|
|
||||||
type TranslationStrings = Record<string, string>
|
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
|
||||||
|
|
||||||
const translations: Record<string, TranslationStrings> = { de, en }
|
export const SUPPORTED_LANGUAGES = [
|
||||||
|
{ value: 'de', label: 'Deutsch' },
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
{ value: 'es', label: 'Español' },
|
||||||
|
{ value: 'fr', label: 'Français' },
|
||||||
|
{ value: 'hu', label: 'Magyar' },
|
||||||
|
{ value: 'nl', label: 'Nederlands' },
|
||||||
|
{ value: 'br', label: 'Português (Brasil)' },
|
||||||
|
{ value: 'cs', label: 'Česky' },
|
||||||
|
{ value: 'ru', label: 'Русский' },
|
||||||
|
{ value: 'zh', label: '中文' },
|
||||||
|
{ value: 'it', label: 'Italiano' },
|
||||||
|
{ value: 'ar', label: 'العربية' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const translations: Record<string, TranslationStrings> = { de, en, es, fr, hu, it, ru, zh, nl, ar, br, cs }
|
||||||
|
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', hu: 'hu-HU', it: 'it-IT', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR', cs: 'cs-CZ' }
|
||||||
|
const RTL_LANGUAGES = new Set(['ar'])
|
||||||
|
|
||||||
|
export function getLocaleForLanguage(language: string): string {
|
||||||
|
return LOCALES[language] || LOCALES.en
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIntlLanguage(language: string): string {
|
||||||
|
if (language === 'br') return 'pt-BR'
|
||||||
|
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'nl', 'ar', 'cs'].includes(language) ? language : 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRtlLanguage(language: string): boolean {
|
||||||
|
return RTL_LANGUAGES.has(language)
|
||||||
|
}
|
||||||
|
|
||||||
interface TranslationContextValue {
|
interface TranslationContextValue {
|
||||||
t: (key: string, params?: Record<string, string | number>) => string
|
t: (key: string, params?: Record<string, string | number>) => string
|
||||||
@@ -13,21 +53,26 @@ interface TranslationContextValue {
|
|||||||
locale: string
|
locale: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const TranslationContext = createContext<TranslationContextValue>({ t: (k: string) => k, language: 'de', locale: 'de-DE' })
|
const TranslationContext = createContext<TranslationContextValue>({ t: (k: string) => k, language: 'en', locale: 'en-US' })
|
||||||
|
|
||||||
interface TranslationProviderProps {
|
interface TranslationProviderProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TranslationProvider({ children }: TranslationProviderProps) {
|
export function TranslationProvider({ children }: TranslationProviderProps) {
|
||||||
const language = useSettingsStore((s) => s.settings.language) || 'de'
|
const language = useSettingsStore((s) => s.settings.language) || 'en'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = language
|
||||||
|
document.documentElement.dir = isRtlLanguage(language) ? 'rtl' : 'ltr'
|
||||||
|
}, [language])
|
||||||
|
|
||||||
const value = useMemo((): TranslationContextValue => {
|
const value = useMemo((): TranslationContextValue => {
|
||||||
const strings = translations[language] || translations.de
|
const strings = translations[language] || translations.en
|
||||||
const fallback = translations.de
|
const fallback = translations.en
|
||||||
|
|
||||||
function t(key: string, params?: Record<string, string | number>): string {
|
function t(key: string, params?: Record<string, string | number>): string {
|
||||||
let val: string = strings[key] ?? fallback[key] ?? key
|
let val: string = (strings[key] ?? fallback[key] ?? key) as string
|
||||||
if (params) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([k, v]) => {
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
|
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
|
||||||
@@ -36,7 +81,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
|
|||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
return { t, language, locale: language === 'en' ? 'en-US' : 'de-DE' }
|
return { t, language, locale: getLocaleForLanguage(language) }
|
||||||
}, [language])
|
}, [language])
|
||||||
|
|
||||||
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
|
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
|
||||||
|
|||||||
@@ -1 +1,8 @@
|
|||||||
export { TranslationProvider, useTranslation } from './TranslationContext'
|
export {
|
||||||
|
TranslationProvider,
|
||||||
|
useTranslation,
|
||||||
|
getLocaleForLanguage,
|
||||||
|
getIntlLanguage,
|
||||||
|
isRtlLanguage,
|
||||||
|
SUPPORTED_LANGUAGES,
|
||||||
|
} from './TranslationContext'
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
const de: Record<string, string> = {
|
const de: Record<string, string | { name: string; category: string }[]> = {
|
||||||
// Allgemein
|
// Allgemein
|
||||||
'common.save': 'Speichern',
|
'common.save': 'Speichern',
|
||||||
'common.cancel': 'Abbrechen',
|
'common.cancel': 'Abbrechen',
|
||||||
@@ -51,9 +51,18 @@ const de: Record<string, string> = {
|
|||||||
'dashboard.subtitle.activeMany': '{count} aktive Reisen',
|
'dashboard.subtitle.activeMany': '{count} aktive Reisen',
|
||||||
'dashboard.subtitle.archivedSuffix': ' · {count} archiviert',
|
'dashboard.subtitle.archivedSuffix': ' · {count} archiviert',
|
||||||
'dashboard.newTrip': 'Neue Reise',
|
'dashboard.newTrip': 'Neue Reise',
|
||||||
|
'dashboard.gridView': 'Kachelansicht',
|
||||||
|
'dashboard.listView': 'Listenansicht',
|
||||||
'dashboard.currency': 'Währung',
|
'dashboard.currency': 'Währung',
|
||||||
'dashboard.timezone': 'Zeitzonen',
|
'dashboard.timezone': 'Zeitzonen',
|
||||||
'dashboard.localTime': 'Lokal',
|
'dashboard.localTime': 'Lokal',
|
||||||
|
'dashboard.timezoneCustomTitle': 'Eigene Zeitzone',
|
||||||
|
'dashboard.timezoneCustomLabelPlaceholder': 'Bezeichnung (optional)',
|
||||||
|
'dashboard.timezoneCustomTzPlaceholder': 'z.B. America/New_York',
|
||||||
|
'dashboard.timezoneCustomAdd': 'Hinzufügen',
|
||||||
|
'dashboard.timezoneCustomErrorEmpty': 'Zeitzone eingeben',
|
||||||
|
'dashboard.timezoneCustomErrorInvalid': 'Ungültige Zeitzone. Format: Europe/Berlin',
|
||||||
|
'dashboard.timezoneCustomErrorDuplicate': 'Bereits hinzugefügt',
|
||||||
'dashboard.emptyTitle': 'Noch keine Reisen',
|
'dashboard.emptyTitle': 'Noch keine Reisen',
|
||||||
'dashboard.emptyText': 'Erstelle deine erste Reise und beginne mit der Planung von Orten, Tagesabläufen und Packlisten.',
|
'dashboard.emptyText': 'Erstelle deine erste Reise und beginne mit der Planung von Orten, Tagesabläufen und Packlisten.',
|
||||||
'dashboard.emptyButton': 'Erste Reise erstellen',
|
'dashboard.emptyButton': 'Erste Reise erstellen',
|
||||||
@@ -92,7 +101,9 @@ const de: Record<string, string> = {
|
|||||||
'dashboard.endDate': 'Enddatum',
|
'dashboard.endDate': 'Enddatum',
|
||||||
'dashboard.noDateHint': 'Kein Datum gesetzt — es werden 7 Standardtage erstellt. Du kannst das jederzeit ändern.',
|
'dashboard.noDateHint': 'Kein Datum gesetzt — es werden 7 Standardtage erstellt. Du kannst das jederzeit ändern.',
|
||||||
'dashboard.coverImage': 'Titelbild',
|
'dashboard.coverImage': 'Titelbild',
|
||||||
'dashboard.addCoverImage': 'Titelbild hinzufügen',
|
'dashboard.addCoverImage': 'Titelbild hinzufügen (oder per Drag & Drop)',
|
||||||
|
'dashboard.addMembers': 'Reisebegleiter',
|
||||||
|
'dashboard.addMember': 'Mitglied hinzufügen',
|
||||||
'dashboard.coverSaved': 'Titelbild gespeichert',
|
'dashboard.coverSaved': 'Titelbild gespeichert',
|
||||||
'dashboard.coverUploadError': 'Fehler beim Hochladen',
|
'dashboard.coverUploadError': 'Fehler beim Hochladen',
|
||||||
'dashboard.coverRemoveError': 'Fehler beim Entfernen',
|
'dashboard.coverRemoveError': 'Fehler beim Entfernen',
|
||||||
@@ -128,6 +139,50 @@ const de: Record<string, string> = {
|
|||||||
'settings.temperature': 'Temperatureinheit',
|
'settings.temperature': 'Temperatureinheit',
|
||||||
'settings.timeFormat': 'Zeitformat',
|
'settings.timeFormat': 'Zeitformat',
|
||||||
'settings.routeCalculation': 'Routenberechnung',
|
'settings.routeCalculation': 'Routenberechnung',
|
||||||
|
'settings.blurBookingCodes': 'Buchungscodes verbergen',
|
||||||
|
'settings.notifications': 'Benachrichtigungen',
|
||||||
|
'settings.notifyTripInvite': 'Trip-Einladungen',
|
||||||
|
'settings.notifyBookingChange': 'Buchungsänderungen',
|
||||||
|
'settings.notifyTripReminder': 'Trip-Erinnerungen',
|
||||||
|
'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen',
|
||||||
|
'settings.notifyPhotosShared': 'Geteilte Fotos (Immich)',
|
||||||
|
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
|
||||||
|
'settings.notifyPackingTagged': 'Packliste: Zuweisungen',
|
||||||
|
'settings.notifyWebhook': 'Webhook-Benachrichtigungen',
|
||||||
|
'admin.smtp.title': 'E-Mail & Benachrichtigungen',
|
||||||
|
'admin.smtp.hint': 'SMTP-Konfiguration für E-Mail-Benachrichtigungen. Optional: Webhook-URL für Discord, Slack, etc.',
|
||||||
|
'admin.smtp.testButton': 'Test-E-Mail senden',
|
||||||
|
'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet',
|
||||||
|
'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen',
|
||||||
|
'dayplan.icsTooltip': 'Kalender exportieren (ICS)',
|
||||||
|
'share.linkTitle': 'Öffentlicher Link',
|
||||||
|
'share.linkHint': 'Erstelle einen Link den jeder ohne Login nutzen kann, um diese Reise anzuschauen. Nur lesen — keine Bearbeitung möglich.',
|
||||||
|
'share.createLink': 'Link erstellen',
|
||||||
|
'share.deleteLink': 'Link löschen',
|
||||||
|
'share.createError': 'Link konnte nicht erstellt werden',
|
||||||
|
'common.copy': 'Kopieren',
|
||||||
|
'common.copied': 'Kopiert',
|
||||||
|
'share.permMap': 'Karte & Plan',
|
||||||
|
'share.permBookings': 'Buchungen',
|
||||||
|
'share.permPacking': 'Packliste',
|
||||||
|
'shared.expired': 'Link abgelaufen oder ungültig',
|
||||||
|
'shared.expiredHint': 'Dieser geteilte Reise-Link ist nicht mehr aktiv.',
|
||||||
|
'shared.readOnly': 'Nur-Lesen Ansicht',
|
||||||
|
'shared.tabPlan': 'Plan',
|
||||||
|
'shared.tabBookings': 'Buchungen',
|
||||||
|
'shared.tabPacking': 'Packliste',
|
||||||
|
'shared.tabBudget': 'Budget',
|
||||||
|
'shared.tabChat': 'Chat',
|
||||||
|
'shared.days': 'Tage',
|
||||||
|
'shared.places': 'Orte',
|
||||||
|
'shared.other': 'Sonstige',
|
||||||
|
'shared.totalBudget': 'Gesamtbudget',
|
||||||
|
'shared.messages': 'Nachrichten',
|
||||||
|
'shared.sharedVia': 'Geteilt über',
|
||||||
|
'shared.confirmed': 'Bestätigt',
|
||||||
|
'shared.pending': 'Ausstehend',
|
||||||
|
'share.permBudget': 'Budget',
|
||||||
|
'share.permCollab': 'Chat',
|
||||||
'settings.on': 'An',
|
'settings.on': 'An',
|
||||||
'settings.off': 'Aus',
|
'settings.off': 'Aus',
|
||||||
'settings.account': 'Konto',
|
'settings.account': 'Konto',
|
||||||
@@ -164,6 +219,22 @@ const de: Record<string, string> = {
|
|||||||
'settings.avatarUploaded': 'Profilbild aktualisiert',
|
'settings.avatarUploaded': 'Profilbild aktualisiert',
|
||||||
'settings.avatarRemoved': 'Profilbild entfernt',
|
'settings.avatarRemoved': 'Profilbild entfernt',
|
||||||
'settings.avatarError': 'Fehler beim Hochladen',
|
'settings.avatarError': 'Fehler beim Hochladen',
|
||||||
|
'settings.mfa.title': 'Zwei-Faktor-Authentifizierung (2FA)',
|
||||||
|
'settings.mfa.description': 'Zusätzlicher Schritt bei der Anmeldung mit E-Mail und Passwort. Nutze eine Authenticator-App (Google Authenticator, Authy, …).',
|
||||||
|
'settings.mfa.enabled': '2FA ist für dein Konto aktiv.',
|
||||||
|
'settings.mfa.disabled': '2FA ist nicht aktiviert.',
|
||||||
|
'settings.mfa.setup': 'Authenticator einrichten',
|
||||||
|
'settings.mfa.scanQr': 'QR-Code mit der App scannen oder den Schlüssel manuell eingeben.',
|
||||||
|
'settings.mfa.secretLabel': 'Geheimer Schlüssel (manuell)',
|
||||||
|
'settings.mfa.codePlaceholder': '6-stelliger Code',
|
||||||
|
'settings.mfa.enable': '2FA aktivieren',
|
||||||
|
'settings.mfa.cancelSetup': 'Abbrechen',
|
||||||
|
'settings.mfa.disableTitle': '2FA deaktivieren',
|
||||||
|
'settings.mfa.disableHint': 'Passwort und einen aktuellen Code aus der Authenticator-App eingeben.',
|
||||||
|
'settings.mfa.disable': '2FA deaktivieren',
|
||||||
|
'settings.mfa.toastEnabled': 'Zwei-Faktor-Authentifizierung aktiviert',
|
||||||
|
'settings.mfa.toastDisabled': 'Zwei-Faktor-Authentifizierung deaktiviert',
|
||||||
|
'settings.mfa.demoBlocked': 'In der Demo nicht verfügbar',
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
'login.error': 'Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.',
|
'login.error': 'Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.',
|
||||||
@@ -206,7 +277,15 @@ const de: Record<string, string> = {
|
|||||||
'login.oidc.invalidState': 'Ungültige Sitzung. Bitte erneut versuchen.',
|
'login.oidc.invalidState': 'Ungültige Sitzung. Bitte erneut versuchen.',
|
||||||
'login.demoFailed': 'Demo-Login fehlgeschlagen',
|
'login.demoFailed': 'Demo-Login fehlgeschlagen',
|
||||||
'login.oidcSignIn': 'Anmelden mit {name}',
|
'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.demoHint': 'Demo ausprobieren — ohne Registrierung',
|
||||||
|
'login.mfaTitle': 'Zwei-Faktor-Authentifizierung',
|
||||||
|
'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.',
|
||||||
|
'login.mfaCodeLabel': 'Bestätigungscode',
|
||||||
|
'login.mfaCodeRequired': 'Bitte den Code aus der Authenticator-App eingeben.',
|
||||||
|
'login.mfaHint': 'Google Authenticator, Authy oder eine andere TOTP-App öffnen.',
|
||||||
|
'login.mfaBack': '← Zurück zur Anmeldung',
|
||||||
|
'login.mfaVerify': 'Bestätigen',
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
|
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
|
||||||
@@ -236,6 +315,7 @@ const de: Record<string, string> = {
|
|||||||
'admin.tabs.users': 'Benutzer',
|
'admin.tabs.users': 'Benutzer',
|
||||||
'admin.tabs.categories': 'Kategorien',
|
'admin.tabs.categories': 'Kategorien',
|
||||||
'admin.tabs.backup': 'Backup',
|
'admin.tabs.backup': 'Backup',
|
||||||
|
'admin.tabs.audit': 'Audit-Protokoll',
|
||||||
'admin.stats.users': 'Benutzer',
|
'admin.stats.users': 'Benutzer',
|
||||||
'admin.stats.trips': 'Reisen',
|
'admin.stats.trips': 'Reisen',
|
||||||
'admin.stats.places': 'Orte',
|
'admin.stats.places': 'Orte',
|
||||||
@@ -264,6 +344,24 @@ const de: Record<string, string> = {
|
|||||||
'admin.toast.createError': 'Fehler beim Erstellen des Benutzers',
|
'admin.toast.createError': 'Fehler beim Erstellen des Benutzers',
|
||||||
'admin.toast.fieldsRequired': 'Benutzername, E-Mail und Passwort sind erforderlich',
|
'admin.toast.fieldsRequired': 'Benutzername, E-Mail und Passwort sind erforderlich',
|
||||||
'admin.createUser': 'Benutzer anlegen',
|
'admin.createUser': 'Benutzer anlegen',
|
||||||
|
'admin.invite.title': 'Einladungslinks',
|
||||||
|
'admin.invite.subtitle': 'Einmal-Links für die Registrierung erstellen',
|
||||||
|
'admin.invite.create': 'Link erstellen',
|
||||||
|
'admin.invite.createAndCopy': 'Erstellen & kopieren',
|
||||||
|
'admin.invite.empty': 'Noch keine Einladungslinks erstellt',
|
||||||
|
'admin.invite.maxUses': 'Max. Nutzungen',
|
||||||
|
'admin.invite.expiry': 'Gültig für',
|
||||||
|
'admin.invite.uses': 'genutzt',
|
||||||
|
'admin.invite.expiresAt': 'läuft ab am',
|
||||||
|
'admin.invite.createdBy': 'von',
|
||||||
|
'admin.invite.active': 'Aktiv',
|
||||||
|
'admin.invite.expired': 'Abgelaufen',
|
||||||
|
'admin.invite.usedUp': 'Aufgebraucht',
|
||||||
|
'admin.invite.copied': 'Einladungslink in Zwischenablage kopiert',
|
||||||
|
'admin.invite.copyLink': 'Link kopieren',
|
||||||
|
'admin.invite.deleted': 'Einladungslink gelöscht',
|
||||||
|
'admin.invite.createError': 'Fehler beim Erstellen des Einladungslinks',
|
||||||
|
'admin.invite.deleteError': 'Fehler beim Löschen des Einladungslinks',
|
||||||
'admin.tabs.settings': 'Einstellungen',
|
'admin.tabs.settings': 'Einstellungen',
|
||||||
'admin.allowRegistration': 'Registrierung erlauben',
|
'admin.allowRegistration': 'Registrierung erlauben',
|
||||||
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
|
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
|
||||||
@@ -285,6 +383,8 @@ const de: Record<string, string> = {
|
|||||||
'admin.oidcIssuer': 'Issuer URL',
|
'admin.oidcIssuer': 'Issuer URL',
|
||||||
'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com',
|
'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com',
|
||||||
'admin.oidcSaved': 'OIDC-Konfiguration gespeichert',
|
'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
|
// File Types
|
||||||
'admin.fileTypes': 'Erlaubte Dateitypen',
|
'admin.fileTypes': 'Erlaubte Dateitypen',
|
||||||
@@ -292,10 +392,47 @@ const de: Record<string, string> = {
|
|||||||
'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.',
|
'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.',
|
||||||
'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert',
|
'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert',
|
||||||
|
|
||||||
|
// Packing Templates & Bag Tracking
|
||||||
|
'admin.bagTracking.title': 'Gepäck-Tracking',
|
||||||
|
'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren',
|
||||||
|
'admin.tabs.config': 'Konfiguration',
|
||||||
|
'admin.tabs.templates': 'Packvorlagen',
|
||||||
|
'admin.packingTemplates.title': 'Packvorlagen',
|
||||||
|
'admin.packingTemplates.subtitle': 'Wiederverwendbare Packlisten für deine Reisen erstellen',
|
||||||
|
'admin.packingTemplates.create': 'Neue Vorlage',
|
||||||
|
'admin.packingTemplates.namePlaceholder': 'Vorlagenname (z.B. Strandurlaub)',
|
||||||
|
'admin.packingTemplates.empty': 'Noch keine Vorlagen erstellt',
|
||||||
|
'admin.packingTemplates.items': 'Einträge',
|
||||||
|
'admin.packingTemplates.categories': 'Kategorien',
|
||||||
|
'admin.packingTemplates.itemName': 'Artikelname',
|
||||||
|
'admin.packingTemplates.itemCategory': 'Kategorie',
|
||||||
|
'admin.packingTemplates.categoryName': 'Kategoriename (z.B. Kleidung)',
|
||||||
|
'admin.packingTemplates.addCategory': 'Kategorie hinzufügen',
|
||||||
|
'admin.packingTemplates.created': 'Vorlage erstellt',
|
||||||
|
'admin.packingTemplates.deleted': 'Vorlage gelöscht',
|
||||||
|
'admin.packingTemplates.loadError': 'Vorlagen konnten nicht geladen werden',
|
||||||
|
'admin.packingTemplates.createError': 'Vorlage konnte nicht erstellt werden',
|
||||||
|
'admin.packingTemplates.deleteError': 'Vorlage konnte nicht gelöscht werden',
|
||||||
|
'admin.packingTemplates.saveError': 'Fehler beim Speichern',
|
||||||
|
|
||||||
// Addons
|
// Addons
|
||||||
'admin.tabs.addons': 'Addons',
|
'admin.tabs.addons': 'Addons',
|
||||||
'admin.addons.title': 'Addons',
|
'admin.addons.title': 'Addons',
|
||||||
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.',
|
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.',
|
||||||
|
'admin.addons.catalog.packing.name': 'Packliste',
|
||||||
|
'admin.addons.catalog.packing.description': 'Checklisten zum Kofferpacken für jede Reise',
|
||||||
|
'admin.addons.catalog.budget.name': 'Budget',
|
||||||
|
'admin.addons.catalog.budget.description': 'Ausgaben verfolgen und Reisebudget planen',
|
||||||
|
'admin.addons.catalog.documents.name': 'Dokumente',
|
||||||
|
'admin.addons.catalog.documents.description': 'Reisedokumente speichern und verwalten',
|
||||||
|
'admin.addons.catalog.vacay.name': 'Vacay',
|
||||||
|
'admin.addons.catalog.vacay.description': 'Persönlicher Urlaubsplaner mit Kalenderansicht',
|
||||||
|
'admin.addons.catalog.atlas.name': 'Atlas',
|
||||||
|
'admin.addons.catalog.atlas.description': 'Weltkarte mit besuchten Ländern und Reisestatistiken',
|
||||||
|
'admin.addons.catalog.collab.name': 'Collab',
|
||||||
|
'admin.addons.catalog.collab.description': 'Echtzeit-Notizen, Umfragen und Chat für die Reiseplanung',
|
||||||
|
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
|
||||||
|
'admin.addons.catalog.memories.description': 'Reisefotos über deine Immich-Instanz teilen',
|
||||||
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
|
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
|
||||||
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
|
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
|
||||||
'admin.addons.enabled': 'Aktiviert',
|
'admin.addons.enabled': 'Aktiviert',
|
||||||
@@ -321,6 +458,19 @@ const de: Record<string, string> = {
|
|||||||
|
|
||||||
// GitHub
|
// GitHub
|
||||||
'admin.tabs.github': 'GitHub',
|
'admin.tabs.github': 'GitHub',
|
||||||
|
|
||||||
|
'admin.audit.subtitle': 'Sicherheitsrelevante und administrative Ereignisse (Backups, Benutzer, MFA, Einstellungen).',
|
||||||
|
'admin.audit.empty': 'Noch keine Audit-Einträge.',
|
||||||
|
'admin.audit.refresh': 'Aktualisieren',
|
||||||
|
'admin.audit.loadMore': 'Mehr laden',
|
||||||
|
'admin.audit.showing': '{count} geladen · {total} gesamt',
|
||||||
|
'admin.audit.col.time': 'Zeit',
|
||||||
|
'admin.audit.col.user': 'Benutzer',
|
||||||
|
'admin.audit.col.action': 'Aktion',
|
||||||
|
'admin.audit.col.resource': 'Ressource',
|
||||||
|
'admin.audit.col.ip': 'IP',
|
||||||
|
'admin.audit.col.details': 'Details',
|
||||||
|
|
||||||
'admin.github.title': 'Update-Verlauf',
|
'admin.github.title': 'Update-Verlauf',
|
||||||
'admin.github.subtitle': 'Neueste Updates von {repo}',
|
'admin.github.subtitle': 'Neueste Updates von {repo}',
|
||||||
'admin.github.latest': 'Aktuell',
|
'admin.github.latest': 'Aktuell',
|
||||||
@@ -331,6 +481,7 @@ const de: Record<string, string> = {
|
|||||||
'admin.github.loading': 'Wird geladen...',
|
'admin.github.loading': 'Wird geladen...',
|
||||||
'admin.github.error': 'Releases konnten nicht geladen werden',
|
'admin.github.error': 'Releases konnten nicht geladen werden',
|
||||||
'admin.github.by': 'von',
|
'admin.github.by': 'von',
|
||||||
|
'admin.github.support': 'Hilft mir, TREK weiterzuentwickeln',
|
||||||
|
|
||||||
'admin.update.available': 'Update verfügbar',
|
'admin.update.available': 'Update verfügbar',
|
||||||
'admin.update.text': 'TREK {version} ist verfügbar. Du verwendest {current}.',
|
'admin.update.text': 'TREK {version} ist verfügbar. Du verwendest {current}.',
|
||||||
@@ -382,11 +533,23 @@ const de: Record<string, string> = {
|
|||||||
'vacay.remaining': 'Rest',
|
'vacay.remaining': 'Rest',
|
||||||
'vacay.carriedOver': 'aus {year}',
|
'vacay.carriedOver': 'aus {year}',
|
||||||
'vacay.blockWeekends': 'Wochenenden sperren',
|
'vacay.blockWeekends': 'Wochenenden sperren',
|
||||||
'vacay.blockWeekendsHint': 'Verhindert Urlaubseinträge an Samstagen und Sonntagen',
|
'vacay.blockWeekendsHint': 'Verhindert Urlaubseinträge an Wochenendtagen',
|
||||||
|
'vacay.weekendDays': 'Wochenendtage',
|
||||||
|
'vacay.mon': 'Mo',
|
||||||
|
'vacay.tue': 'Di',
|
||||||
|
'vacay.wed': 'Mi',
|
||||||
|
'vacay.thu': 'Do',
|
||||||
|
'vacay.fri': 'Fr',
|
||||||
|
'vacay.sat': 'Sa',
|
||||||
|
'vacay.sun': 'So',
|
||||||
'vacay.publicHolidays': 'Feiertage',
|
'vacay.publicHolidays': 'Feiertage',
|
||||||
'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren',
|
'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren',
|
||||||
'vacay.selectCountry': 'Land wählen',
|
'vacay.selectCountry': 'Land wählen',
|
||||||
'vacay.selectRegion': 'Region wählen (optional)',
|
'vacay.selectRegion': 'Region wählen (optional)',
|
||||||
|
'vacay.addCalendar': 'Kalender hinzufügen',
|
||||||
|
'vacay.calendarLabel': 'Bezeichnung (optional)',
|
||||||
|
'vacay.calendarColor': 'Farbe',
|
||||||
|
'vacay.noCalendars': 'Noch keine Feiertagskalender angelegt',
|
||||||
'vacay.companyHolidays': 'Betriebsferien',
|
'vacay.companyHolidays': 'Betriebsferien',
|
||||||
'vacay.companyHolidaysHint': 'Erlaubt das Markieren von unternehmensweiten Feiertagen',
|
'vacay.companyHolidaysHint': 'Erlaubt das Markieren von unternehmensweiten Feiertagen',
|
||||||
'vacay.companyHolidaysNoDeduct': 'Betriebsferien werden nicht vom Urlaubskontingent abgezogen.',
|
'vacay.companyHolidaysNoDeduct': 'Betriebsferien werden nicht vom Urlaubskontingent abgezogen.',
|
||||||
@@ -431,6 +594,25 @@ const de: Record<string, string> = {
|
|||||||
'atlas.countries': 'Länder',
|
'atlas.countries': 'Länder',
|
||||||
'atlas.trips': 'Reisen',
|
'atlas.trips': 'Reisen',
|
||||||
'atlas.places': 'Orte',
|
'atlas.places': 'Orte',
|
||||||
|
'atlas.unmark': 'Entfernen',
|
||||||
|
'atlas.confirmMark': 'Dieses Land als besucht markieren?',
|
||||||
|
'atlas.confirmUnmark': 'Dieses Land von der Liste entfernen?',
|
||||||
|
'atlas.markVisited': 'Als besucht markieren',
|
||||||
|
'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen',
|
||||||
|
'atlas.addToBucket': 'Zur Bucket List',
|
||||||
|
'atlas.addPoi': 'Ort hinzufügen',
|
||||||
|
'atlas.bucketNamePlaceholder': 'Name (Land, Stadt, Ort...)',
|
||||||
|
'atlas.month': 'Monat',
|
||||||
|
'atlas.year': 'Jahr',
|
||||||
|
'atlas.addToBucketHint': 'Als Wunschziel speichern',
|
||||||
|
'atlas.bucketWhen': 'Wann möchtest du dorthin reisen?',
|
||||||
|
'atlas.statsTab': 'Statistik',
|
||||||
|
'atlas.bucketTab': 'Bucket List',
|
||||||
|
'atlas.addBucket': 'Zur Bucket List hinzufügen',
|
||||||
|
'atlas.bucketNamePlaceholder': 'Ort oder Reiseziel...',
|
||||||
|
'atlas.bucketNotesPlaceholder': 'Notizen (optional)',
|
||||||
|
'atlas.bucketEmpty': 'Deine Bucket List ist leer',
|
||||||
|
'atlas.bucketEmptyHint': 'Füge Orte hinzu, die du besuchen möchtest',
|
||||||
'atlas.days': 'Tage',
|
'atlas.days': 'Tage',
|
||||||
'atlas.visitedCountries': 'Besuchte Länder',
|
'atlas.visitedCountries': 'Besuchte Länder',
|
||||||
'atlas.cities': 'Städte',
|
'atlas.cities': 'Städte',
|
||||||
@@ -485,6 +667,12 @@ const de: Record<string, string> = {
|
|||||||
|
|
||||||
// Day Plan Sidebar
|
// Day Plan Sidebar
|
||||||
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
|
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
|
||||||
|
'dayplan.cannotReorderTransport': 'Buchungen mit fester Uhrzeit können nicht verschoben werden',
|
||||||
|
'dayplan.confirmRemoveTimeTitle': 'Uhrzeit entfernen?',
|
||||||
|
'dayplan.confirmRemoveTimeBody': 'Dieser Ort hat eine feste Uhrzeit ({time}). Durch das Verschieben wird die Uhrzeit entfernt und der Ort kann frei sortiert werden.',
|
||||||
|
'dayplan.confirmRemoveTimeAction': 'Uhrzeit entfernen & verschieben',
|
||||||
|
'dayplan.cannotDropOnTimed': 'Orte können nicht zwischen zeitgebundene Einträge geschoben werden',
|
||||||
|
'dayplan.cannotBreakChronology': 'Die zeitliche Reihenfolge von Uhrzeiten und Buchungen darf nicht verletzt werden',
|
||||||
'dayplan.addNote': 'Notiz hinzufügen',
|
'dayplan.addNote': 'Notiz hinzufügen',
|
||||||
'dayplan.editNote': 'Notiz bearbeiten',
|
'dayplan.editNote': 'Notiz bearbeiten',
|
||||||
'dayplan.noteAdd': 'Notiz hinzufügen',
|
'dayplan.noteAdd': 'Notiz hinzufügen',
|
||||||
@@ -510,11 +698,17 @@ const de: Record<string, string> = {
|
|||||||
|
|
||||||
// Places Sidebar
|
// Places Sidebar
|
||||||
'places.addPlace': 'Ort/Aktivität hinzufügen',
|
'places.addPlace': 'Ort/Aktivität hinzufügen',
|
||||||
|
'places.importGpx': 'GPX importieren',
|
||||||
|
'places.gpxImported': '{count} Orte aus GPX importiert',
|
||||||
|
'places.urlResolved': 'Ort aus URL importiert',
|
||||||
|
'places.gpxError': 'GPX-Import fehlgeschlagen',
|
||||||
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
||||||
'places.all': 'Alle',
|
'places.all': 'Alle',
|
||||||
'places.unplanned': 'Ungeplant',
|
'places.unplanned': 'Ungeplant',
|
||||||
'places.search': 'Orte suchen...',
|
'places.search': 'Orte suchen...',
|
||||||
'places.allCategories': 'Alle Kategorien',
|
'places.allCategories': 'Alle Kategorien',
|
||||||
|
'places.categoriesSelected': 'Kategorien',
|
||||||
|
'places.clearFilter': 'Filter zurücksetzen',
|
||||||
'places.count': '{count} Orte',
|
'places.count': '{count} Orte',
|
||||||
'places.countSingular': '1 Ort',
|
'places.countSingular': '1 Ort',
|
||||||
'places.allPlanned': 'Alle Orte sind eingeplant',
|
'places.allPlanned': 'Alle Orte sind eingeplant',
|
||||||
@@ -598,13 +792,13 @@ const de: Record<string, string> = {
|
|||||||
'reservations.meta.linkAccommodation': 'Unterkunft',
|
'reservations.meta.linkAccommodation': 'Unterkunft',
|
||||||
'reservations.meta.pickAccommodation': 'Mit Unterkunft verknüpfen',
|
'reservations.meta.pickAccommodation': 'Mit Unterkunft verknüpfen',
|
||||||
'reservations.meta.noAccommodation': 'Keine',
|
'reservations.meta.noAccommodation': 'Keine',
|
||||||
'reservations.meta.hotelPlace': 'Hotel',
|
'reservations.meta.hotelPlace': 'Unterkunft',
|
||||||
'reservations.meta.pickHotel': 'Hotel auswählen',
|
'reservations.meta.pickHotel': 'Unterkunft auswählen',
|
||||||
'reservations.meta.fromDay': 'Von',
|
'reservations.meta.fromDay': 'Von',
|
||||||
'reservations.meta.toDay': 'Bis',
|
'reservations.meta.toDay': 'Bis',
|
||||||
'reservations.meta.selectDay': 'Tag wählen',
|
'reservations.meta.selectDay': 'Tag wählen',
|
||||||
'reservations.type.flight': 'Flug',
|
'reservations.type.flight': 'Flug',
|
||||||
'reservations.type.hotel': 'Hotel',
|
'reservations.type.hotel': 'Unterkunft',
|
||||||
'reservations.type.restaurant': 'Restaurant',
|
'reservations.type.restaurant': 'Restaurant',
|
||||||
'reservations.type.train': 'Zug',
|
'reservations.type.train': 'Zug',
|
||||||
'reservations.type.car': 'Mietwagen',
|
'reservations.type.car': 'Mietwagen',
|
||||||
@@ -613,6 +807,8 @@ const de: Record<string, string> = {
|
|||||||
'reservations.type.tour': 'Tour',
|
'reservations.type.tour': 'Tour',
|
||||||
'reservations.type.other': 'Sonstiges',
|
'reservations.type.other': 'Sonstiges',
|
||||||
'reservations.confirm.delete': 'Möchtest du die Reservierung "{name}" wirklich löschen?',
|
'reservations.confirm.delete': 'Möchtest du die Reservierung "{name}" wirklich löschen?',
|
||||||
|
'reservations.confirm.deleteTitle': 'Buchung löschen?',
|
||||||
|
'reservations.confirm.deleteBody': '"{name}" wird unwiderruflich gelöscht.',
|
||||||
'reservations.toast.updated': 'Reservierung aktualisiert',
|
'reservations.toast.updated': 'Reservierung aktualisiert',
|
||||||
'reservations.toast.removed': 'Reservierung gelöscht',
|
'reservations.toast.removed': 'Reservierung gelöscht',
|
||||||
'reservations.toast.saveError': 'Fehler beim Speichern',
|
'reservations.toast.saveError': 'Fehler beim Speichern',
|
||||||
@@ -636,6 +832,7 @@ const de: Record<string, string> = {
|
|||||||
'reservations.pendingSave': 'wird gespeichert…',
|
'reservations.pendingSave': 'wird gespeichert…',
|
||||||
'reservations.uploading': 'Wird hochgeladen...',
|
'reservations.uploading': 'Wird hochgeladen...',
|
||||||
'reservations.attachFile': 'Datei anhängen',
|
'reservations.attachFile': 'Datei anhängen',
|
||||||
|
'reservations.linkExisting': 'Vorhandene verknüpfen',
|
||||||
'reservations.linkAssignment': 'Mit Tagesplanung verknüpfen',
|
'reservations.linkAssignment': 'Mit Tagesplanung verknüpfen',
|
||||||
'reservations.pickAssignment': 'Zuordnung aus dem Plan wählen...',
|
'reservations.pickAssignment': 'Zuordnung aus dem Plan wählen...',
|
||||||
'reservations.noAssignment': 'Keine Verknüpfung',
|
'reservations.noAssignment': 'Keine Verknüpfung',
|
||||||
@@ -669,6 +866,9 @@ const de: Record<string, string> = {
|
|||||||
'budget.paid': 'Bezahlt',
|
'budget.paid': 'Bezahlt',
|
||||||
'budget.open': 'Offen',
|
'budget.open': 'Offen',
|
||||||
'budget.noMembers': 'Keine Teilnehmer zugewiesen',
|
'budget.noMembers': 'Keine Teilnehmer zugewiesen',
|
||||||
|
'budget.settlement': 'Ausgleich',
|
||||||
|
'budget.settlementInfo': 'Klicke auf ein Mitglied-Bild bei einem Eintrag, um es grün zu markieren — das bedeutet, diese Person hat bezahlt. Der Ausgleich zeigt dann, wer wem wie viel schuldet.',
|
||||||
|
'budget.netBalances': 'Netto-Salden',
|
||||||
|
|
||||||
// Files
|
// Files
|
||||||
'files.title': 'Dateien',
|
'files.title': 'Dateien',
|
||||||
@@ -722,6 +922,15 @@ const de: Record<string, string> = {
|
|||||||
// Packing
|
// Packing
|
||||||
'packing.title': 'Packliste',
|
'packing.title': 'Packliste',
|
||||||
'packing.empty': 'Packliste ist leer',
|
'packing.empty': 'Packliste ist leer',
|
||||||
|
'packing.import': 'Importieren',
|
||||||
|
'packing.importTitle': 'Packliste importieren',
|
||||||
|
'packing.importHint': 'Ein Eintrag pro Zeile. Format: Kategorie, Name, Gewicht in g (optional), Tasche (optional), checked/unchecked (optional)',
|
||||||
|
'packing.importPlaceholder': 'Hygiene, Zahnbürste\nKleidung, T-Shirts, 200\nDokumente, Reisepass, , Handgepäck\nElektronik, Ladekabel, 50, Koffer, checked',
|
||||||
|
'packing.importCsv': 'CSV/TXT laden',
|
||||||
|
'packing.importAction': '{count} importieren',
|
||||||
|
'packing.importSuccess': '{count} Einträge importiert',
|
||||||
|
'packing.importError': 'Import fehlgeschlagen',
|
||||||
|
'packing.importEmpty': 'Keine Einträge zum Importieren',
|
||||||
'packing.progress': '{packed} von {total} gepackt ({percent}%)',
|
'packing.progress': '{packed} von {total} gepackt ({percent}%)',
|
||||||
'packing.clearChecked': '{count} abgehakte entfernen',
|
'packing.clearChecked': '{count} abgehakte entfernen',
|
||||||
'packing.clearCheckedShort': '{count} entfernen',
|
'packing.clearCheckedShort': '{count} entfernen',
|
||||||
@@ -741,6 +950,21 @@ const de: Record<string, string> = {
|
|||||||
'packing.menuCheckAll': 'Alle abhaken',
|
'packing.menuCheckAll': 'Alle abhaken',
|
||||||
'packing.menuUncheckAll': 'Alle Haken entfernen',
|
'packing.menuUncheckAll': 'Alle Haken entfernen',
|
||||||
'packing.menuDeleteCat': 'Kategorie löschen',
|
'packing.menuDeleteCat': 'Kategorie löschen',
|
||||||
|
'packing.assignUser': 'Benutzer zuweisen',
|
||||||
|
'packing.noMembers': 'Keine Mitglieder',
|
||||||
|
'packing.addItem': 'Eintrag hinzufügen',
|
||||||
|
'packing.addItemPlaceholder': 'Artikelname...',
|
||||||
|
'packing.addCategory': 'Kategorie hinzufügen',
|
||||||
|
'packing.newCategoryPlaceholder': 'Kategoriename (z.B. Kleidung)',
|
||||||
|
'packing.applyTemplate': 'Vorlage anwenden',
|
||||||
|
'packing.template': 'Vorlage',
|
||||||
|
'packing.templateApplied': '{count} Einträge aus Vorlage hinzugefügt',
|
||||||
|
'packing.templateError': 'Vorlage konnte nicht angewendet werden',
|
||||||
|
'packing.bags': 'Gepäck',
|
||||||
|
'packing.noBag': 'Nicht zugeordnet',
|
||||||
|
'packing.totalWeight': 'Gesamtgewicht',
|
||||||
|
'packing.bagName': 'Name...',
|
||||||
|
'packing.addBag': 'Gepäck hinzufügen',
|
||||||
'packing.changeCategory': 'Kategorie ändern',
|
'packing.changeCategory': 'Kategorie ändern',
|
||||||
'packing.confirm.clearChecked': 'Möchtest du {count} abgehakte Gegenstände wirklich entfernen?',
|
'packing.confirm.clearChecked': 'Möchtest du {count} abgehakte Gegenstände wirklich entfernen?',
|
||||||
'packing.confirm.deleteCat': 'Möchtest du die Kategorie "{name}" mit {count} Gegenständen wirklich löschen?',
|
'packing.confirm.deleteCat': 'Möchtest du die Kategorie "{name}" mit {count} Gegenständen wirklich löschen?',
|
||||||
@@ -858,7 +1082,27 @@ const de: Record<string, string> = {
|
|||||||
'backup.auto.enable': 'Auto-Backup aktivieren',
|
'backup.auto.enable': 'Auto-Backup aktivieren',
|
||||||
'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt',
|
'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt',
|
||||||
'backup.auto.interval': 'Intervall',
|
'backup.auto.interval': 'Intervall',
|
||||||
|
'backup.auto.hour': 'Ausführung um',
|
||||||
|
'backup.auto.hourHint': 'Lokale Serverzeit ({format}-Format)',
|
||||||
|
'backup.auto.dayOfWeek': 'Wochentag',
|
||||||
|
'backup.auto.dayOfMonth': 'Tag des Monats',
|
||||||
|
'backup.auto.dayOfMonthHint': 'Auf 1–28 beschränkt, um mit allen Monaten kompatibel zu sein',
|
||||||
|
'backup.auto.scheduleSummary': 'Zeitplan',
|
||||||
|
'backup.auto.summaryDaily': 'Täglich um {hour}:00',
|
||||||
|
'backup.auto.summaryWeekly': 'Jeden {day} um {hour}:00',
|
||||||
|
'backup.auto.summaryMonthly': 'Am {day}. jedes Monats um {hour}:00',
|
||||||
|
'backup.auto.envLocked': 'Docker',
|
||||||
|
'backup.auto.envLockedHint': 'Auto-Backup wird über Docker-Umgebungsvariablen konfiguriert. Ändern Sie Ihre docker-compose.yml und starten Sie den Container neu.',
|
||||||
|
'backup.auto.copyEnv': 'Docker-Umgebungsvariablen kopieren',
|
||||||
|
'backup.auto.envCopied': 'Docker-Umgebungsvariablen in die Zwischenablage kopiert',
|
||||||
'backup.auto.keepLabel': 'Alte Backups löschen nach',
|
'backup.auto.keepLabel': 'Alte Backups löschen nach',
|
||||||
|
'backup.dow.sunday': 'So',
|
||||||
|
'backup.dow.monday': 'Mo',
|
||||||
|
'backup.dow.tuesday': 'Di',
|
||||||
|
'backup.dow.wednesday': 'Mi',
|
||||||
|
'backup.dow.thursday': 'Do',
|
||||||
|
'backup.dow.friday': 'Fr',
|
||||||
|
'backup.dow.saturday': 'Sa',
|
||||||
'backup.interval.hourly': 'Stündlich',
|
'backup.interval.hourly': 'Stündlich',
|
||||||
'backup.interval.daily': 'Täglich',
|
'backup.interval.daily': 'Täglich',
|
||||||
'backup.interval.weekly': 'Wöchentlich',
|
'backup.interval.weekly': 'Wöchentlich',
|
||||||
@@ -985,6 +1229,45 @@ const de: Record<string, string> = {
|
|||||||
'day.editAccommodation': 'Unterkunft bearbeiten',
|
'day.editAccommodation': 'Unterkunft bearbeiten',
|
||||||
'day.reservations': 'Reservierungen',
|
'day.reservations': 'Reservierungen',
|
||||||
|
|
||||||
|
// Photos / Immich
|
||||||
|
'memories.title': 'Fotos',
|
||||||
|
'memories.notConnected': 'Immich nicht verbunden',
|
||||||
|
'memories.notConnectedHint': 'Verbinde deine Immich-Instanz in den Einstellungen, um deine Reisefotos hier zu sehen.',
|
||||||
|
'memories.noDates': 'Füge Daten zu deiner Reise hinzu, um Fotos zu laden.',
|
||||||
|
'memories.noPhotos': 'Keine Fotos gefunden',
|
||||||
|
'memories.noPhotosHint': 'Keine Fotos in Immich für den Zeitraum dieser Reise gefunden.',
|
||||||
|
'memories.photosFound': 'Fotos',
|
||||||
|
'memories.fromOthers': 'von anderen',
|
||||||
|
'memories.sharePhotos': 'Fotos teilen',
|
||||||
|
'memories.sharing': 'Wird geteilt',
|
||||||
|
'memories.reviewTitle': 'Deine Fotos prüfen',
|
||||||
|
'memories.reviewHint': 'Klicke auf Fotos, um sie vom Teilen auszuschließen.',
|
||||||
|
'memories.shareCount': '{count} Fotos teilen',
|
||||||
|
'memories.immichUrl': 'Immich Server URL',
|
||||||
|
'memories.immichApiKey': 'API-Schlüssel',
|
||||||
|
'memories.testConnection': 'Verbindung testen',
|
||||||
|
'memories.connected': 'Verbunden',
|
||||||
|
'memories.disconnected': 'Nicht verbunden',
|
||||||
|
'memories.connectionSuccess': 'Verbindung zu Immich hergestellt',
|
||||||
|
'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen',
|
||||||
|
'memories.saved': 'Immich-Einstellungen gespeichert',
|
||||||
|
'memories.addPhotos': 'Fotos hinzufügen',
|
||||||
|
'memories.selectPhotos': 'Fotos aus Immich auswählen',
|
||||||
|
'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.',
|
||||||
|
'memories.selected': 'ausgewählt',
|
||||||
|
'memories.addSelected': '{count} Fotos hinzufügen',
|
||||||
|
'memories.alreadyAdded': 'Hinzugefügt',
|
||||||
|
'memories.private': 'Privat',
|
||||||
|
'memories.stopSharing': 'Nicht mehr teilen',
|
||||||
|
'memories.oldest': 'Älteste zuerst',
|
||||||
|
'memories.newest': 'Neueste zuerst',
|
||||||
|
'memories.allLocations': 'Alle Orte',
|
||||||
|
'memories.tripDates': 'Trip-Zeitraum',
|
||||||
|
'memories.allPhotos': 'Alle Fotos',
|
||||||
|
'memories.confirmShareTitle': 'Mit Reisebegleitern teilen?',
|
||||||
|
'memories.confirmShareHint': '{count} Fotos werden für alle Mitglieder dieses Trips sichtbar. Du kannst einzelne Fotos nachträglich auf privat setzen.',
|
||||||
|
'memories.confirmShareButton': 'Fotos teilen',
|
||||||
|
|
||||||
// Collab Addon
|
// Collab Addon
|
||||||
'collab.tabs.chat': 'Chat',
|
'collab.tabs.chat': 'Chat',
|
||||||
'collab.tabs.notes': 'Notizen',
|
'collab.tabs.notes': 'Notizen',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const en: Record<string, string> = {
|
const en: Record<string, string | { name: string; category: string }[]> = {
|
||||||
// Common
|
// Common
|
||||||
'common.save': 'Save',
|
'common.save': 'Save',
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
@@ -51,9 +51,18 @@ const en: Record<string, string> = {
|
|||||||
'dashboard.subtitle.activeMany': '{count} active trips',
|
'dashboard.subtitle.activeMany': '{count} active trips',
|
||||||
'dashboard.subtitle.archivedSuffix': ' · {count} archived',
|
'dashboard.subtitle.archivedSuffix': ' · {count} archived',
|
||||||
'dashboard.newTrip': 'New Trip',
|
'dashboard.newTrip': 'New Trip',
|
||||||
|
'dashboard.gridView': 'Grid view',
|
||||||
|
'dashboard.listView': 'List view',
|
||||||
'dashboard.currency': 'Currency',
|
'dashboard.currency': 'Currency',
|
||||||
'dashboard.timezone': 'Timezones',
|
'dashboard.timezone': 'Timezones',
|
||||||
'dashboard.localTime': 'Local',
|
'dashboard.localTime': 'Local',
|
||||||
|
'dashboard.timezoneCustomTitle': 'Custom Timezone',
|
||||||
|
'dashboard.timezoneCustomLabelPlaceholder': 'Label (optional)',
|
||||||
|
'dashboard.timezoneCustomTzPlaceholder': 'e.g. America/New_York',
|
||||||
|
'dashboard.timezoneCustomAdd': 'Add',
|
||||||
|
'dashboard.timezoneCustomErrorEmpty': 'Enter a timezone identifier',
|
||||||
|
'dashboard.timezoneCustomErrorInvalid': 'Invalid timezone. Use format like Europe/Berlin',
|
||||||
|
'dashboard.timezoneCustomErrorDuplicate': 'Already added',
|
||||||
'dashboard.emptyTitle': 'No trips yet',
|
'dashboard.emptyTitle': 'No trips yet',
|
||||||
'dashboard.emptyText': 'Create your first trip and start planning!',
|
'dashboard.emptyText': 'Create your first trip and start planning!',
|
||||||
'dashboard.emptyButton': 'Create First Trip',
|
'dashboard.emptyButton': 'Create First Trip',
|
||||||
@@ -92,7 +101,9 @@ const en: Record<string, string> = {
|
|||||||
'dashboard.endDate': 'End Date',
|
'dashboard.endDate': 'End Date',
|
||||||
'dashboard.noDateHint': 'No date set — 7 default days will be created. You can change this anytime.',
|
'dashboard.noDateHint': 'No date set — 7 default days will be created. You can change this anytime.',
|
||||||
'dashboard.coverImage': 'Cover Image',
|
'dashboard.coverImage': 'Cover Image',
|
||||||
'dashboard.addCoverImage': 'Add cover image',
|
'dashboard.addCoverImage': 'Add cover image (or drag & drop)',
|
||||||
|
'dashboard.addMembers': 'Travel buddies',
|
||||||
|
'dashboard.addMember': 'Add member',
|
||||||
'dashboard.coverSaved': 'Cover image saved',
|
'dashboard.coverSaved': 'Cover image saved',
|
||||||
'dashboard.coverUploadError': 'Failed to upload',
|
'dashboard.coverUploadError': 'Failed to upload',
|
||||||
'dashboard.coverRemoveError': 'Failed to remove',
|
'dashboard.coverRemoveError': 'Failed to remove',
|
||||||
@@ -128,6 +139,50 @@ const en: Record<string, string> = {
|
|||||||
'settings.temperature': 'Temperature Unit',
|
'settings.temperature': 'Temperature Unit',
|
||||||
'settings.timeFormat': 'Time Format',
|
'settings.timeFormat': 'Time Format',
|
||||||
'settings.routeCalculation': 'Route Calculation',
|
'settings.routeCalculation': 'Route Calculation',
|
||||||
|
'settings.blurBookingCodes': 'Blur Booking Codes',
|
||||||
|
'settings.notifications': 'Notifications',
|
||||||
|
'settings.notifyTripInvite': 'Trip invitations',
|
||||||
|
'settings.notifyBookingChange': 'Booking changes',
|
||||||
|
'settings.notifyTripReminder': 'Trip reminders',
|
||||||
|
'settings.notifyVacayInvite': 'Vacay fusion invitations',
|
||||||
|
'settings.notifyPhotosShared': 'Shared photos (Immich)',
|
||||||
|
'settings.notifyCollabMessage': 'Chat messages (Collab)',
|
||||||
|
'settings.notifyPackingTagged': 'Packing list: assignments',
|
||||||
|
'settings.notifyWebhook': 'Webhook notifications',
|
||||||
|
'admin.smtp.title': 'Email & Notifications',
|
||||||
|
'admin.smtp.hint': 'SMTP configuration for email notifications. Optional: Webhook URL for Discord, Slack, etc.',
|
||||||
|
'admin.smtp.testButton': 'Send test email',
|
||||||
|
'admin.smtp.testSuccess': 'Test email sent successfully',
|
||||||
|
'admin.smtp.testFailed': 'Test email failed',
|
||||||
|
'dayplan.icsTooltip': 'Export calendar (ICS)',
|
||||||
|
'share.linkTitle': 'Public Link',
|
||||||
|
'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
|
||||||
|
'share.createLink': 'Create link',
|
||||||
|
'share.deleteLink': 'Delete link',
|
||||||
|
'share.createError': 'Could not create link',
|
||||||
|
'common.copy': 'Copy',
|
||||||
|
'common.copied': 'Copied',
|
||||||
|
'share.permMap': 'Map & Plan',
|
||||||
|
'share.permBookings': 'Bookings',
|
||||||
|
'share.permPacking': 'Packing',
|
||||||
|
'shared.expired': 'Link expired or invalid',
|
||||||
|
'shared.expiredHint': 'This shared trip link is no longer active.',
|
||||||
|
'shared.readOnly': 'Read-only shared view',
|
||||||
|
'shared.tabPlan': 'Plan',
|
||||||
|
'shared.tabBookings': 'Bookings',
|
||||||
|
'shared.tabPacking': 'Packing',
|
||||||
|
'shared.tabBudget': 'Budget',
|
||||||
|
'shared.tabChat': 'Chat',
|
||||||
|
'shared.days': 'days',
|
||||||
|
'shared.places': 'places',
|
||||||
|
'shared.other': 'Other',
|
||||||
|
'shared.totalBudget': 'Total Budget',
|
||||||
|
'shared.messages': 'messages',
|
||||||
|
'shared.sharedVia': 'Shared via',
|
||||||
|
'shared.confirmed': 'Confirmed',
|
||||||
|
'shared.pending': 'Pending',
|
||||||
|
'share.permBudget': 'Budget',
|
||||||
|
'share.permCollab': 'Chat',
|
||||||
'settings.on': 'On',
|
'settings.on': 'On',
|
||||||
'settings.off': 'Off',
|
'settings.off': 'Off',
|
||||||
'settings.account': 'Account',
|
'settings.account': 'Account',
|
||||||
@@ -164,6 +219,22 @@ const en: Record<string, string> = {
|
|||||||
'settings.avatarUploaded': 'Profile picture updated',
|
'settings.avatarUploaded': 'Profile picture updated',
|
||||||
'settings.avatarRemoved': 'Profile picture removed',
|
'settings.avatarRemoved': 'Profile picture removed',
|
||||||
'settings.avatarError': 'Upload failed',
|
'settings.avatarError': 'Upload failed',
|
||||||
|
'settings.mfa.title': 'Two-factor authentication (2FA)',
|
||||||
|
'settings.mfa.description': 'Adds a second step when you sign in with email and password. Use an authenticator app (Google Authenticator, Authy, etc.).',
|
||||||
|
'settings.mfa.enabled': '2FA is enabled on your account.',
|
||||||
|
'settings.mfa.disabled': '2FA is not enabled.',
|
||||||
|
'settings.mfa.setup': 'Set up authenticator',
|
||||||
|
'settings.mfa.scanQr': 'Scan this QR code with your app, or enter the secret manually.',
|
||||||
|
'settings.mfa.secretLabel': 'Secret key (manual entry)',
|
||||||
|
'settings.mfa.codePlaceholder': '6-digit code',
|
||||||
|
'settings.mfa.enable': 'Enable 2FA',
|
||||||
|
'settings.mfa.cancelSetup': 'Cancel',
|
||||||
|
'settings.mfa.disableTitle': 'Disable 2FA',
|
||||||
|
'settings.mfa.disableHint': 'Enter your account password and a current code from your authenticator.',
|
||||||
|
'settings.mfa.disable': 'Disable 2FA',
|
||||||
|
'settings.mfa.toastEnabled': 'Two-factor authentication enabled',
|
||||||
|
'settings.mfa.toastDisabled': 'Two-factor authentication disabled',
|
||||||
|
'settings.mfa.demoBlocked': 'Not available in demo mode',
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
'login.error': 'Login failed. Please check your credentials.',
|
'login.error': 'Login failed. Please check your credentials.',
|
||||||
@@ -206,7 +277,15 @@ const en: Record<string, string> = {
|
|||||||
'login.oidc.invalidState': 'Invalid session. Please try again.',
|
'login.oidc.invalidState': 'Invalid session. Please try again.',
|
||||||
'login.demoFailed': 'Demo login failed',
|
'login.demoFailed': 'Demo login failed',
|
||||||
'login.oidcSignIn': 'Sign in with {name}',
|
'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.demoHint': 'Try the demo — no registration needed',
|
||||||
|
'login.mfaTitle': 'Two-factor authentication',
|
||||||
|
'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.',
|
||||||
|
'login.mfaCodeLabel': 'Verification code',
|
||||||
|
'login.mfaCodeRequired': 'Enter the code from your authenticator app.',
|
||||||
|
'login.mfaHint': 'Open Google Authenticator, Authy, or another TOTP app.',
|
||||||
|
'login.mfaBack': '← Back to sign in',
|
||||||
|
'login.mfaVerify': 'Verify',
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
'register.passwordMismatch': 'Passwords do not match',
|
'register.passwordMismatch': 'Passwords do not match',
|
||||||
@@ -236,6 +315,7 @@ const en: Record<string, string> = {
|
|||||||
'admin.tabs.users': 'Users',
|
'admin.tabs.users': 'Users',
|
||||||
'admin.tabs.categories': 'Categories',
|
'admin.tabs.categories': 'Categories',
|
||||||
'admin.tabs.backup': 'Backup',
|
'admin.tabs.backup': 'Backup',
|
||||||
|
'admin.tabs.audit': 'Audit log',
|
||||||
'admin.stats.users': 'Users',
|
'admin.stats.users': 'Users',
|
||||||
'admin.stats.trips': 'Trips',
|
'admin.stats.trips': 'Trips',
|
||||||
'admin.stats.places': 'Places',
|
'admin.stats.places': 'Places',
|
||||||
@@ -264,6 +344,24 @@ const en: Record<string, string> = {
|
|||||||
'admin.toast.createError': 'Failed to create user',
|
'admin.toast.createError': 'Failed to create user',
|
||||||
'admin.toast.fieldsRequired': 'Username, email and password are required',
|
'admin.toast.fieldsRequired': 'Username, email and password are required',
|
||||||
'admin.createUser': 'Create User',
|
'admin.createUser': 'Create User',
|
||||||
|
'admin.invite.title': 'Invite Links',
|
||||||
|
'admin.invite.subtitle': 'Create one-time registration links',
|
||||||
|
'admin.invite.create': 'Create Link',
|
||||||
|
'admin.invite.createAndCopy': 'Create & Copy',
|
||||||
|
'admin.invite.empty': 'No invite links created yet',
|
||||||
|
'admin.invite.maxUses': 'Max. Uses',
|
||||||
|
'admin.invite.expiry': 'Expires after',
|
||||||
|
'admin.invite.uses': 'used',
|
||||||
|
'admin.invite.expiresAt': 'expires',
|
||||||
|
'admin.invite.createdBy': 'by',
|
||||||
|
'admin.invite.active': 'Active',
|
||||||
|
'admin.invite.expired': 'Expired',
|
||||||
|
'admin.invite.usedUp': 'Used up',
|
||||||
|
'admin.invite.copied': 'Invite link copied to clipboard',
|
||||||
|
'admin.invite.copyLink': 'Copy link',
|
||||||
|
'admin.invite.deleted': 'Invite link deleted',
|
||||||
|
'admin.invite.createError': 'Failed to create invite link',
|
||||||
|
'admin.invite.deleteError': 'Failed to delete invite link',
|
||||||
'admin.tabs.settings': 'Settings',
|
'admin.tabs.settings': 'Settings',
|
||||||
'admin.allowRegistration': 'Allow Registration',
|
'admin.allowRegistration': 'Allow Registration',
|
||||||
'admin.allowRegistrationHint': 'New users can register themselves',
|
'admin.allowRegistrationHint': 'New users can register themselves',
|
||||||
@@ -285,6 +383,8 @@ const en: Record<string, string> = {
|
|||||||
'admin.oidcIssuer': 'Issuer URL',
|
'admin.oidcIssuer': 'Issuer URL',
|
||||||
'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com',
|
'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com',
|
||||||
'admin.oidcSaved': 'OIDC configuration saved',
|
'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
|
// File Types
|
||||||
'admin.fileTypes': 'Allowed File Types',
|
'admin.fileTypes': 'Allowed File Types',
|
||||||
@@ -292,10 +392,47 @@ const en: Record<string, string> = {
|
|||||||
'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
|
'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
|
||||||
'admin.fileTypesSaved': 'File type settings saved',
|
'admin.fileTypesSaved': 'File type settings saved',
|
||||||
|
|
||||||
|
// Packing Templates & Bag Tracking
|
||||||
|
'admin.bagTracking.title': 'Bag Tracking',
|
||||||
|
'admin.bagTracking.subtitle': 'Enable weight and bag assignment for packing items',
|
||||||
|
'admin.tabs.config': 'Configuration',
|
||||||
|
'admin.tabs.templates': 'Packing Templates',
|
||||||
|
'admin.packingTemplates.title': 'Packing Templates',
|
||||||
|
'admin.packingTemplates.subtitle': 'Create reusable packing lists for your trips',
|
||||||
|
'admin.packingTemplates.create': 'New Template',
|
||||||
|
'admin.packingTemplates.namePlaceholder': 'Template name (e.g. Beach Holiday)',
|
||||||
|
'admin.packingTemplates.empty': 'No templates created yet',
|
||||||
|
'admin.packingTemplates.items': 'items',
|
||||||
|
'admin.packingTemplates.categories': 'categories',
|
||||||
|
'admin.packingTemplates.itemName': 'Item name',
|
||||||
|
'admin.packingTemplates.itemCategory': 'Category',
|
||||||
|
'admin.packingTemplates.categoryName': 'Category name (e.g. Clothing)',
|
||||||
|
'admin.packingTemplates.addCategory': 'Add category',
|
||||||
|
'admin.packingTemplates.created': 'Template created',
|
||||||
|
'admin.packingTemplates.deleted': 'Template deleted',
|
||||||
|
'admin.packingTemplates.loadError': 'Failed to load templates',
|
||||||
|
'admin.packingTemplates.createError': 'Failed to create template',
|
||||||
|
'admin.packingTemplates.deleteError': 'Failed to delete template',
|
||||||
|
'admin.packingTemplates.saveError': 'Failed to save',
|
||||||
|
|
||||||
// Addons
|
// Addons
|
||||||
'admin.tabs.addons': 'Addons',
|
'admin.tabs.addons': 'Addons',
|
||||||
'admin.addons.title': 'Addons',
|
'admin.addons.title': 'Addons',
|
||||||
'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.',
|
'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.',
|
||||||
|
'admin.addons.catalog.packing.name': 'Packing',
|
||||||
|
'admin.addons.catalog.packing.description': 'Checklists to prepare your luggage for each trip',
|
||||||
|
'admin.addons.catalog.budget.name': 'Budget',
|
||||||
|
'admin.addons.catalog.budget.description': 'Track expenses and plan your trip budget',
|
||||||
|
'admin.addons.catalog.documents.name': 'Documents',
|
||||||
|
'admin.addons.catalog.documents.description': 'Store and manage travel documents',
|
||||||
|
'admin.addons.catalog.vacay.name': 'Vacay',
|
||||||
|
'admin.addons.catalog.vacay.description': 'Personal vacation planner with calendar view',
|
||||||
|
'admin.addons.catalog.atlas.name': 'Atlas',
|
||||||
|
'admin.addons.catalog.atlas.description': 'World map with visited countries and travel stats',
|
||||||
|
'admin.addons.catalog.collab.name': 'Collab',
|
||||||
|
'admin.addons.catalog.collab.description': 'Real-time notes, polls, and chat for trip planning',
|
||||||
|
'admin.addons.catalog.memories.name': 'Photos (Immich)',
|
||||||
|
'admin.addons.catalog.memories.description': 'Share trip photos via your Immich instance',
|
||||||
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
|
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
|
||||||
'admin.addons.subtitleAfter': ' experience.',
|
'admin.addons.subtitleAfter': ' experience.',
|
||||||
'admin.addons.enabled': 'Enabled',
|
'admin.addons.enabled': 'Enabled',
|
||||||
@@ -321,6 +458,18 @@ const en: Record<string, string> = {
|
|||||||
|
|
||||||
// GitHub
|
// GitHub
|
||||||
'admin.tabs.github': 'GitHub',
|
'admin.tabs.github': 'GitHub',
|
||||||
|
|
||||||
|
'admin.audit.subtitle': 'Security-sensitive and administration events (backups, users, MFA, settings).',
|
||||||
|
'admin.audit.empty': 'No audit entries yet.',
|
||||||
|
'admin.audit.refresh': 'Refresh',
|
||||||
|
'admin.audit.loadMore': 'Load more',
|
||||||
|
'admin.audit.showing': '{count} loaded · {total} total',
|
||||||
|
'admin.audit.col.time': 'Time',
|
||||||
|
'admin.audit.col.user': 'User',
|
||||||
|
'admin.audit.col.action': 'Action',
|
||||||
|
'admin.audit.col.resource': 'Resource',
|
||||||
|
'admin.audit.col.ip': 'IP',
|
||||||
|
'admin.audit.col.details': 'Details',
|
||||||
'admin.github.title': 'Release History',
|
'admin.github.title': 'Release History',
|
||||||
'admin.github.subtitle': 'Latest updates from {repo}',
|
'admin.github.subtitle': 'Latest updates from {repo}',
|
||||||
'admin.github.latest': 'Latest',
|
'admin.github.latest': 'Latest',
|
||||||
@@ -331,6 +480,7 @@ const en: Record<string, string> = {
|
|||||||
'admin.github.loading': 'Loading...',
|
'admin.github.loading': 'Loading...',
|
||||||
'admin.github.error': 'Failed to load releases',
|
'admin.github.error': 'Failed to load releases',
|
||||||
'admin.github.by': 'by',
|
'admin.github.by': 'by',
|
||||||
|
'admin.github.support': 'Helps me keep building TREK',
|
||||||
|
|
||||||
'admin.update.available': 'Update available',
|
'admin.update.available': 'Update available',
|
||||||
'admin.update.text': 'TREK {version} is available. You are running {current}.',
|
'admin.update.text': 'TREK {version} is available. You are running {current}.',
|
||||||
@@ -382,11 +532,23 @@ const en: Record<string, string> = {
|
|||||||
'vacay.remaining': 'Left',
|
'vacay.remaining': 'Left',
|
||||||
'vacay.carriedOver': 'from {year}',
|
'vacay.carriedOver': 'from {year}',
|
||||||
'vacay.blockWeekends': 'Block Weekends',
|
'vacay.blockWeekends': 'Block Weekends',
|
||||||
'vacay.blockWeekendsHint': 'Prevent vacation entries on Saturdays and Sundays',
|
'vacay.blockWeekendsHint': 'Prevent vacation entries on weekend days',
|
||||||
|
'vacay.weekendDays': 'Weekend days',
|
||||||
|
'vacay.mon': 'Mon',
|
||||||
|
'vacay.tue': 'Tue',
|
||||||
|
'vacay.wed': 'Wed',
|
||||||
|
'vacay.thu': 'Thu',
|
||||||
|
'vacay.fri': 'Fri',
|
||||||
|
'vacay.sat': 'Sat',
|
||||||
|
'vacay.sun': 'Sun',
|
||||||
'vacay.publicHolidays': 'Public Holidays',
|
'vacay.publicHolidays': 'Public Holidays',
|
||||||
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
|
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
|
||||||
'vacay.selectCountry': 'Select country',
|
'vacay.selectCountry': 'Select country',
|
||||||
'vacay.selectRegion': 'Select region (optional)',
|
'vacay.selectRegion': 'Select region (optional)',
|
||||||
|
'vacay.addCalendar': 'Add calendar',
|
||||||
|
'vacay.calendarLabel': 'Label (optional)',
|
||||||
|
'vacay.calendarColor': 'Color',
|
||||||
|
'vacay.noCalendars': 'No holiday calendars added yet',
|
||||||
'vacay.companyHolidays': 'Company Holidays',
|
'vacay.companyHolidays': 'Company Holidays',
|
||||||
'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days',
|
'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days',
|
||||||
'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.',
|
'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.',
|
||||||
@@ -431,6 +593,25 @@ const en: Record<string, string> = {
|
|||||||
'atlas.countries': 'Countries',
|
'atlas.countries': 'Countries',
|
||||||
'atlas.trips': 'Trips',
|
'atlas.trips': 'Trips',
|
||||||
'atlas.places': 'Places',
|
'atlas.places': 'Places',
|
||||||
|
'atlas.unmark': 'Remove',
|
||||||
|
'atlas.confirmMark': 'Mark this country as visited?',
|
||||||
|
'atlas.confirmUnmark': 'Remove this country from your visited list?',
|
||||||
|
'atlas.markVisited': 'Mark as visited',
|
||||||
|
'atlas.markVisitedHint': 'Add this country to your visited list',
|
||||||
|
'atlas.addToBucket': 'Add to bucket list',
|
||||||
|
'atlas.addPoi': 'Add place',
|
||||||
|
'atlas.bucketNamePlaceholder': 'Name (country, city, place...)',
|
||||||
|
'atlas.month': 'Month',
|
||||||
|
'atlas.year': 'Year',
|
||||||
|
'atlas.addToBucketHint': 'Save as a place you want to visit',
|
||||||
|
'atlas.bucketWhen': 'When do you plan to visit?',
|
||||||
|
'atlas.statsTab': 'Stats',
|
||||||
|
'atlas.bucketTab': 'Bucket List',
|
||||||
|
'atlas.addBucket': 'Add to bucket list',
|
||||||
|
'atlas.bucketNamePlaceholder': 'Place or destination...',
|
||||||
|
'atlas.bucketNotesPlaceholder': 'Notes (optional)',
|
||||||
|
'atlas.bucketEmpty': 'Your bucket list is empty',
|
||||||
|
'atlas.bucketEmptyHint': 'Add places you dream of visiting',
|
||||||
'atlas.days': 'Days',
|
'atlas.days': 'Days',
|
||||||
'atlas.visitedCountries': 'Visited Countries',
|
'atlas.visitedCountries': 'Visited Countries',
|
||||||
'atlas.cities': 'Cities',
|
'atlas.cities': 'Cities',
|
||||||
@@ -485,6 +666,12 @@ const en: Record<string, string> = {
|
|||||||
|
|
||||||
// Day Plan Sidebar
|
// Day Plan Sidebar
|
||||||
'dayplan.emptyDay': 'No places planned for this day',
|
'dayplan.emptyDay': 'No places planned for this day',
|
||||||
|
'dayplan.cannotReorderTransport': 'Bookings with a fixed time cannot be reordered',
|
||||||
|
'dayplan.confirmRemoveTimeTitle': 'Remove time?',
|
||||||
|
'dayplan.confirmRemoveTimeBody': 'This place has a fixed time ({time}). Moving it will remove the time and allow free sorting.',
|
||||||
|
'dayplan.confirmRemoveTimeAction': 'Remove time & move',
|
||||||
|
'dayplan.cannotDropOnTimed': 'Items cannot be placed between time-bound entries',
|
||||||
|
'dayplan.cannotBreakChronology': 'This would break the chronological order of timed items and bookings',
|
||||||
'dayplan.addNote': 'Add Note',
|
'dayplan.addNote': 'Add Note',
|
||||||
'dayplan.editNote': 'Edit Note',
|
'dayplan.editNote': 'Edit Note',
|
||||||
'dayplan.noteAdd': 'Add Note',
|
'dayplan.noteAdd': 'Add Note',
|
||||||
@@ -510,11 +697,17 @@ const en: Record<string, string> = {
|
|||||||
|
|
||||||
// Places Sidebar
|
// Places Sidebar
|
||||||
'places.addPlace': 'Add Place/Activity',
|
'places.addPlace': 'Add Place/Activity',
|
||||||
|
'places.importGpx': 'Import GPX',
|
||||||
|
'places.gpxImported': '{count} places imported from GPX',
|
||||||
|
'places.urlResolved': 'Place imported from URL',
|
||||||
|
'places.gpxError': 'GPX import failed',
|
||||||
'places.assignToDay': 'Add to which day?',
|
'places.assignToDay': 'Add to which day?',
|
||||||
'places.all': 'All',
|
'places.all': 'All',
|
||||||
'places.unplanned': 'Unplanned',
|
'places.unplanned': 'Unplanned',
|
||||||
'places.search': 'Search places...',
|
'places.search': 'Search places...',
|
||||||
'places.allCategories': 'All Categories',
|
'places.allCategories': 'All Categories',
|
||||||
|
'places.categoriesSelected': 'categories',
|
||||||
|
'places.clearFilter': 'Clear filter',
|
||||||
'places.count': '{count} places',
|
'places.count': '{count} places',
|
||||||
'places.countSingular': '1 place',
|
'places.countSingular': '1 place',
|
||||||
'places.allPlanned': 'All places are planned',
|
'places.allPlanned': 'All places are planned',
|
||||||
@@ -598,13 +791,13 @@ const en: Record<string, string> = {
|
|||||||
'reservations.meta.linkAccommodation': 'Accommodation',
|
'reservations.meta.linkAccommodation': 'Accommodation',
|
||||||
'reservations.meta.pickAccommodation': 'Link to accommodation',
|
'reservations.meta.pickAccommodation': 'Link to accommodation',
|
||||||
'reservations.meta.noAccommodation': 'None',
|
'reservations.meta.noAccommodation': 'None',
|
||||||
'reservations.meta.hotelPlace': 'Hotel',
|
'reservations.meta.hotelPlace': 'Accommodation',
|
||||||
'reservations.meta.pickHotel': 'Select hotel',
|
'reservations.meta.pickHotel': 'Select accommodation',
|
||||||
'reservations.meta.fromDay': 'From',
|
'reservations.meta.fromDay': 'From',
|
||||||
'reservations.meta.toDay': 'To',
|
'reservations.meta.toDay': 'To',
|
||||||
'reservations.meta.selectDay': 'Select day',
|
'reservations.meta.selectDay': 'Select day',
|
||||||
'reservations.type.flight': 'Flight',
|
'reservations.type.flight': 'Flight',
|
||||||
'reservations.type.hotel': 'Hotel',
|
'reservations.type.hotel': 'Accommodation',
|
||||||
'reservations.type.restaurant': 'Restaurant',
|
'reservations.type.restaurant': 'Restaurant',
|
||||||
'reservations.type.train': 'Train',
|
'reservations.type.train': 'Train',
|
||||||
'reservations.type.car': 'Rental Car',
|
'reservations.type.car': 'Rental Car',
|
||||||
@@ -613,6 +806,8 @@ const en: Record<string, string> = {
|
|||||||
'reservations.type.tour': 'Tour',
|
'reservations.type.tour': 'Tour',
|
||||||
'reservations.type.other': 'Other',
|
'reservations.type.other': 'Other',
|
||||||
'reservations.confirm.delete': 'Are you sure you want to delete the reservation "{name}"?',
|
'reservations.confirm.delete': 'Are you sure you want to delete the reservation "{name}"?',
|
||||||
|
'reservations.confirm.deleteTitle': 'Delete booking?',
|
||||||
|
'reservations.confirm.deleteBody': '"{name}" will be permanently deleted.',
|
||||||
'reservations.toast.updated': 'Reservation updated',
|
'reservations.toast.updated': 'Reservation updated',
|
||||||
'reservations.toast.removed': 'Reservation deleted',
|
'reservations.toast.removed': 'Reservation deleted',
|
||||||
'reservations.toast.fileUploaded': 'File uploaded',
|
'reservations.toast.fileUploaded': 'File uploaded',
|
||||||
@@ -632,6 +827,7 @@ const en: Record<string, string> = {
|
|||||||
'reservations.pendingSave': 'will be saved…',
|
'reservations.pendingSave': 'will be saved…',
|
||||||
'reservations.uploading': 'Uploading...',
|
'reservations.uploading': 'Uploading...',
|
||||||
'reservations.attachFile': 'Attach file',
|
'reservations.attachFile': 'Attach file',
|
||||||
|
'reservations.linkExisting': 'Link existing file',
|
||||||
'reservations.toast.saveError': 'Failed to save',
|
'reservations.toast.saveError': 'Failed to save',
|
||||||
'reservations.toast.updateError': 'Failed to update',
|
'reservations.toast.updateError': 'Failed to update',
|
||||||
'reservations.toast.deleteError': 'Failed to delete',
|
'reservations.toast.deleteError': 'Failed to delete',
|
||||||
@@ -669,6 +865,9 @@ const en: Record<string, string> = {
|
|||||||
'budget.paid': 'Paid',
|
'budget.paid': 'Paid',
|
||||||
'budget.open': 'Open',
|
'budget.open': 'Open',
|
||||||
'budget.noMembers': 'No members assigned',
|
'budget.noMembers': 'No members assigned',
|
||||||
|
'budget.settlement': 'Settlement',
|
||||||
|
'budget.settlementInfo': 'Click a member avatar on a budget item to mark them green — this means they paid. The settlement then shows who owes whom and how much.',
|
||||||
|
'budget.netBalances': 'Net Balances',
|
||||||
|
|
||||||
// Files
|
// Files
|
||||||
'files.title': 'Files',
|
'files.title': 'Files',
|
||||||
@@ -722,6 +921,15 @@ const en: Record<string, string> = {
|
|||||||
// Packing
|
// Packing
|
||||||
'packing.title': 'Packing List',
|
'packing.title': 'Packing List',
|
||||||
'packing.empty': 'Packing list is empty',
|
'packing.empty': 'Packing list is empty',
|
||||||
|
'packing.import': 'Import',
|
||||||
|
'packing.importTitle': 'Import Packing List',
|
||||||
|
'packing.importHint': 'One item per line. Format: Category, Name, Weight in g (optional), Bag (optional), checked/unchecked (optional)',
|
||||||
|
'packing.importPlaceholder': 'Hygiene, Toothbrush\nClothing, T-Shirts, 200\nDocuments, Passport, , Carry-on\nElectronics, Charger, 50, Suitcase, checked',
|
||||||
|
'packing.importCsv': 'Load CSV/TXT',
|
||||||
|
'packing.importAction': 'Import {count}',
|
||||||
|
'packing.importSuccess': '{count} items imported',
|
||||||
|
'packing.importError': 'Import failed',
|
||||||
|
'packing.importEmpty': 'No items to import',
|
||||||
'packing.progress': '{packed} of {total} packed ({percent}%)',
|
'packing.progress': '{packed} of {total} packed ({percent}%)',
|
||||||
'packing.clearChecked': 'Remove {count} checked',
|
'packing.clearChecked': 'Remove {count} checked',
|
||||||
'packing.clearCheckedShort': 'Remove {count}',
|
'packing.clearCheckedShort': 'Remove {count}',
|
||||||
@@ -741,6 +949,21 @@ const en: Record<string, string> = {
|
|||||||
'packing.menuCheckAll': 'Check All',
|
'packing.menuCheckAll': 'Check All',
|
||||||
'packing.menuUncheckAll': 'Uncheck All',
|
'packing.menuUncheckAll': 'Uncheck All',
|
||||||
'packing.menuDeleteCat': 'Delete Category',
|
'packing.menuDeleteCat': 'Delete Category',
|
||||||
|
'packing.assignUser': 'Assign user',
|
||||||
|
'packing.noMembers': 'No trip members',
|
||||||
|
'packing.addItem': 'Add item',
|
||||||
|
'packing.addItemPlaceholder': 'Item name...',
|
||||||
|
'packing.addCategory': 'Add category',
|
||||||
|
'packing.newCategoryPlaceholder': 'Category name (e.g. Clothing)',
|
||||||
|
'packing.applyTemplate': 'Apply template',
|
||||||
|
'packing.template': 'Template',
|
||||||
|
'packing.templateApplied': '{count} items added from template',
|
||||||
|
'packing.templateError': 'Failed to apply template',
|
||||||
|
'packing.bags': 'Bags',
|
||||||
|
'packing.noBag': 'Unassigned',
|
||||||
|
'packing.totalWeight': 'Total weight',
|
||||||
|
'packing.bagName': 'Bag name...',
|
||||||
|
'packing.addBag': 'Add bag',
|
||||||
'packing.changeCategory': 'Change Category',
|
'packing.changeCategory': 'Change Category',
|
||||||
'packing.confirm.clearChecked': 'Are you sure you want to remove {count} checked items?',
|
'packing.confirm.clearChecked': 'Are you sure you want to remove {count} checked items?',
|
||||||
'packing.confirm.deleteCat': 'Are you sure you want to delete the category "{name}" with {count} items?',
|
'packing.confirm.deleteCat': 'Are you sure you want to delete the category "{name}" with {count} items?',
|
||||||
@@ -858,7 +1081,27 @@ const en: Record<string, string> = {
|
|||||||
'backup.auto.enable': 'Enable auto-backup',
|
'backup.auto.enable': 'Enable auto-backup',
|
||||||
'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule',
|
'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule',
|
||||||
'backup.auto.interval': 'Interval',
|
'backup.auto.interval': 'Interval',
|
||||||
|
'backup.auto.hour': 'Run at hour',
|
||||||
|
'backup.auto.hourHint': 'Server local time ({format} format)',
|
||||||
|
'backup.auto.dayOfWeek': 'Day of week',
|
||||||
|
'backup.auto.dayOfMonth': 'Day of month',
|
||||||
|
'backup.auto.dayOfMonthHint': 'Limited to 1–28 for compatibility with all months',
|
||||||
|
'backup.auto.scheduleSummary': 'Schedule',
|
||||||
|
'backup.auto.summaryDaily': 'Every day at {hour}:00',
|
||||||
|
'backup.auto.summaryWeekly': 'Every {day} at {hour}:00',
|
||||||
|
'backup.auto.summaryMonthly': 'Day {day} of every month at {hour}:00',
|
||||||
|
'backup.auto.envLocked': 'Docker',
|
||||||
|
'backup.auto.envLockedHint': 'Auto-backup is configured via Docker environment variables. To change these settings, update your docker-compose.yml and restart the container.',
|
||||||
|
'backup.auto.copyEnv': 'Copy Docker env vars',
|
||||||
|
'backup.auto.envCopied': 'Docker env vars copied to clipboard',
|
||||||
'backup.auto.keepLabel': 'Delete old backups after',
|
'backup.auto.keepLabel': 'Delete old backups after',
|
||||||
|
'backup.dow.sunday': 'Sun',
|
||||||
|
'backup.dow.monday': 'Mon',
|
||||||
|
'backup.dow.tuesday': 'Tue',
|
||||||
|
'backup.dow.wednesday': 'Wed',
|
||||||
|
'backup.dow.thursday': 'Thu',
|
||||||
|
'backup.dow.friday': 'Fri',
|
||||||
|
'backup.dow.saturday': 'Sat',
|
||||||
'backup.interval.hourly': 'Hourly',
|
'backup.interval.hourly': 'Hourly',
|
||||||
'backup.interval.daily': 'Daily',
|
'backup.interval.daily': 'Daily',
|
||||||
'backup.interval.weekly': 'Weekly',
|
'backup.interval.weekly': 'Weekly',
|
||||||
@@ -985,6 +1228,45 @@ const en: Record<string, string> = {
|
|||||||
'day.editAccommodation': 'Edit accommodation',
|
'day.editAccommodation': 'Edit accommodation',
|
||||||
'day.reservations': 'Reservations',
|
'day.reservations': 'Reservations',
|
||||||
|
|
||||||
|
// Photos / Immich
|
||||||
|
'memories.title': 'Photos',
|
||||||
|
'memories.notConnected': 'Immich not connected',
|
||||||
|
'memories.notConnectedHint': 'Connect your Immich instance in Settings to see your trip photos here.',
|
||||||
|
'memories.noDates': 'Add dates to your trip to load photos.',
|
||||||
|
'memories.noPhotos': 'No photos found',
|
||||||
|
'memories.noPhotosHint': 'No photos found in Immich for this trip\'s date range.',
|
||||||
|
'memories.photosFound': 'photos',
|
||||||
|
'memories.fromOthers': 'from others',
|
||||||
|
'memories.sharePhotos': 'Share photos',
|
||||||
|
'memories.sharing': 'Sharing',
|
||||||
|
'memories.reviewTitle': 'Review your photos',
|
||||||
|
'memories.reviewHint': 'Click photos to exclude them from sharing.',
|
||||||
|
'memories.shareCount': 'Share {count} photos',
|
||||||
|
'memories.immichUrl': 'Immich Server URL',
|
||||||
|
'memories.immichApiKey': 'API Key',
|
||||||
|
'memories.testConnection': 'Test connection',
|
||||||
|
'memories.connected': 'Connected',
|
||||||
|
'memories.disconnected': 'Not connected',
|
||||||
|
'memories.connectionSuccess': 'Connected to Immich',
|
||||||
|
'memories.connectionError': 'Could not connect to Immich',
|
||||||
|
'memories.saved': 'Immich settings saved',
|
||||||
|
'memories.addPhotos': 'Add photos',
|
||||||
|
'memories.selectPhotos': 'Select photos from Immich',
|
||||||
|
'memories.selectHint': 'Tap photos to select them.',
|
||||||
|
'memories.selected': 'selected',
|
||||||
|
'memories.addSelected': 'Add {count} photos',
|
||||||
|
'memories.alreadyAdded': 'Added',
|
||||||
|
'memories.private': 'Private',
|
||||||
|
'memories.stopSharing': 'Stop sharing',
|
||||||
|
'memories.oldest': 'Oldest first',
|
||||||
|
'memories.newest': 'Newest first',
|
||||||
|
'memories.allLocations': 'All locations',
|
||||||
|
'memories.tripDates': 'Trip dates',
|
||||||
|
'memories.allPhotos': 'All photos',
|
||||||
|
'memories.confirmShareTitle': 'Share with trip members?',
|
||||||
|
'memories.confirmShareHint': '{count} photos will be visible to all members of this trip. You can make individual photos private later.',
|
||||||
|
'memories.confirmShareButton': 'Share photos',
|
||||||
|
|
||||||
// Collab Addon
|
// Collab Addon
|
||||||
'collab.tabs.chat': 'Chat',
|
'collab.tabs.chat': 'Chat',
|
||||||
'collab.tabs.notes': 'Notes',
|
'collab.tabs.notes': 'Notes',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+253
-13
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { adminApi, authApi } from '../api/client'
|
import apiClient, { adminApi, authApi, notificationsApi } from '../api/client'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
@@ -12,7 +12,9 @@ import CategoryManager from '../components/Admin/CategoryManager'
|
|||||||
import BackupPanel from '../components/Admin/BackupPanel'
|
import BackupPanel from '../components/Admin/BackupPanel'
|
||||||
import GitHubPanel from '../components/Admin/GitHubPanel'
|
import GitHubPanel from '../components/Admin/GitHubPanel'
|
||||||
import AddonManager from '../components/Admin/AddonManager'
|
import AddonManager from '../components/Admin/AddonManager'
|
||||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun } from 'lucide-react'
|
import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
|
||||||
|
import AuditLogPanel from '../components/Admin/AuditLogPanel'
|
||||||
|
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react'
|
||||||
import CustomSelect from '../components/shared/CustomSelect'
|
import CustomSelect from '../components/shared/CustomSelect'
|
||||||
|
|
||||||
interface AdminUser {
|
interface AdminUser {
|
||||||
@@ -39,6 +41,7 @@ interface OidcConfig {
|
|||||||
client_secret: string
|
client_secret: string
|
||||||
client_secret_set: boolean
|
client_secret_set: boolean
|
||||||
display_name: string
|
display_name: string
|
||||||
|
oidc_only: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateInfo {
|
interface UpdateInfo {
|
||||||
@@ -50,15 +53,16 @@ interface UpdateInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPage(): React.ReactElement {
|
export default function AdminPage(): React.ReactElement {
|
||||||
const { demoMode } = useAuthStore()
|
const { demoMode, serverTimezone } = useAuthStore()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
|
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'users', label: t('admin.tabs.users') },
|
{ id: 'users', label: t('admin.tabs.users') },
|
||||||
{ id: 'categories', label: t('admin.tabs.categories') },
|
{ id: 'config', label: t('admin.tabs.config') },
|
||||||
{ id: 'addons', label: t('admin.tabs.addons') },
|
{ id: 'addons', label: t('admin.tabs.addons') },
|
||||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||||
{ id: 'backup', label: t('admin.tabs.backup') },
|
{ id: 'backup', label: t('admin.tabs.backup') },
|
||||||
|
{ id: 'audit', label: t('admin.tabs.audit') },
|
||||||
{ id: 'github', label: t('admin.tabs.github') },
|
{ id: 'github', label: t('admin.tabs.github') },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -71,17 +75,36 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
const [showCreateUser, setShowCreateUser] = useState<boolean>(false)
|
const [showCreateUser, setShowCreateUser] = useState<boolean>(false)
|
||||||
const [createForm, setCreateForm] = useState<{ username: string; email: string; password: string; role: string }>({ username: '', email: '', password: '', role: 'user' })
|
const [createForm, setCreateForm] = useState<{ username: string; email: string; password: string; role: string }>({ username: '', email: '', password: '', role: 'user' })
|
||||||
|
|
||||||
|
// Bag tracking
|
||||||
|
const [bagTrackingEnabled, setBagTrackingEnabled] = useState<boolean>(false)
|
||||||
|
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
|
||||||
|
|
||||||
// OIDC config
|
// OIDC config
|
||||||
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '' })
|
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false })
|
||||||
const [savingOidc, setSavingOidc] = useState<boolean>(false)
|
const [savingOidc, setSavingOidc] = useState<boolean>(false)
|
||||||
|
|
||||||
// Registration toggle
|
// Registration toggle
|
||||||
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
|
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
|
||||||
|
|
||||||
|
// Invite links
|
||||||
|
const [invites, setInvites] = useState<any[]>([])
|
||||||
|
const [showCreateInvite, setShowCreateInvite] = useState<boolean>(false)
|
||||||
|
const [inviteForm, setInviteForm] = useState<{ max_uses: number; expires_in_days: number | '' }>({ max_uses: 1, expires_in_days: 7 })
|
||||||
|
|
||||||
// File types
|
// File types
|
||||||
const [allowedFileTypes, setAllowedFileTypes] = useState<string>('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
|
const [allowedFileTypes, setAllowedFileTypes] = useState<string>('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
|
||||||
const [savingFileTypes, setSavingFileTypes] = useState<boolean>(false)
|
const [savingFileTypes, setSavingFileTypes] = useState<boolean>(false)
|
||||||
|
|
||||||
|
// SMTP settings
|
||||||
|
const [smtpValues, setSmtpValues] = useState<Record<string, string>>({})
|
||||||
|
const [smtpLoaded, setSmtpLoaded] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.get('/auth/app-settings').then(r => {
|
||||||
|
setSmtpValues(r.data || {})
|
||||||
|
setSmtpLoaded(true)
|
||||||
|
}).catch(() => setSmtpLoaded(true))
|
||||||
|
}, [])
|
||||||
|
|
||||||
// API Keys
|
// API Keys
|
||||||
const [mapsKey, setMapsKey] = useState<string>('')
|
const [mapsKey, setMapsKey] = useState<string>('')
|
||||||
const [weatherKey, setWeatherKey] = useState<string>('')
|
const [weatherKey, setWeatherKey] = useState<string>('')
|
||||||
@@ -113,12 +136,14 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const [usersData, statsData] = await Promise.all([
|
const [usersData, statsData, invitesData] = await Promise.all([
|
||||||
adminApi.users(),
|
adminApi.users(),
|
||||||
adminApi.stats(),
|
adminApi.stats(),
|
||||||
|
adminApi.listInvites().catch(() => ({ invites: [] })),
|
||||||
])
|
])
|
||||||
setUsers(usersData.users)
|
setUsers(usersData.users)
|
||||||
setStats(statsData)
|
setStats(statsData)
|
||||||
|
setInvites(invitesData.invites || [])
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(t('admin.toast.loadError'))
|
toast.error(t('admin.toast.loadError'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -239,6 +264,38 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCreateInvite = async () => {
|
||||||
|
try {
|
||||||
|
const data = await adminApi.createInvite({
|
||||||
|
max_uses: inviteForm.max_uses,
|
||||||
|
expires_in_days: inviteForm.expires_in_days || undefined,
|
||||||
|
})
|
||||||
|
setInvites(prev => [data.invite, ...prev])
|
||||||
|
setShowCreateInvite(false)
|
||||||
|
setInviteForm({ max_uses: 1, expires_in_days: 7 })
|
||||||
|
// Copy link to clipboard
|
||||||
|
const link = `${window.location.origin}/register?invite=${data.invite.token}`
|
||||||
|
navigator.clipboard.writeText(link).then(() => toast.success(t('admin.invite.copied')))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('admin.invite.createError')))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteInvite = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await adminApi.deleteInvite(id)
|
||||||
|
setInvites(prev => prev.filter(i => i.id !== id))
|
||||||
|
toast.success(t('admin.invite.deleted'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('admin.invite.deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyInviteLink = (token: string) => {
|
||||||
|
const link = `${window.location.origin}/register?invite=${token}`
|
||||||
|
navigator.clipboard.writeText(link).then(() => toast.success(t('admin.invite.copied')))
|
||||||
|
}
|
||||||
|
|
||||||
const handleEditUser = (user) => {
|
const handleEditUser = (user) => {
|
||||||
setEditingUser(user)
|
setEditingUser(user)
|
||||||
setEditForm({ username: user.username, email: user.email, role: user.role, password: '' })
|
setEditForm({ username: user.username, email: user.email, role: user.role, password: '' })
|
||||||
@@ -246,7 +303,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
|
|
||||||
const handleSaveUser = async () => {
|
const handleSaveUser = async () => {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload: { username?: string; email?: string; role: string; password?: string } = {
|
||||||
username: editForm.username.trim() || undefined,
|
username: editForm.username.trim() || undefined,
|
||||||
email: editForm.email.trim() || undefined,
|
email: editForm.email.trim() || undefined,
|
||||||
role: editForm.role,
|
role: editForm.role,
|
||||||
@@ -288,7 +345,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
<Shield className="w-5 h-5 text-slate-700" />
|
<Shield className="w-5 h-5 text-slate-700" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900">Administration</h1>
|
<h1 className="text-2xl font-bold text-slate-900">{t('admin.title')}</h1>
|
||||||
<p className="text-slate-500 text-sm">{t('admin.subtitle')}</p>
|
<p className="text-slate-500 text-sm">{t('admin.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -467,10 +524,10 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-sm text-slate-500">
|
<td className="px-5 py-3 text-sm text-slate-500">
|
||||||
{new Date(u.created_at).toLocaleDateString(locale)}
|
{new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-sm text-slate-500">
|
<td className="px-5 py-3 text-sm text-slate-500">
|
||||||
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
|
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
@@ -500,9 +557,125 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'categories' && <CategoryManager />}
|
{/* Invite Links (inside users tab) */}
|
||||||
|
{activeTab === 'users' && (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden mt-6">
|
||||||
|
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('admin.invite.title')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('admin.invite.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateInvite(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{t('admin.invite.create')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{activeTab === 'addons' && <AddonManager />}
|
{invites.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-slate-400">{t('admin.invite.empty')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{invites.map(inv => {
|
||||||
|
const isExpired = inv.expires_at && new Date(inv.expires_at) < new Date()
|
||||||
|
const isUsedUp = inv.max_uses > 0 && inv.used_count >= inv.max_uses
|
||||||
|
const isActive = !isExpired && !isUsedUp
|
||||||
|
return (
|
||||||
|
<div key={inv.id} className="px-5 py-3 flex items-center gap-4">
|
||||||
|
<Link2 className="w-4 h-4 flex-shrink-0" style={{ color: isActive ? 'var(--text-primary)' : '#d1d5db' }} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-slate-600 truncate">{inv.token.slice(0, 12)}...</code>
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
|
||||||
|
isActive ? 'bg-green-50 text-green-700' : 'bg-slate-100 text-slate-400'
|
||||||
|
}`}>
|
||||||
|
{isUsedUp ? t('admin.invite.usedUp') : isExpired ? t('admin.invite.expired') : t('admin.invite.active')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 mt-0.5">
|
||||||
|
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
|
||||||
|
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
|
||||||
|
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isActive && (
|
||||||
|
<button onClick={() => copyInviteLink(inv.token)} title={t('admin.invite.copyLink')}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-400 hover:text-slate-700 transition-colors">
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => handleDeleteInvite(inv.id)} title={t('common.delete')}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-red-50 text-slate-400 hover:text-red-500 transition-colors">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Invite Modal */}
|
||||||
|
<Modal isOpen={showCreateInvite} onClose={() => setShowCreateInvite(false)} title={t('admin.invite.create')} size="sm">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">{t('admin.invite.maxUses')}</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[1, 2, 3, 4, 5, 0].map(n => (
|
||||||
|
<button key={n} type="button" onClick={() => setInviteForm(f => ({ ...f, max_uses: n }))}
|
||||||
|
className={`flex-1 py-2 rounded-lg text-sm font-semibold border transition-colors ${
|
||||||
|
inviteForm.max_uses === n ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-200 hover:border-slate-400'
|
||||||
|
}`}>
|
||||||
|
{n === 0 ? '∞' : `${n}×`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">{t('admin.invite.expiry')}</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ value: 1, label: '1d' },
|
||||||
|
{ value: 3, label: '3d' },
|
||||||
|
{ value: 7, label: '7d' },
|
||||||
|
{ value: 14, label: '14d' },
|
||||||
|
{ value: '', label: '∞' },
|
||||||
|
].map(opt => (
|
||||||
|
<button key={String(opt.value)} type="button" onClick={() => setInviteForm(f => ({ ...f, expires_in_days: opt.value as number | '' }))}
|
||||||
|
className={`flex-1 py-2 rounded-lg text-sm font-semibold border transition-colors ${
|
||||||
|
inviteForm.expires_in_days === opt.value ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-200 hover:border-slate-400'
|
||||||
|
}`}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2 border-t border-slate-100">
|
||||||
|
<button onClick={() => setShowCreateInvite(false)} className="px-4 py-2 text-sm text-slate-500 hover:text-slate-700">{t('common.cancel')}</button>
|
||||||
|
<button onClick={handleCreateInvite} className="px-4 py-2 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700">{t('admin.invite.createAndCopy')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{activeTab === 'config' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PackingTemplateManager />
|
||||||
|
<CategoryManager />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'addons' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<AddonManager bagTrackingEnabled={bagTrackingEnabled} onToggleBagTracking={async () => {
|
||||||
|
const next = !bagTrackingEnabled
|
||||||
|
setBagTrackingEnabled(next)
|
||||||
|
try { await adminApi.updateBagTracking(next) } catch { setBagTrackingEnabled(!next) }
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'settings' && (
|
{activeTab === 'settings' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -715,11 +888,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"
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* OIDC-only mode toggle */}
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">{t('admin.oidcOnlyMode')}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">{t('admin.oidcOnlyModeHint')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOidcConfig(c => ({ ...c, oidc_only: !c.oidc_only }))}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ml-4 ${
|
||||||
|
oidcConfig.oidc_only ? 'bg-slate-900' : 'bg-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
oidcConfig.oidc_only ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setSavingOidc(true)
|
setSavingOidc(true)
|
||||||
try {
|
try {
|
||||||
const payload = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name }
|
const payload: Record<string, unknown> = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only }
|
||||||
if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret
|
if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret
|
||||||
await adminApi.updateOidc(payload)
|
await adminApi.updateOidc(payload)
|
||||||
toast.success(t('admin.oidcSaved'))
|
toast.success(t('admin.oidcSaved'))
|
||||||
@@ -737,11 +930,58 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* SMTP / Notifications */}
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100">
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('admin.smtp.title')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-3">
|
||||||
|
{smtpLoaded && [
|
||||||
|
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
|
||||||
|
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
|
||||||
|
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
|
||||||
|
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
|
||||||
|
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
|
||||||
|
{ key: 'notification_webhook_url', label: 'Webhook URL (optional)', placeholder: 'https://discord.com/api/webhooks/...' },
|
||||||
|
{ key: 'app_url', label: 'App URL (for email links)', placeholder: 'https://trek.example.com' },
|
||||||
|
].map(field => (
|
||||||
|
<div key={field.key}>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
|
||||||
|
<input
|
||||||
|
type={field.type || 'text'}
|
||||||
|
value={smtpValues[field.key] || ''}
|
||||||
|
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
onBlur={e => { if (e.target.value !== '') authApi.updateAppSettings({ [field.key]: e.target.value }).then(() => toast.success(t('common.saved'))).catch(() => {}) }}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
for (const k of ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from']) {
|
||||||
|
if (smtpValues[k]) await authApi.updateAppSettings({ [k]: smtpValues[k] }).catch(() => {})
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await notificationsApi.testSmtp()
|
||||||
|
if (result.success) toast.success(t('admin.smtp.testSuccess'))
|
||||||
|
else toast.error(result.error || t('admin.smtp.testFailed'))
|
||||||
|
} catch { toast.error(t('admin.smtp.testFailed')) }
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
{t('admin.smtp.testButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'backup' && <BackupPanel />}
|
{activeTab === 'backup' && <BackupPanel />}
|
||||||
|
|
||||||
|
{activeTab === 'audit' && <AuditLogPanel />}
|
||||||
|
|
||||||
{activeTab === 'github' && <GitHubPanel />}
|
{activeTab === 'github' && <GitHubPanel />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+508
-24
@@ -1,10 +1,11 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react'
|
import React, { useEffect, useState, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useTranslation } from '../i18n'
|
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import apiClient from '../api/client'
|
import apiClient, { mapsApi } from '../api/client'
|
||||||
import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X } from 'lucide-react'
|
import CustomSelect from '../components/shared/CustomSelect'
|
||||||
|
import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types'
|
import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types'
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ interface AtlasData {
|
|||||||
interface CountryDetail {
|
interface CountryDetail {
|
||||||
places: AtlasPlace[]
|
places: AtlasPlace[]
|
||||||
trips: { id: number; title: string }[]
|
trips: { id: number; title: string }[]
|
||||||
|
manually_marked?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileStats({ data, stats, countries, resolveName, t, dark }: { data: AtlasData | null; stats: AtlasStats; countries: AtlasCountry[]; resolveName: (code: string) => string; t: TranslationFn; dark: boolean }): React.ReactElement {
|
function MobileStats({ data, stats, countries, resolveName, t, dark }: { data: AtlasData | null; stats: AtlasStats; countries: AtlasCountry[]; resolveName: (code: string) => string; t: TranslationFn; dark: boolean }): React.ReactElement {
|
||||||
@@ -100,7 +102,7 @@ function useCountryNames(language: string): (code: string) => string {
|
|||||||
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
|
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const dn = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' })
|
const dn = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' })
|
||||||
setResolver(() => (code: string) => { try { return dn.of(code) || code } catch { return code } })
|
setResolver(() => (code: string) => { try { return dn.of(code) || code } catch { return code } })
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
}, [language])
|
}, [language])
|
||||||
@@ -108,7 +110,9 @@ function useCountryNames(language: string): (code: string) => string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map visited country codes to ISO-3166 alpha3 (GeoJSON uses alpha3)
|
// Map visited country codes to ISO-3166 alpha3 (GeoJSON uses alpha3)
|
||||||
const A2_TO_A3: Record<string, string> = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BR":"BRA","BE":"BEL","BG":"BGR","CA":"CAN","CL":"CHL","CN":"CHN","CO":"COL","HR":"HRV","CZ":"CZE","DK":"DNK","EG":"EGY","EE":"EST","FI":"FIN","FR":"FRA","DE":"DEU","GR":"GRC","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JP":"JPN","KE":"KEN","KR":"KOR","LV":"LVA","LT":"LTU","LU":"LUX","MY":"MYS","MX":"MEX","MA":"MAR","NL":"NLD","NZ":"NZL","NO":"NOR","PK":"PAK","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","RO":"ROU","RU":"RUS","SA":"SAU","RS":"SRB","SK":"SVK","SI":"SVN","ZA":"ZAF","ES":"ESP","SE":"SWE","CH":"CHE","TH":"THA","TR":"TUR","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","VN":"VNM","NG":"NGA"}
|
// Built dynamically from GeoJSON + hardcoded fallbacks
|
||||||
|
const A2_TO_A3_BASE: Record<string, string> = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AG":"ATG","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BS":"BHS","BH":"BHR","BD":"BGD","BB":"BRB","BY":"BLR","BE":"BEL","BZ":"BLZ","BJ":"BEN","BT":"BTN","BO":"BOL","BA":"BIH","BW":"BWA","BR":"BRA","BN":"BRN","BG":"BGR","BF":"BFA","BI":"BDI","CV":"CPV","KH":"KHM","CM":"CMR","CA":"CAN","CF":"CAF","TD":"TCD","CL":"CHL","CN":"CHN","CO":"COL","KM":"COM","CG":"COG","CD":"COD","CR":"CRI","CI":"CIV","HR":"HRV","CU":"CUB","CY":"CYP","CZ":"CZE","DK":"DNK","DJ":"DJI","DM":"DMA","DO":"DOM","EC":"ECU","EG":"EGY","SV":"SLV","GQ":"GNQ","ER":"ERI","EE":"EST","SZ":"SWZ","ET":"ETH","FJ":"FJI","FI":"FIN","FR":"FRA","GA":"GAB","GM":"GMB","GE":"GEO","DE":"DEU","GH":"GHA","GR":"GRC","GD":"GRD","GT":"GTM","GN":"GIN","GW":"GNB","GY":"GUY","HT":"HTI","HN":"HND","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JM":"JAM","JP":"JPN","JO":"JOR","KZ":"KAZ","KE":"KEN","KI":"KIR","KP":"PRK","KR":"KOR","KW":"KWT","KG":"KGZ","LA":"LAO","LV":"LVA","LB":"LBN","LS":"LSO","LR":"LBR","LY":"LBY","LI":"LIE","LT":"LTU","LU":"LUX","MG":"MDG","MW":"MWI","MY":"MYS","MV":"MDV","ML":"MLI","MT":"MLT","MR":"MRT","MU":"MUS","MX":"MEX","MD":"MDA","MN":"MNG","ME":"MNE","MA":"MAR","MZ":"MOZ","MM":"MMR","NA":"NAM","NP":"NPL","NL":"NLD","NZ":"NZL","NI":"NIC","NE":"NER","NG":"NGA","MK":"MKD","NO":"NOR","OM":"OMN","PK":"PAK","PA":"PAN","PG":"PNG","PY":"PRY","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","QA":"QAT","RO":"ROU","RU":"RUS","RW":"RWA","SA":"SAU","SN":"SEN","RS":"SRB","SL":"SLE","SG":"SGP","SK":"SVK","SI":"SVN","SB":"SLB","SO":"SOM","ZA":"ZAF","SS":"SSD","ES":"ESP","LK":"LKA","SD":"SDN","SR":"SUR","SE":"SWE","CH":"CHE","SY":"SYR","TW":"TWN","TJ":"TJK","TZ":"TZA","TH":"THA","TL":"TLS","TG":"TGO","TT":"TTO","TN":"TUN","TR":"TUR","TM":"TKM","UG":"UGA","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","UY":"URY","UZ":"UZB","VU":"VUT","VE":"VEN","VN":"VNM","YE":"YEM","ZM":"ZMB","ZW":"ZWE"}
|
||||||
|
let A2_TO_A3: Record<string, string> = { ...A2_TO_A3_BASE }
|
||||||
|
|
||||||
export default function AtlasPage(): React.ReactElement {
|
export default function AtlasPage(): React.ReactElement {
|
||||||
const { t, language } = useTranslation()
|
const { t, language } = useTranslation()
|
||||||
@@ -149,20 +153,50 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
|
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
|
||||||
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
|
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
|
||||||
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
|
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
|
||||||
|
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket'; code: string; name: string } | null>(null)
|
||||||
|
const [bucketMonth, setBucketMonth] = useState(0)
|
||||||
|
const [bucketYear, setBucketYear] = useState(0)
|
||||||
|
|
||||||
// Load atlas data
|
// Bucket list
|
||||||
|
interface BucketItem { id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null; target_date: string | null }
|
||||||
|
const [bucketList, setBucketList] = useState<BucketItem[]>([])
|
||||||
|
const [showBucketAdd, setShowBucketAdd] = useState(false)
|
||||||
|
const [bucketForm, setBucketForm] = useState({ name: '', notes: '', lat: '', lng: '', target_date: '' })
|
||||||
|
const [bucketSearch, setBucketSearch] = useState('')
|
||||||
|
const [bucketSearchResults, setBucketSearchResults] = useState<any[]>([])
|
||||||
|
const [bucketSearching, setBucketSearching] = useState(false)
|
||||||
|
const [bucketPoiMonth, setBucketPoiMonth] = useState(0)
|
||||||
|
const [bucketPoiYear, setBucketPoiYear] = useState(0)
|
||||||
|
const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats')
|
||||||
|
const bucketMarkersRef = useRef<any>(null)
|
||||||
|
|
||||||
|
// Load atlas data + bucket list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient.get('/addons/atlas/stats').then(r => {
|
Promise.all([
|
||||||
setData(r.data)
|
apiClient.get('/addons/atlas/stats'),
|
||||||
|
apiClient.get('/addons/atlas/bucket-list'),
|
||||||
|
]).then(([statsRes, bucketRes]) => {
|
||||||
|
setData(statsRes.data)
|
||||||
|
setBucketList(bucketRes.data.items || [])
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}).catch(() => setLoading(false))
|
}).catch(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load GeoJSON world data (direct GeoJSON, no conversion needed)
|
// Load GeoJSON world data (direct GeoJSON, no conversion needed)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson')
|
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(geo => setGeoData(geo))
|
.then(geo => {
|
||||||
|
// Dynamically build A2→A3 mapping from GeoJSON
|
||||||
|
for (const f of geo.features) {
|
||||||
|
const a2 = f.properties?.ISO_A2
|
||||||
|
const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3
|
||||||
|
if (a2 && a3 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) {
|
||||||
|
A2_TO_A3[a2] = a3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setGeoData(geo)
|
||||||
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -222,6 +256,10 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
const countryMap = {}
|
const countryMap = {}
|
||||||
data.countries.forEach(c => { if (A2_TO_A3[c.code]) countryMap[A2_TO_A3[c.code]] = c })
|
data.countries.forEach(c => { if (A2_TO_A3[c.code]) countryMap[A2_TO_A3[c.code]] = c })
|
||||||
|
|
||||||
|
// Preserve current map view
|
||||||
|
const currentCenter = mapInstance.current.getCenter()
|
||||||
|
const currentZoom = mapInstance.current.getZoom()
|
||||||
|
|
||||||
if (geoLayerRef.current) {
|
if (geoLayerRef.current) {
|
||||||
mapInstance.current.removeLayer(geoLayerRef.current)
|
mapInstance.current.removeLayer(geoLayerRef.current)
|
||||||
}
|
}
|
||||||
@@ -241,7 +279,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
interactive: true,
|
interactive: true,
|
||||||
bubblingMouseEvents: false,
|
bubblingMouseEvents: false,
|
||||||
style: (feature) => {
|
style: (feature) => {
|
||||||
const a3 = feature.properties?.ISO_A3 || feature.properties?.ADM0_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
|
const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
|
||||||
const visited = visitedA3.has(a3)
|
const visited = visitedA3.has(a3)
|
||||||
return {
|
return {
|
||||||
fillColor: visited ? colorForCode(a3) : (dark ? '#1e1e2e' : '#e2e8f0'),
|
fillColor: visited ? colorForCode(a3) : (dark ? '#1e1e2e' : '#e2e8f0'),
|
||||||
@@ -251,11 +289,11 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onEachFeature: (feature, layer) => {
|
onEachFeature: (feature, layer) => {
|
||||||
const a3 = feature.properties?.ISO_A3 || feature.properties?.ADM0_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
|
const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
|
||||||
const c = countryMap[a3]
|
const c = countryMap[a3]
|
||||||
if (c) {
|
if (c) {
|
||||||
const name = resolveName(c.code)
|
const name = resolveName(c.code)
|
||||||
const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { month: 'short', year: 'numeric' }) }
|
const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(getLocaleForLanguage(language), { month: 'short', year: 'numeric' }) }
|
||||||
const tooltipHtml = `
|
const tooltipHtml = `
|
||||||
<div style="display:flex;flex-direction:column;gap:8px;min-width:160px">
|
<div style="display:flex;flex-direction:column;gap:8px;min-width:160px">
|
||||||
<div style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;padding-bottom:6px;border-bottom:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}">${name}</div>
|
<div style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;padding-bottom:6px;border-bottom:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}">${name}</div>
|
||||||
@@ -278,18 +316,156 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
layer.bindTooltip(tooltipHtml, {
|
layer.bindTooltip(tooltipHtml, {
|
||||||
sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||||
})
|
})
|
||||||
layer.on('click', () => loadCountryDetail(c.code))
|
layer.on('click', () => {
|
||||||
|
if (c.placeCount === 0 && c.tripCount === 0) {
|
||||||
|
// Manually marked only — show unmark popup
|
||||||
|
handleUnmarkCountry(c.code)
|
||||||
|
} else {
|
||||||
|
loadCountryDetail(c.code)
|
||||||
|
}
|
||||||
|
})
|
||||||
layer.on('mouseover', (e) => {
|
layer.on('mouseover', (e) => {
|
||||||
e.target.setStyle({ fillOpacity: 0.9, weight: 2, color: dark ? '#818cf8' : '#4f46e5' })
|
e.target.setStyle({ fillOpacity: 0.9, weight: 2, color: dark ? '#818cf8' : '#4f46e5' })
|
||||||
})
|
})
|
||||||
layer.on('mouseout', (e) => {
|
layer.on('mouseout', (e) => {
|
||||||
geoLayerRef.current.resetStyle(e.target)
|
geoLayerRef.current.resetStyle(e.target)
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
// Unvisited country — allow clicking to mark as visited
|
||||||
|
// Reverse lookup: find A2 code from A3, or use A3 directly
|
||||||
|
const a3ToA2Entry = Object.entries(A2_TO_A3).find(([, v]) => v === a3)
|
||||||
|
const isoA2 = feature.properties?.ISO_A2
|
||||||
|
const countryCode = a3ToA2Entry ? a3ToA2Entry[0] : (isoA2 && isoA2 !== '-99' ? isoA2 : null)
|
||||||
|
if (countryCode && countryCode !== '-99') {
|
||||||
|
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
|
||||||
|
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
|
||||||
|
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||||
|
})
|
||||||
|
layer.on('click', () => handleMarkCountry(countryCode, name))
|
||||||
|
layer.on('mouseover', (e) => {
|
||||||
|
e.target.setStyle({ fillOpacity: 0.5, weight: 1.5, color: dark ? '#555' : '#94a3b8' })
|
||||||
|
})
|
||||||
|
layer.on('mouseout', (e) => {
|
||||||
|
geoLayerRef.current.resetStyle(e.target)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).addTo(mapInstance.current)
|
}).addTo(mapInstance.current)
|
||||||
|
|
||||||
|
// Restore map view after re-render
|
||||||
|
mapInstance.current.setView(currentCenter, currentZoom, { animate: false })
|
||||||
}, [geoData, data, dark])
|
}, [geoData, data, dark])
|
||||||
|
|
||||||
|
const handleMarkCountry = (code: string, name: string): void => {
|
||||||
|
setConfirmAction({ type: 'choose', code, name })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnmarkCountry = (code: string): void => {
|
||||||
|
const country = data?.countries.find(c => c.code === code)
|
||||||
|
setConfirmAction({ type: 'unmark', code, name: resolveName(code) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeConfirmAction = async (): Promise<void> => {
|
||||||
|
if (!confirmAction) return
|
||||||
|
const { type, code } = confirmAction
|
||||||
|
setConfirmAction(null)
|
||||||
|
|
||||||
|
// Update local state immediately (no API reload = no map re-render flash)
|
||||||
|
if (type === 'mark') {
|
||||||
|
apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {})
|
||||||
|
setData(prev => {
|
||||||
|
if (!prev || prev.countries.find(c => c.code === code)) return prev
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
|
||||||
|
stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
apiClient.delete(`/addons/atlas/country/${code}/mark`).catch(() => {})
|
||||||
|
setSelectedCountry(null)
|
||||||
|
setCountryDetail(null)
|
||||||
|
setData(prev => {
|
||||||
|
if (!prev) return prev
|
||||||
|
const c = prev.countries.find(c => c.code === code)
|
||||||
|
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
countries: prev.countries.filter(c => c.code !== code),
|
||||||
|
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddBucketItem = async (): Promise<void> => {
|
||||||
|
if (!bucketForm.name.trim()) return
|
||||||
|
try {
|
||||||
|
const data: Record<string, unknown> = { name: bucketForm.name.trim() }
|
||||||
|
if (bucketForm.notes.trim()) data.notes = bucketForm.notes.trim()
|
||||||
|
if (bucketForm.lat && bucketForm.lng) { data.lat = parseFloat(bucketForm.lat); data.lng = parseFloat(bucketForm.lng) }
|
||||||
|
const targetDate = bucketForm.target_date || (bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null)
|
||||||
|
if (targetDate) data.target_date = targetDate
|
||||||
|
const r = await apiClient.post('/addons/atlas/bucket-list', data)
|
||||||
|
setBucketList(prev => [r.data.item, ...prev])
|
||||||
|
setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' })
|
||||||
|
setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0)
|
||||||
|
setShowBucketAdd(false)
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteBucketItem = async (id: number): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/addons/atlas/bucket-list/${id}`)
|
||||||
|
setBucketList(prev => prev.filter(i => i.id !== id))
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBucketPoiSearch = async () => {
|
||||||
|
if (!bucketSearch.trim()) return
|
||||||
|
setBucketSearching(true)
|
||||||
|
try {
|
||||||
|
const result = await mapsApi.search(bucketSearch, language)
|
||||||
|
setBucketSearchResults(result.places || [])
|
||||||
|
} catch {} finally { setBucketSearching(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectBucketPoi = (result: any) => {
|
||||||
|
const targetDate = bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null
|
||||||
|
setBucketForm({
|
||||||
|
name: result.name || bucketSearch,
|
||||||
|
notes: '',
|
||||||
|
lat: String(result.lat || ''),
|
||||||
|
lng: String(result.lng || ''),
|
||||||
|
target_date: targetDate || '',
|
||||||
|
})
|
||||||
|
setBucketSearchResults([])
|
||||||
|
setBucketSearch('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render bucket list markers on map
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapInstance.current) return
|
||||||
|
if (bucketMarkersRef.current) {
|
||||||
|
mapInstance.current.removeLayer(bucketMarkersRef.current)
|
||||||
|
}
|
||||||
|
if (bucketList.length === 0) return
|
||||||
|
const markers = bucketList.filter(b => b.lat && b.lng).map(b => {
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="width:28px;height:28px;border-radius:50%;background:rgba(251,191,36,0.9);display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.3);border:2px solid white"><svg width="14" height="14" viewBox="0 0 24 24" fill="white" stroke="none"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></div>`,
|
||||||
|
iconSize: [28, 28],
|
||||||
|
iconAnchor: [14, 14],
|
||||||
|
})
|
||||||
|
return L.marker([b.lat!, b.lng!], { icon }).bindTooltip(
|
||||||
|
`<div style="font-size:12px;font-weight:600">${b.name}</div>${b.notes ? `<div style="font-size:10px;opacity:0.7;margin-top:2px">${b.notes}</div>` : ''}`,
|
||||||
|
{ className: 'atlas-tooltip', direction: 'top', offset: [0, -14] }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
bucketMarkersRef.current = L.layerGroup(markers).addTo(mapInstance.current)
|
||||||
|
}, [bucketList])
|
||||||
|
|
||||||
const loadCountryDetail = async (code: string): Promise<void> => {
|
const loadCountryDetail = async (code: string): Promise<void> => {
|
||||||
setSelectedCountry(code)
|
setSelectedCountry(code)
|
||||||
try {
|
try {
|
||||||
@@ -348,6 +524,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
width: 'fit-content',
|
width: 'fit-content',
|
||||||
|
maxWidth: 'calc(100vw - 40px)',
|
||||||
background: dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.2)',
|
background: dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.2)',
|
||||||
backdropFilter: 'blur(24px) saturate(180%)',
|
backdropFilter: 'blur(24px) saturate(180%)',
|
||||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||||
@@ -368,13 +545,152 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
<SidebarContent
|
<SidebarContent
|
||||||
data={data} stats={stats} countries={countries} selectedCountry={selectedCountry}
|
data={data} stats={stats} countries={countries} selectedCountry={selectedCountry}
|
||||||
countryDetail={countryDetail} resolveName={resolveName}
|
countryDetail={countryDetail} resolveName={resolveName}
|
||||||
onCountryClick={loadCountryDetail} onTripClick={(id) => navigate(`/trips/${id}`)}
|
onCountryClick={loadCountryDetail} onTripClick={(id) => navigate(`/trips/${id}`)} onUnmarkCountry={handleUnmarkCountry}
|
||||||
|
bucketList={bucketList} bucketTab={bucketTab} setBucketTab={setBucketTab}
|
||||||
|
showBucketAdd={showBucketAdd} setShowBucketAdd={setShowBucketAdd}
|
||||||
|
bucketForm={bucketForm} setBucketForm={setBucketForm}
|
||||||
|
onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem}
|
||||||
|
onSearchBucket={handleBucketPoiSearch} onSelectBucketPoi={handleSelectBucketPoi}
|
||||||
|
bucketSearchResults={bucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth}
|
||||||
|
bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching}
|
||||||
|
bucketSearch={bucketSearch} setBucketSearch={setBucketSearch}
|
||||||
t={t} dark={dark}
|
t={t} dark={dark}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Country action popup */}
|
||||||
|
{confirmAction && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
|
||||||
|
onClick={() => setConfirmAction(null)}>
|
||||||
|
<div style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 340, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}
|
||||||
|
onClick={e => e.stopPropagation()}>
|
||||||
|
{confirmAction.code.length === 2 ? (
|
||||||
|
<img src={`https://flagcdn.com/w80/${confirmAction.code.toLowerCase()}.png`} alt={confirmAction.code} style={{ width: 48, height: 34, borderRadius: 6, objectFit: 'cover', marginBottom: 12, display: 'inline-block' }} />
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 36, marginBottom: 12 }}>{countryCodeToFlag(confirmAction.code)}</div>
|
||||||
|
)}
|
||||||
|
<h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{confirmAction.name}</h3>
|
||||||
|
|
||||||
|
{confirmAction.type === 'choose' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<button onClick={async () => {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`)
|
||||||
|
setData(prev => {
|
||||||
|
if (!prev || prev.countries.find(c => c.code === confirmAction.code)) return prev
|
||||||
|
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
setConfirmAction(null)
|
||||||
|
}}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||||
|
<MapPin size={18} style={{ color: 'var(--text-primary)', flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.markVisited')}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.markVisitedHint')}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' as any })}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||||
|
<Star size={18} style={{ color: '#fbbf24', flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.addToBucket')}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmAction.type === 'unmark' && (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmark')}</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||||
|
<button onClick={() => setConfirmAction(null)}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={executeConfirmAction}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#ef4444', color: 'white' }}>
|
||||||
|
{t('atlas.unmark')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmAction.type === 'bucket' && (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '0 0 14px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.bucketWhen')}</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginBottom: 16 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<CustomSelect
|
||||||
|
value={bucketMonth}
|
||||||
|
onChange={v => setBucketMonth(Number(v))}
|
||||||
|
placeholder={t('atlas.month')}
|
||||||
|
options={[
|
||||||
|
{ value: 0, label: '—' },
|
||||||
|
...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })),
|
||||||
|
]}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<CustomSelect
|
||||||
|
value={bucketYear}
|
||||||
|
onChange={v => setBucketYear(Number(v))}
|
||||||
|
placeholder={t('atlas.year')}
|
||||||
|
options={[
|
||||||
|
{ value: 0, label: '—' },
|
||||||
|
...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) })),
|
||||||
|
]}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'choose' })}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.back')}
|
||||||
|
</button>
|
||||||
|
<button onClick={async () => {
|
||||||
|
const targetDate = bucketMonth > 0 && bucketYear > 0 ? `${bucketYear}-${String(bucketMonth).padStart(2, '0')}` : null
|
||||||
|
try {
|
||||||
|
const r = await apiClient.post('/addons/atlas/bucket-list', { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate })
|
||||||
|
setBucketList(prev => [r.data.item, ...prev])
|
||||||
|
} catch {}
|
||||||
|
setBucketMonth(0); setBucketYear(0)
|
||||||
|
setConfirmAction(null)
|
||||||
|
}}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#fbbf24', color: '#1a1a1a' }}>
|
||||||
|
{t('atlas.addToBucket')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmAction.type === 'mark' && (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmMark')}</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||||
|
<button onClick={() => setConfirmAction(null)}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={executeConfirmAction}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'white' }}>
|
||||||
|
{t('atlas.markVisited')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -388,11 +704,32 @@ interface SidebarContentProps {
|
|||||||
resolveName: (code: string) => string
|
resolveName: (code: string) => string
|
||||||
onCountryClick: (code: string) => void
|
onCountryClick: (code: string) => void
|
||||||
onTripClick: (id: number) => void
|
onTripClick: (id: number) => void
|
||||||
|
onUnmarkCountry?: (code: string) => void
|
||||||
|
bucketList: any[]
|
||||||
|
bucketTab: 'stats' | 'bucket'
|
||||||
|
setBucketTab: (tab: 'stats' | 'bucket') => void
|
||||||
|
showBucketAdd: boolean
|
||||||
|
setShowBucketAdd: (v: boolean) => void
|
||||||
|
bucketForm: { name: string; notes: string; lat: string; lng: string; target_date: string }
|
||||||
|
setBucketForm: (f: { name: string; notes: string; lat: string; lng: string; target_date: string }) => void
|
||||||
|
onAddBucket: () => Promise<void>
|
||||||
|
onDeleteBucket: (id: number) => Promise<void>
|
||||||
|
onSearchBucket: () => Promise<void>
|
||||||
|
onSelectBucketPoi: (result: any) => void
|
||||||
|
bucketSearchResults: any[]
|
||||||
|
bucketPoiMonth: number
|
||||||
|
setBucketPoiMonth: (v: number) => void
|
||||||
|
bucketPoiYear: number
|
||||||
|
setBucketPoiYear: (v: number) => void
|
||||||
|
bucketSearching: boolean
|
||||||
|
bucketSearch: string
|
||||||
|
setBucketSearch: (v: string) => void
|
||||||
t: TranslationFn
|
t: TranslationFn
|
||||||
dark: boolean
|
dark: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, t, dark }: SidebarContentProps): React.ReactElement {
|
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
|
||||||
|
const { language } = useTranslation()
|
||||||
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
|
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
|
||||||
const tp = dark ? '#f1f5f9' : '#0f172a'
|
const tp = dark ? '#f1f5f9' : '#0f172a'
|
||||||
const tm = dark ? '#94a3b8' : '#64748b'
|
const tm = dark ? '#94a3b8' : '#64748b'
|
||||||
@@ -405,20 +742,154 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
|||||||
const CL = { 'Europe': t('atlas.europe'), 'Asia': t('atlas.asia'), 'North America': t('atlas.northAmerica'), 'South America': t('atlas.southAmerica'), 'Africa': t('atlas.africa'), 'Oceania': t('atlas.oceania') }
|
const CL = { 'Europe': t('atlas.europe'), 'Asia': t('atlas.asia'), 'North America': t('atlas.northAmerica'), 'South America': t('atlas.southAmerica'), 'Africa': t('atlas.africa'), 'Oceania': t('atlas.oceania') }
|
||||||
const contColors = ['#818cf8', '#f472b6', '#34d399', '#fbbf24', '#fb923c', '#22d3ee']
|
const contColors = ['#818cf8', '#f472b6', '#34d399', '#fbbf24', '#fb923c', '#22d3ee']
|
||||||
|
|
||||||
if (countries.length === 0 && !lastTrip) {
|
// Tab switcher
|
||||||
|
const tabBar = (
|
||||||
|
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', marginBottom: 4 }}>
|
||||||
|
{[{ id: 'stats', label: t('atlas.statsTab'), icon: Globe }, { id: 'bucket', label: t('atlas.bucketTab'), icon: Star }].map(tab => (
|
||||||
|
<button key={tab.id} onClick={() => setBucketTab(tab.id as any)}
|
||||||
|
style={{
|
||||||
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
|
padding: '7px 0', borderRadius: 10, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
fontSize: 12, fontWeight: 600, transition: 'all 0.15s',
|
||||||
|
background: bucketTab === tab.id ? bg(0.1) : 'transparent',
|
||||||
|
color: bucketTab === tab.id ? tp : tf,
|
||||||
|
}}>
|
||||||
|
<tab.icon size={13} />
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (countries.length === 0 && !lastTrip && bucketTab !== 'bucket') {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center">
|
<>
|
||||||
<Globe size={28} className="mx-auto mb-2" style={{ color: tf, opacity: 0.4 }} />
|
{tabBar}
|
||||||
<p className="text-sm font-medium" style={{ color: tm }}>{t('atlas.noData')}</p>
|
<div className="p-8 text-center">
|
||||||
<p className="text-xs mt-1" style={{ color: tf }}>{t('atlas.noDataHint')}</p>
|
<Globe size={28} className="mx-auto mb-2" style={{ color: tf, opacity: 0.4 }} />
|
||||||
</div>
|
<p className="text-sm font-medium" style={{ color: tm }}>{t('atlas.noData')}</p>
|
||||||
|
<p className="text-xs mt-1" style={{ color: tf }}>{t('atlas.noDataHint')}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const thisYear = new Date().getFullYear()
|
const thisYear = new Date().getFullYear()
|
||||||
const divider = `2px solid ${bg(0.08)}`
|
const divider = `2px solid ${bg(0.08)}`
|
||||||
|
|
||||||
|
// Bucket list content
|
||||||
|
const bucketContent = (
|
||||||
|
<>
|
||||||
|
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px' }}>
|
||||||
|
{bucketList.map(item => (
|
||||||
|
<div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}>
|
||||||
|
{(() => {
|
||||||
|
const code = item.country_code?.length === 2 ? item.country_code : (Object.entries(A2_TO_A3).find(([, v]) => v === item.country_code)?.[0] || '')
|
||||||
|
return code ? (
|
||||||
|
<img src={`https://flagcdn.com/w40/${code.toLowerCase()}.png`} alt={code} style={{ width: 28, height: 20, borderRadius: 4, objectFit: 'cover', marginBottom: 4 }} />
|
||||||
|
) : <Star size={16} style={{ color: '#fbbf24', marginBottom: 4 }} fill="#fbbf24" />
|
||||||
|
})()}
|
||||||
|
<span className="text-xs font-semibold text-center leading-tight" style={{ color: tp, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.name}</span>
|
||||||
|
{item.target_date && (() => {
|
||||||
|
const [y, m] = item.target_date.split('-')
|
||||||
|
const label = m ? new Date(Number(y), Number(m) - 1).toLocaleString(language, { month: 'short', year: 'numeric' }) : y
|
||||||
|
return <span className="text-[9px] mt-0.5 text-center" style={{ color: tf }}>{label}</span>
|
||||||
|
})()}
|
||||||
|
{!item.target_date && item.notes && <span className="text-[9px] mt-0.5 text-center" style={{ color: tf, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.notes}</span>}
|
||||||
|
<button onClick={() => onDeleteBucket(item.id)}
|
||||||
|
className="opacity-0 group-hover:opacity-100"
|
||||||
|
style={{ position: 'absolute', top: 4, right: 4, background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: tf, display: 'flex', transition: 'opacity 0.15s' }}>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{bucketList.length === 0 && !showBucketAdd && (
|
||||||
|
<div className="flex items-center justify-center py-4 px-6" style={{ color: tf, fontSize: 12 }}>
|
||||||
|
{t('atlas.bucketEmptyHint')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showBucketAdd ? (
|
||||||
|
<div style={{ padding: '8px 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{/* Search or manual name */}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<input type="text" value={bucketForm.name || bucketSearch}
|
||||||
|
onChange={e => { const v = e.target.value; if (bucketForm.name) setBucketForm({ ...bucketForm, name: v }); else setBucketSearch(v) }}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && !bucketForm.name) onSearchBucket(); else if (e.key === 'Enter') onAddBucket(); if (e.key === 'Escape') setShowBucketAdd(false) }}
|
||||||
|
placeholder={t('atlas.bucketNamePlaceholder')}
|
||||||
|
autoFocus
|
||||||
|
style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 12, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)' }}
|
||||||
|
/>
|
||||||
|
{!bucketForm.name && (
|
||||||
|
<button onClick={onSearchBucket} disabled={bucketSearching}
|
||||||
|
style={{ padding: '6px 10px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Search size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{bucketForm.name && (
|
||||||
|
<button onClick={() => { setBucketForm({ ...bucketForm, name: '', lat: '', lng: '' }); setBucketSearch('') }}
|
||||||
|
style={{ padding: '6px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{bucketSearchResults.length > 0 && (
|
||||||
|
<div style={{ position: 'absolute', bottom: '100%', left: 0, right: 0, zIndex: 50, marginBottom: 4, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.12)', maxHeight: 160, overflowY: 'auto' }}>
|
||||||
|
{bucketSearchResults.slice(0, 6).map((r, i) => (
|
||||||
|
<button key={i} onClick={() => onSelectBucketPoi(r)} style={{ display: 'flex', flexDirection: 'column', gap: 1, width: '100%', padding: '6px 10px', border: 'none', background: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', borderBottom: '1px solid var(--border-faint)' }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-primary)' }}>{r.name}</span>
|
||||||
|
{r.address && <span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{r.address}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Selected place indicator */}
|
||||||
|
{bucketForm.lat && bucketForm.lng && (
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<MapPin size={10} /> {Number(bucketForm.lat).toFixed(4)}, {Number(bucketForm.lng).toFixed(4)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Month / Year with CustomSelect */}
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<CustomSelect value={bucketPoiMonth} onChange={v => setBucketPoiMonth(Number(v))} placeholder={t('atlas.month')} size="sm"
|
||||||
|
options={[{ value: 0, label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<CustomSelect value={bucketPoiYear} onChange={v => setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm"
|
||||||
|
options={[{ value: 0, label: '—' }, ...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) }))]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||||
|
<button onClick={() => { setShowBucketAdd(false); setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' }); setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0) }}
|
||||||
|
style={{ fontSize: 11, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={onAddBucket} disabled={!bucketForm.name.trim()}
|
||||||
|
style={{ fontSize: 11, padding: '4px 12px', borderRadius: 6, border: 'none', background: '#fbbf24', color: '#1a1a1a', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: bucketForm.name.trim() ? 1 : 0.5 }}>
|
||||||
|
{t('common.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '4px 16px 8px' }}>
|
||||||
|
<button onClick={() => setShowBucketAdd(true)}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, width: '100%', padding: '5px 0', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', fontSize: 11, color: tf, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
|
<Plus size={11} /> {t('atlas.addPoi')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{tabBar}
|
||||||
|
{/* Both tabs always rendered so the wider one sets the panel width */}
|
||||||
|
<div style={{ display: 'grid' }}>
|
||||||
|
<div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
|
||||||
<div className="flex items-stretch justify-center">
|
<div className="flex items-stretch justify-center">
|
||||||
|
|
||||||
{/* ═══ SECTION 1: Numbers ═══ */}
|
{/* ═══ SECTION 1: Numbers ═══ */}
|
||||||
@@ -507,12 +978,25 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
|||||||
{trip.title}
|
{trip.title}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{countryDetail.manually_marked && onUnmarkCountry && (
|
||||||
|
<button onClick={() => onUnmarkCountry(selectedCountry!)}
|
||||||
|
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold transition-opacity hover:opacity-75"
|
||||||
|
style={{ background: 'rgba(239,68,68,0.1)', color: '#ef4444' }}>
|
||||||
|
<X size={9} />
|
||||||
|
{t('atlas.unmark')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={bucketTab === 'stats' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
|
||||||
|
{bucketContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useToast } from '../components/shared/Toast'
|
|||||||
import {
|
import {
|
||||||
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
||||||
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
|
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
|
||||||
|
LayoutGrid, List,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
interface DashboardTrip {
|
interface DashboardTrip {
|
||||||
@@ -53,12 +54,12 @@ function getTripStatus(trip: DashboardTrip): string | null {
|
|||||||
return 'past'
|
return 'past'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string | null | undefined, locale: string = 'de-DE'): string | null {
|
function formatDate(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
|
||||||
if (!dateStr) return null
|
if (!dateStr) return null
|
||||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
|
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateShort(dateStr: string | null | undefined, locale: string = 'de-DE'): string | null {
|
function formatDateShort(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
|
||||||
if (!dateStr) return null
|
if (!dateStr) return null
|
||||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
|
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
|
||||||
}
|
}
|
||||||
@@ -315,6 +316,102 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── List View Item ──────────────────────────────────────────────────────────
|
||||||
|
function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||||
|
const status = getTripStatus(trip)
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
|
const coverBg = trip.cover_image
|
||||||
|
? `url(${trip.cover_image}) center/cover no-repeat`
|
||||||
|
: tripGradient(trip.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
onClick={() => onClick(trip)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14, padding: '10px 16px',
|
||||||
|
background: hovered ? 'var(--bg-tertiary)' : 'var(--bg-card)', borderRadius: 14,
|
||||||
|
border: `1px solid ${hovered ? 'var(--text-faint)' : 'var(--border-primary)'}`,
|
||||||
|
cursor: 'pointer', transition: 'all 0.15s',
|
||||||
|
boxShadow: hovered ? '0 4px 16px rgba(0,0,0,0.08)' : '0 1px 3px rgba(0,0,0,0.03)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Cover thumbnail */}
|
||||||
|
<div style={{
|
||||||
|
width: 52, height: 52, borderRadius: 12, flexShrink: 0,
|
||||||
|
background: coverBg, position: 'relative', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{status === 'ongoing' && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: 4, left: 4,
|
||||||
|
width: 7, height: 7, borderRadius: '50%', background: '#ef4444',
|
||||||
|
animation: 'blink 1s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & description */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{trip.title}
|
||||||
|
</span>
|
||||||
|
{!trip.is_owner && (
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 99, whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
|
{t('dashboard.shared')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{status && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 700, padding: '1px 8px', borderRadius: 99,
|
||||||
|
background: status === 'ongoing' ? 'rgba(239,68,68,0.1)' : 'var(--bg-tertiary)',
|
||||||
|
color: status === 'ongoing' ? '#ef4444' : 'var(--text-muted)',
|
||||||
|
whiteSpace: 'nowrap', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{status === 'ongoing' ? t('dashboard.status.ongoing')
|
||||||
|
: status === 'today' ? t('dashboard.status.today')
|
||||||
|
: status === 'tomorrow' ? t('dashboard.status.tomorrow')
|
||||||
|
: status === 'future' ? t('dashboard.status.daysLeft', { count: daysUntil(trip.start_date) })
|
||||||
|
: t('dashboard.status.past')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{trip.description && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '2px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{trip.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date & stats */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
|
||||||
|
{trip.start_date && (
|
||||||
|
<div className="hidden sm:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
<Calendar size={11} />
|
||||||
|
{formatDateShort(trip.start_date, locale)}
|
||||||
|
{trip.end_date && <> — {formatDateShort(trip.end_date, locale)}</>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
<Clock size={11} /> {trip.day_count || 0}
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
<MapPin size={11} /> {trip.place_count || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />
|
||||||
|
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />
|
||||||
|
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Archived Trip Row ────────────────────────────────────────────────────────
|
// ── Archived Trip Row ────────────────────────────────────────────────────────
|
||||||
interface ArchivedRowProps {
|
interface ArchivedRowProps {
|
||||||
trip: DashboardTrip
|
trip: DashboardTrip
|
||||||
@@ -429,6 +526,15 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
const [editingTrip, setEditingTrip] = useState<DashboardTrip | null>(null)
|
const [editingTrip, setEditingTrip] = useState<DashboardTrip | null>(null)
|
||||||
const [showArchived, setShowArchived] = useState<boolean>(false)
|
const [showArchived, setShowArchived] = useState<boolean>(false)
|
||||||
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile'>(false)
|
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile'>(false)
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
|
||||||
|
|
||||||
|
const toggleViewMode = () => {
|
||||||
|
setViewMode(prev => {
|
||||||
|
const next = prev === 'grid' ? 'list' : 'grid'
|
||||||
|
localStorage.setItem('trek_dashboard_view', next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -554,6 +660,22 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
|
||||||
|
{/* View mode toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleViewMode}
|
||||||
|
title={viewMode === 'grid' ? t('dashboard.listView') : t('dashboard.gridView')}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '0 14px',
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||||
|
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||||
|
transition: 'background 0.15s, border-color 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.borderColor = 'var(--text-faint)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||||
|
>
|
||||||
|
{viewMode === 'grid' ? <List size={15} /> : <LayoutGrid size={15} />}
|
||||||
|
</button>
|
||||||
{/* Widget settings */}
|
{/* Widget settings */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowWidgetSettings(s => s ? false : true)}
|
onClick={() => setShowWidgetSettings(s => s ? false : true)}
|
||||||
@@ -655,8 +777,8 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Spotlight */}
|
{/* Spotlight (grid mode only) */}
|
||||||
{!isLoading && spotlight && (
|
{!isLoading && spotlight && viewMode === 'grid' && (
|
||||||
<SpotlightCard
|
<SpotlightCard
|
||||||
trip={spotlight}
|
trip={spotlight}
|
||||||
t={t} locale={locale} dark={dark}
|
t={t} locale={locale} dark={dark}
|
||||||
@@ -667,21 +789,37 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rest grid */}
|
{/* Trips — grid or list */}
|
||||||
{!isLoading && rest.length > 0 && (
|
{!isLoading && (viewMode === 'grid' ? rest : trips).length > 0 && (
|
||||||
<div className="trip-grid" style={{ display: 'grid', gap: 16, marginBottom: 40 }}>
|
viewMode === 'grid' ? (
|
||||||
{rest.map(trip => (
|
<div className="trip-grid" style={{ display: 'grid', gap: 16, marginBottom: 40 }}>
|
||||||
<TripCard
|
{rest.map(trip => (
|
||||||
key={trip.id}
|
<TripCard
|
||||||
trip={trip}
|
key={trip.id}
|
||||||
t={t} locale={locale}
|
trip={trip}
|
||||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
t={t} locale={locale}
|
||||||
onDelete={handleDelete}
|
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||||
onArchive={handleArchive}
|
onDelete={handleDelete}
|
||||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
onArchive={handleArchive}
|
||||||
/>
|
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 40 }}>
|
||||||
|
{trips.map(trip => (
|
||||||
|
<TripListItem
|
||||||
|
key={trip.id}
|
||||||
|
trip={trip}
|
||||||
|
t={t} locale={locale}
|
||||||
|
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onArchive={handleArchive}
|
||||||
|
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Archived section */}
|
{/* Archived section */}
|
||||||
|
|||||||
+151
-28
@@ -2,9 +2,9 @@ import React, { useState, useEffect } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import { useTranslation } from '../i18n'
|
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||||
import { authApi } from '../api/client'
|
import { authApi } from '../api/client'
|
||||||
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield } from 'lucide-react'
|
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound } from 'lucide-react'
|
||||||
|
|
||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
has_users: boolean
|
has_users: boolean
|
||||||
@@ -12,6 +12,7 @@ interface AppConfig {
|
|||||||
demo_mode: boolean
|
demo_mode: boolean
|
||||||
oidc_configured: boolean
|
oidc_configured: boolean
|
||||||
oidc_display_name?: string
|
oidc_display_name?: string
|
||||||
|
oidc_only_mode: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage(): React.ReactElement {
|
export default function LoginPage(): React.ReactElement {
|
||||||
@@ -24,38 +25,50 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||||
const [error, setError] = useState<string>('')
|
const [error, setError] = useState<string>('')
|
||||||
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
|
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
|
||||||
|
const [inviteToken, setInviteToken] = useState<string>('')
|
||||||
|
const [inviteValid, setInviteValid] = useState<boolean>(false)
|
||||||
|
|
||||||
const { login, register, demoLogin } = useAuthStore()
|
const { login, register, demoLogin, completeMfaLogin } = useAuthStore()
|
||||||
const { setLanguageLocal } = useSettingsStore()
|
const { setLanguageLocal } = useSettingsStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
|
|
||||||
if (config) {
|
|
||||||
setAppConfig(config)
|
|
||||||
if (!config.has_users) setMode('register')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle OIDC callback via short-lived auth code (secure exchange)
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
|
const invite = params.get('invite')
|
||||||
const oidcCode = params.get('oidc_code')
|
const oidcCode = params.get('oidc_code')
|
||||||
const oidcError = params.get('oidc_error')
|
const oidcError = params.get('oidc_error')
|
||||||
|
|
||||||
|
if (invite) {
|
||||||
|
setInviteToken(invite)
|
||||||
|
setMode('register')
|
||||||
|
authApi.validateInvite(invite).then(() => {
|
||||||
|
setInviteValid(true)
|
||||||
|
}).catch(() => {
|
||||||
|
setError('Invalid or expired invite link')
|
||||||
|
})
|
||||||
|
window.history.replaceState({}, '', window.location.pathname)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (oidcCode) {
|
if (oidcCode) {
|
||||||
|
setIsLoading(true)
|
||||||
window.history.replaceState({}, '', '/login')
|
window.history.replaceState({}, '', '/login')
|
||||||
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode))
|
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode))
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
localStorage.setItem('auth_token', data.token)
|
localStorage.setItem('auth_token', data.token)
|
||||||
navigate('/dashboard')
|
navigate('/dashboard', { replace: true })
|
||||||
window.location.reload()
|
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'OIDC login failed')
|
setError(data.error || 'OIDC login failed')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => setError('OIDC login failed'))
|
.catch(() => setError('OIDC login failed'))
|
||||||
|
.finally(() => setIsLoading(false))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oidcError) {
|
if (oidcError) {
|
||||||
const errorMessages: Record<string, string> = {
|
const errorMessages: Record<string, string> = {
|
||||||
registration_disabled: t('login.oidc.registrationDisabled'),
|
registration_disabled: t('login.oidc.registrationDisabled'),
|
||||||
@@ -65,8 +78,19 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
setError(errorMessages[oidcError] || oidcError)
|
setError(errorMessages[oidcError] || oidcError)
|
||||||
window.history.replaceState({}, '', '/login')
|
window.history.replaceState({}, '', '/login')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}, [])
|
|
||||||
|
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
|
||||||
|
if (config) {
|
||||||
|
setAppConfig(config)
|
||||||
|
if (!config.has_users) setMode('register')
|
||||||
|
if (config.oidc_only_mode && config.oidc_configured && config.has_users) {
|
||||||
|
window.location.href = '/api/auth/oidc/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [navigate, t])
|
||||||
|
|
||||||
const handleDemoLogin = async (): Promise<void> => {
|
const handleDemoLogin = async (): Promise<void> => {
|
||||||
setError('')
|
setError('')
|
||||||
@@ -83,18 +107,39 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [showTakeoff, setShowTakeoff] = useState<boolean>(false)
|
const [showTakeoff, setShowTakeoff] = useState<boolean>(false)
|
||||||
|
const [mfaStep, setMfaStep] = useState(false)
|
||||||
|
const [mfaToken, setMfaToken] = useState('')
|
||||||
|
const [mfaCode, setMfaCode] = useState('')
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
|
if (mode === 'login' && mfaStep) {
|
||||||
|
if (!mfaCode.trim()) {
|
||||||
|
setError(t('login.mfaCodeRequired'))
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await completeMfaLogin(mfaToken, mfaCode)
|
||||||
|
setShowTakeoff(true)
|
||||||
|
setTimeout(() => navigate('/dashboard'), 2600)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (mode === 'register') {
|
if (mode === 'register') {
|
||||||
if (!username.trim()) { setError('Username is required'); setIsLoading(false); return }
|
if (!username.trim()) { setError('Username is required'); setIsLoading(false); return }
|
||||||
if (password.length < 6) { setError('Password must be at least 6 characters'); setIsLoading(false); return }
|
if (password.length < 6) { setError('Password must be at least 6 characters'); setIsLoading(false); return }
|
||||||
await register(username, email, password)
|
await register(username, email, password, inviteToken || undefined)
|
||||||
} else {
|
} else {
|
||||||
await login(email, password)
|
const result = await login(email, password)
|
||||||
|
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
|
||||||
|
setMfaToken(result.mfa_token)
|
||||||
|
setMfaStep(true)
|
||||||
|
setMfaCode('')
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setShowTakeoff(true)
|
setShowTakeoff(true)
|
||||||
setTimeout(() => navigate('/dashboard'), 2600)
|
setTimeout(() => navigate('/dashboard'), 2600)
|
||||||
@@ -104,7 +149,10 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showRegisterOption = appConfig?.allow_registration || !appConfig?.has_users
|
const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users || inviteValid) && !appConfig?.oidc_only_mode
|
||||||
|
|
||||||
|
// In OIDC-only mode, show a minimal page that redirects directly to the IdP
|
||||||
|
const oidcOnly = appConfig?.oidc_only_mode && appConfig?.oidc_configured
|
||||||
|
|
||||||
const inputBase: React.CSSProperties = {
|
const inputBase: React.CSSProperties = {
|
||||||
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
|
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
|
||||||
@@ -266,9 +314,14 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
|
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
|
||||||
|
|
||||||
{/* Sprach-Toggle oben rechts */}
|
{/* Language toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setLanguageLocal(language === 'en' ? 'de' : 'en')}
|
onClick={() => {
|
||||||
|
const languages = SUPPORTED_LANGUAGES.map(({ value }) => value)
|
||||||
|
const currentIndex = languages.findIndex(code => code === language)
|
||||||
|
const nextLanguage = languages[(currentIndex + 1) % languages.length]
|
||||||
|
setLanguageLocal(nextLanguage)
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', top: 16, right: 16, zIndex: 10,
|
position: 'absolute', top: 16, right: 16, zIndex: 10,
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
@@ -282,7 +335,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
|
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
|
||||||
>
|
>
|
||||||
<Globe size={14} />
|
<Globe size={14} />
|
||||||
{language === 'en' ? 'EN' : 'DE'}
|
{language.toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Left — branding */}
|
{/* Left — branding */}
|
||||||
@@ -434,11 +487,47 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}>
|
<div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}>
|
||||||
|
{oidcOnly ? (
|
||||||
|
<>
|
||||||
|
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>{t('login.title')}</h2>
|
||||||
|
<p style={{ margin: '0 0 24px', fontSize: 13.5, color: '#9ca3af' }}>{t('login.oidcOnly')}</p>
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10, fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<a href={`/api/auth/oidc/login${inviteToken ? '?invite=' + encodeURIComponent(inviteToken) : ''}`}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '12px',
|
||||||
|
background: '#111827', color: 'white',
|
||||||
|
border: 'none', borderRadius: 12,
|
||||||
|
fontSize: 14, fontWeight: 700, cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
textDecoration: 'none', transition: 'all 0.15s',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#1f2937' }}
|
||||||
|
onMouseLeave={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#111827' }}
|
||||||
|
>
|
||||||
|
<Shield size={16} />
|
||||||
|
{t('login.oidcSignIn', { name: appConfig?.oidc_display_name || 'SSO' })}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
|
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
|
||||||
{mode === 'register' ? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount')) : t('login.title')}
|
{mode === 'login' && mfaStep
|
||||||
|
? t('login.mfaTitle')
|
||||||
|
: mode === 'register'
|
||||||
|
? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount'))
|
||||||
|
: t('login.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}>
|
<p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}>
|
||||||
{mode === 'register' ? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint')) : t('login.subtitle')}
|
{mode === 'login' && mfaStep
|
||||||
|
? t('login.mfaSubtitle')
|
||||||
|
: mode === 'register'
|
||||||
|
? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint'))
|
||||||
|
: t('login.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
@@ -448,6 +537,35 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mode === 'login' && mfaStep && (
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.mfaCodeLabel')}</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<KeyRound size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
value={mfaCode}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||||
|
placeholder="000000"
|
||||||
|
required
|
||||||
|
style={inputBase}
|
||||||
|
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||||
|
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: '#9ca3af', marginTop: 8 }}>{t('login.mfaHint')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setMfaStep(false); setMfaToken(''); setMfaCode(''); setError('') }}
|
||||||
|
style={{ marginTop: 8, background: 'none', border: 'none', color: '#6b7280', fontSize: 13, cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{t('login.mfaBack')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Username (register only) */}
|
{/* Username (register only) */}
|
||||||
{mode === 'register' && (
|
{mode === 'register' && (
|
||||||
<div>
|
<div>
|
||||||
@@ -465,6 +583,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
|
{!(mode === 'login' && mfaStep) && (
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label>
|
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
@@ -477,8 +596,10 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
|
{!(mode === 'login' && mfaStep) && (
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label>
|
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
@@ -497,6 +618,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button type="submit" disabled={isLoading} style={{
|
<button type="submit" disabled={isLoading} style={{
|
||||||
marginTop: 4, width: '100%', padding: '12px', background: '#111827', color: 'white',
|
marginTop: 4, width: '100%', padding: '12px', background: '#111827', color: 'white',
|
||||||
@@ -508,8 +630,8 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = '#111827'}
|
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = '#111827'}
|
||||||
>
|
>
|
||||||
{isLoading
|
{isLoading
|
||||||
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : t('login.signingIn')}</>
|
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signingIn'))}</>
|
||||||
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : t('login.signIn')}</>
|
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -518,23 +640,24 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
{showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && (
|
{showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && (
|
||||||
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: '#9ca3af' }}>
|
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: '#9ca3af' }}>
|
||||||
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
|
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
|
||||||
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError('') }}
|
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError(''); setMfaStep(false); setMfaToken(''); setMfaCode('') }}
|
||||||
style={{ background: 'none', border: 'none', color: '#111827', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13 }}>
|
style={{ background: 'none', border: 'none', color: '#111827', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13 }}>
|
||||||
{mode === 'login' ? t('login.register') : t('login.signIn')}
|
{mode === 'login' ? t('login.register') : t('login.signIn')}
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OIDC / SSO login button */}
|
{/* OIDC / SSO login button (only when OIDC is configured but not in oidc-only mode) */}
|
||||||
{appConfig?.oidc_configured && (
|
{appConfig?.oidc_configured && !oidcOnly && (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
|
||||||
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
||||||
<span style={{ fontSize: 12, color: '#9ca3af' }}>{t('common.or')}</span>
|
<span style={{ fontSize: 12, color: '#9ca3af' }}>{t('common.or')}</span>
|
||||||
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
||||||
</div>
|
</div>
|
||||||
<a href="/api/auth/oidc/login"
|
<a href={`/api/auth/oidc/login${inviteToken ? '?invite=' + encodeURIComponent(inviteToken) : ''}`}
|
||||||
style={{
|
style={{
|
||||||
marginTop: 12, width: '100%', padding: '12px',
|
marginTop: 12, width: '100%', padding: '12px',
|
||||||
background: 'white', color: '#374151',
|
background: 'white', color: '#374151',
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import React, { useState, useEffect } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import { useTranslation } from '../i18n'
|
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import CustomSelect from '../components/shared/CustomSelect'
|
import CustomSelect from '../components/shared/CustomSelect'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock } from 'lucide-react'
|
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound } from 'lucide-react'
|
||||||
import { authApi, adminApi } from '../api/client'
|
import { authApi, adminApi, notificationsApi } from '../api/client'
|
||||||
|
import apiClient from '../api/client'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import type { UserWithOidc } from '../types'
|
import type { UserWithOidc } from '../types'
|
||||||
import { getApiErrorMessage } from '../types'
|
import { getApiErrorMessage } from '../types'
|
||||||
@@ -33,7 +34,7 @@ interface SectionProps {
|
|||||||
|
|
||||||
function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement {
|
function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', breakInside: 'avoid', marginBottom: 24 }}>
|
||||||
<div className="px-6 py-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-secondary)' }}>
|
<div className="px-6 py-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
<Icon className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
<Icon className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
||||||
@@ -45,8 +46,62 @@ function Section({ title, icon: Icon, children }: SectionProps): React.ReactElem
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotificationPreferences({ t, memoriesEnabled }: { t: any; memoriesEnabled: boolean }) {
|
||||||
|
const [prefs, setPrefs] = useState<Record<string, number> | null>(null)
|
||||||
|
const [addons, setAddons] = useState<Record<string, boolean>>({})
|
||||||
|
useEffect(() => { notificationsApi.getPreferences().then(d => setPrefs(d.preferences)).catch(() => {}) }, [])
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.get('/addons').then(r => {
|
||||||
|
const map: Record<string, boolean> = {}
|
||||||
|
for (const a of (r.data.addons || [])) map[a.id] = !!a.enabled
|
||||||
|
setAddons(map)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggle = async (key: string) => {
|
||||||
|
if (!prefs) return
|
||||||
|
const newVal = prefs[key] ? 0 : 1
|
||||||
|
setPrefs(prev => prev ? { ...prev, [key]: newVal } : prev)
|
||||||
|
try { await notificationsApi.updatePreferences({ [key]: !!newVal }) } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prefs) return <p style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</p>
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
|
||||||
|
{ key: 'notify_booking_change', label: t('settings.notifyBookingChange') },
|
||||||
|
...(addons.vacay ? [{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') }] : []),
|
||||||
|
...(memoriesEnabled ? [{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') }] : []),
|
||||||
|
...(addons.collab ? [{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') }] : []),
|
||||||
|
...(addons.documents ? [{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') }] : []),
|
||||||
|
{ key: 'notify_webhook', label: t('settings.notifyWebhook') },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{options.map(opt => (
|
||||||
|
<div key={opt.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{opt.label}</span>
|
||||||
|
<button onClick={() => toggle(opt.key)}
|
||||||
|
style={{
|
||||||
|
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||||
|
background: prefs[opt.key] ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: 2, left: prefs[opt.key] ? 22 : 2,
|
||||||
|
width: 20, height: 20, borderRadius: '50%', background: 'white',
|
||||||
|
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||||
|
}} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage(): React.ReactElement {
|
export default function SettingsPage(): React.ReactElement {
|
||||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout } = useAuthStore()
|
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode } = useAuthStore()
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||||
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
const { settings, updateSetting, updateSettings } = useSettingsStore()
|
const { settings, updateSetting, updateSettings } = useSettingsStore()
|
||||||
@@ -56,6 +111,59 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
|
|
||||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
// Immich
|
||||||
|
const [memoriesEnabled, setMemoriesEnabled] = useState(false)
|
||||||
|
const [immichUrl, setImmichUrl] = useState('')
|
||||||
|
const [immichApiKey, setImmichApiKey] = useState('')
|
||||||
|
const [immichConnected, setImmichConnected] = useState(false)
|
||||||
|
const [immichTesting, setImmichTesting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.get('/addons').then(r => {
|
||||||
|
const mem = r.data.addons?.find((a: any) => a.id === 'memories' && a.enabled)
|
||||||
|
setMemoriesEnabled(!!mem)
|
||||||
|
if (mem) {
|
||||||
|
apiClient.get('/integrations/immich/settings').then(r2 => {
|
||||||
|
setImmichUrl(r2.data.immich_url || '')
|
||||||
|
setImmichConnected(r2.data.connected)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSaveImmich = async () => {
|
||||||
|
setSaving(s => ({ ...s, immich: true }))
|
||||||
|
try {
|
||||||
|
await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined })
|
||||||
|
toast.success(t('memories.saved'))
|
||||||
|
// Test connection
|
||||||
|
const res = await apiClient.get('/integrations/immich/status')
|
||||||
|
setImmichConnected(res.data.connected)
|
||||||
|
} catch {
|
||||||
|
toast.error(t('memories.connectionError'))
|
||||||
|
} finally {
|
||||||
|
setSaving(s => ({ ...s, immich: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTestImmich = async () => {
|
||||||
|
setImmichTesting(true)
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/integrations/immich/status')
|
||||||
|
if (res.data.connected) {
|
||||||
|
toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''}`)
|
||||||
|
setImmichConnected(true)
|
||||||
|
} else {
|
||||||
|
toast.error(`${t('memories.connectionError')}: ${res.data.error}`)
|
||||||
|
setImmichConnected(false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t('memories.connectionError'))
|
||||||
|
} finally {
|
||||||
|
setImmichTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Map settings
|
// Map settings
|
||||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||||
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
||||||
@@ -71,6 +179,20 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
const [currentPassword, setCurrentPassword] = useState<string>('')
|
const [currentPassword, setCurrentPassword] = useState<string>('')
|
||||||
const [newPassword, setNewPassword] = useState<string>('')
|
const [newPassword, setNewPassword] = useState<string>('')
|
||||||
const [confirmPassword, setConfirmPassword] = useState<string>('')
|
const [confirmPassword, setConfirmPassword] = useState<string>('')
|
||||||
|
const [oidcOnlyMode, setOidcOnlyMode] = useState<boolean>(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
authApi.getAppConfig?.().then((config) => {
|
||||||
|
if (config?.oidc_only_mode) setOidcOnlyMode(true)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const [mfaQr, setMfaQr] = useState<string | null>(null)
|
||||||
|
const [mfaSecret, setMfaSecret] = useState<string | null>(null)
|
||||||
|
const [mfaSetupCode, setMfaSetupCode] = useState('')
|
||||||
|
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
|
||||||
|
const [mfaDisableCode, setMfaDisableCode] = useState('')
|
||||||
|
const [mfaLoading, setMfaLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMapTileUrl(settings.map_tile_url || '')
|
setMapTileUrl(settings.map_tile_url || '')
|
||||||
@@ -152,12 +274,15 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||||
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
<div>
|
<style>{`@media (max-width: 900px) { .settings-columns { column-count: 1 !important; } }`}</style>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1>
|
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1>
|
||||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>{t('settings.subtitle')}</p>
|
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>{t('settings.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-columns" style={{ columnCount: 2, columnGap: 24 }}>
|
||||||
|
|
||||||
{/* Map settings */}
|
{/* Map settings */}
|
||||||
<Section title={t('settings.map')} icon={Map}>
|
<Section title={t('settings.map')} icon={Map}>
|
||||||
<div>
|
<div>
|
||||||
@@ -258,11 +383,8 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
{/* Sprache */}
|
{/* Sprache */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
|
||||||
<div className="flex gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{[
|
{SUPPORTED_LANGUAGES.map(opt => (
|
||||||
{ value: 'de', label: 'Deutsch' },
|
|
||||||
{ value: 'en', label: 'English' },
|
|
||||||
].map(opt => (
|
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -374,8 +496,82 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Blur Booking Codes */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.blurBookingCodes')}</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{[
|
||||||
|
{ value: true, label: t('settings.on') || 'On' },
|
||||||
|
{ value: false, label: t('settings.off') || 'Off' },
|
||||||
|
].map(opt => (
|
||||||
|
<button
|
||||||
|
key={String(opt.value)}
|
||||||
|
onClick={async () => {
|
||||||
|
try { await updateSetting('blur_booking_codes', opt.value) }
|
||||||
|
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||||
|
border: (!!settings.blur_booking_codes) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||||
|
background: (!!settings.blur_booking_codes) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<Section title={t('settings.notifications')} icon={Lock}>
|
||||||
|
<NotificationPreferences t={t} memoriesEnabled={memoriesEnabled} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Immich — only when Memories addon is enabled */}
|
||||||
|
{memoriesEnabled && (
|
||||||
|
<Section title="Immich" icon={Camera}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichUrl')}</label>
|
||||||
|
<input type="url" value={immichUrl} onChange={e => setImmichUrl(e.target.value)}
|
||||||
|
placeholder="https://immich.example.com"
|
||||||
|
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichApiKey')}</label>
|
||||||
|
<input type="password" value={immichApiKey} onChange={e => setImmichApiKey(e.target.value)}
|
||||||
|
placeholder={immichConnected ? '••••••••' : 'API Key'}
|
||||||
|
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={handleSaveImmich} disabled={saving.immich}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400">
|
||||||
|
<Save className="w-4 h-4" /> {t('common.save')}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleTestImmich} disabled={immichTesting}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50">
|
||||||
|
{immichTesting
|
||||||
|
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
||||||
|
: <Camera className="w-4 h-4" />}
|
||||||
|
{t('memories.testConnection')}
|
||||||
|
</button>
|
||||||
|
{immichConnected && (
|
||||||
|
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||||
|
{t('memories.connected')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Account */}
|
{/* Account */}
|
||||||
<Section title={t('settings.account')} icon={User}>
|
<Section title={t('settings.account')} icon={User}>
|
||||||
<div>
|
<div>
|
||||||
@@ -398,7 +594,8 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Change Password */}
|
{/* Change Password */}
|
||||||
<div style={{ paddingTop: 8, marginTop: 8, borderTop: '1px solid var(--border-secondary)' }}>
|
{!oidcOnlyMode && (
|
||||||
|
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<input
|
<input
|
||||||
@@ -446,6 +643,146 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MFA */}
|
||||||
|
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<KeyRound className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||||
|
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
|
||||||
|
{demoMode ? (
|
||||||
|
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium m-0" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!user?.mfa_enabled && !mfaQr && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={mfaLoading}
|
||||||
|
onClick={async () => {
|
||||||
|
setMfaLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await authApi.mfaSetup() as { qr_data_url: string; secret: string }
|
||||||
|
setMfaQr(data.qr_data_url)
|
||||||
|
setMfaSecret(data.secret)
|
||||||
|
setMfaSetupCode('')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
|
} finally {
|
||||||
|
setMfaLoading(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
{mfaLoading ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> : <KeyRound size={14} />}
|
||||||
|
{t('settings.mfa.setup')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!user?.mfa_enabled && mfaQr && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.scanQr')}</p>
|
||||||
|
<img src={mfaQr} alt="" className="rounded-lg border mx-auto block" style={{ maxWidth: 200, borderColor: 'var(--border-primary)' }} />
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.secretLabel')}</label>
|
||||||
|
<code className="block text-xs p-2 rounded break-all" style={{ background: 'var(--bg-hover)', color: 'var(--text-primary)' }}>{mfaSecret}</code>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={mfaSetupCode}
|
||||||
|
onChange={(e) => setMfaSetupCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||||
|
placeholder={t('settings.mfa.codePlaceholder')}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={mfaLoading || mfaSetupCode.length < 6}
|
||||||
|
onClick={async () => {
|
||||||
|
setMfaLoading(true)
|
||||||
|
try {
|
||||||
|
await authApi.mfaEnable({ code: mfaSetupCode })
|
||||||
|
toast.success(t('settings.mfa.toastEnabled'))
|
||||||
|
setMfaQr(null)
|
||||||
|
setMfaSecret(null)
|
||||||
|
setMfaSetupCode('')
|
||||||
|
await loadUser()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
|
} finally {
|
||||||
|
setMfaLoading(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('settings.mfa.enable')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm border"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{t('settings.mfa.cancelSetup')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user?.mfa_enabled && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.disableTitle')}</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.disableHint')}</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={mfaDisablePwd}
|
||||||
|
onChange={(e) => setMfaDisablePwd(e.target.value)}
|
||||||
|
placeholder={t('settings.currentPassword')}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={mfaDisableCode}
|
||||||
|
onChange={(e) => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||||
|
placeholder={t('settings.mfa.codePlaceholder')}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
|
||||||
|
onClick={async () => {
|
||||||
|
setMfaLoading(true)
|
||||||
|
try {
|
||||||
|
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode })
|
||||||
|
toast.success(t('settings.mfa.toastDisabled'))
|
||||||
|
setMfaDisablePwd('')
|
||||||
|
setMfaDisableCode('')
|
||||||
|
await loadUser()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
|
} finally {
|
||||||
|
setMfaLoading(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('settings.mfa.disable')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
@@ -643,6 +980,7 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { MapContainer, TileLayer, Marker, Tooltip, useMap } from 'react-leaflet'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
|
||||||
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
|
import { shareApi } from '../api/client'
|
||||||
|
import { getCategoryIcon } from '../components/shared/categoryIcons'
|
||||||
|
import { createElement } from 'react'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||||
|
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||||
|
|
||||||
|
function createMarkerIcon(place: any) {
|
||||||
|
const cat = place.category
|
||||||
|
const color = cat?.color || '#6366f1'
|
||||||
|
const CatIcon = getCategoryIcon(cat?.icon)
|
||||||
|
const iconSvg = renderToStaticMarkup(createElement(CatIcon, { size: 14, strokeWidth: 2, color: 'white' }))
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
iconSize: [28, 28],
|
||||||
|
iconAnchor: [14, 14],
|
||||||
|
html: `<div style="width:28px;height:28px;border-radius:50%;background:${color};display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(0,0,0,0.3);border:2px solid white;">${iconSvg}</div>`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function FitBoundsToPlaces({ places }: { places: any[] }) {
|
||||||
|
const map = useMap()
|
||||||
|
useEffect(() => {
|
||||||
|
if (places.length === 0) return
|
||||||
|
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
|
||||||
|
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 })
|
||||||
|
}, [places, map])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SharedTripPage() {
|
||||||
|
const { token } = useParams<{ token: string }>()
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const [data, setData] = useState<any>(null)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const [selectedDay, setSelectedDay] = useState<number | null>(null)
|
||||||
|
const [activeTab, setActiveTab] = useState('plan')
|
||||||
|
const { updateSetting } = useSettingsStore()
|
||||||
|
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return
|
||||||
|
shareApi.getSharedTrip(token).then(setData).catch(() => setError(true))
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
if (error) return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: '#f3f4f6' }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>🔒</div>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 700, color: '#111827' }}>{t('shared.expired')}</h1>
|
||||||
|
<p style={{ color: '#6b7280', marginTop: 8 }}>{t('shared.expiredHint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data) return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: '#f3f4f6' }}>
|
||||||
|
<div style={{ width: 32, height: 32, border: '3px solid #e5e7eb', borderTopColor: '#111827', borderRadius: '50%', animation: 'spin 0.6s linear infinite' }} />
|
||||||
|
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const { trip, days, assignments, dayNotes, places, reservations, accommodations, packing, budget, categories, permissions, collab } = data
|
||||||
|
const sortedDays = [...(days || [])].sort((a: any, b: any) => a.day_number - b.day_number)
|
||||||
|
|
||||||
|
// Map places
|
||||||
|
const mapPlaces = selectedDay
|
||||||
|
? (assignments[String(selectedDay)] || []).map((a: any) => a.place).filter((p: any) => p?.lat && p?.lng)
|
||||||
|
: (places || []).filter((p: any) => p?.lat && p?.lng)
|
||||||
|
|
||||||
|
const center = mapPlaces.length > 0 ? [mapPlaces[0].lat, mapPlaces[0].lng] : [48.85, 2.35]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', background: 'var(--bg-secondary, #f3f4f6)', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', color: 'white', padding: '32px 20px 28px', textAlign: 'center', position: 'relative' }}>
|
||||||
|
{/* Cover image background */}
|
||||||
|
{trip.cover_image && (
|
||||||
|
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(${trip.cover_image.startsWith('http') ? trip.cover_image : trip.cover_image.startsWith('/') ? trip.cover_image : '/uploads/' + trip.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
|
||||||
|
)}
|
||||||
|
{/* Background decoration */}
|
||||||
|
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} />
|
||||||
|
<div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
<div style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 44, height: 44, borderRadius: 12, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(8px)', marginBottom: 12, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||||
|
<img src="/icons/icon-white.svg" alt="TREK" width="26" height="26" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: 3, textTransform: 'uppercase', opacity: 0.35, marginBottom: 12 }}>Travel Resource & Exploration Kit</div>
|
||||||
|
|
||||||
|
<h1 style={{ margin: '0 0 4px', fontSize: 26, fontWeight: 700, letterSpacing: -0.5 }}>{trip.title}</h1>
|
||||||
|
|
||||||
|
{trip.description && (
|
||||||
|
<div style={{ fontSize: 13, opacity: 0.5, maxWidth: 400, margin: '0 auto', lineHeight: 1.5 }}>{trip.description}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(trip.start_date || trip.end_date) && (
|
||||||
|
<div style={{ marginTop: 10, display: 'inline-flex', alignItems: 'center', gap: 8, padding: '6px 14px', borderRadius: 20, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(4px)', border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8 }}>
|
||||||
|
{[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })).join(' — ')}
|
||||||
|
</span>
|
||||||
|
{days?.length > 0 && <span style={{ fontSize: 11, opacity: 0.4 }}>·</span>}
|
||||||
|
{days?.length > 0 && <span style={{ fontSize: 11, opacity: 0.5 }}>{days.length} {t('shared.days')}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12, fontSize: 9, fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase', opacity: 0.25 }}>{t('shared.readOnly')}</div>
|
||||||
|
|
||||||
|
{/* Language picker - top right */}
|
||||||
|
<div style={{ position: 'absolute', top: 12, right: 12, zIndex: 10 }}>
|
||||||
|
<button onClick={() => setShowLangPicker(v => !v)} style={{
|
||||||
|
padding: '5px 12px', borderRadius: 20, border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
background: 'rgba(255,255,255,0.1)', backdropFilter: 'blur(8px)',
|
||||||
|
color: 'rgba(255,255,255,0.7)', fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
{SUPPORTED_LANGUAGES.find(l => l.value === (locale?.split('-')[0] || 'en'))?.label || 'Language'}
|
||||||
|
</button>
|
||||||
|
{showLangPicker && (
|
||||||
|
<div style={{ position: 'absolute', top: '100%', right: 0, marginTop: 6, background: 'white', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.2)', padding: 4, zIndex: 50, minWidth: 150 }}>
|
||||||
|
{SUPPORTED_LANGUAGES.map(lang => (
|
||||||
|
<button key={lang.value} onClick={() => { updateSetting('language', lang.value); setShowLangPicker(false) }}
|
||||||
|
style={{ display: 'block', width: '100%', padding: '6px 12px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 12, color: '#374151', borderRadius: 6, fontFamily: 'inherit' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = '#f3f4f6'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||||
|
>{lang.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ maxWidth: 900, margin: '0 auto', padding: '20px 16px' }}>
|
||||||
|
{/* Tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 20, overflowX: 'auto', padding: '2px 0' }}>
|
||||||
|
{[
|
||||||
|
{ id: 'plan', label: t('shared.tabPlan'), Icon: Map },
|
||||||
|
...(permissions?.share_bookings ? [{ id: 'bookings', label: t('shared.tabBookings'), Icon: Ticket }] : []),
|
||||||
|
...(permissions?.share_packing ? [{ id: 'packing', label: t('shared.tabPacking'), Icon: Luggage }] : []),
|
||||||
|
...(permissions?.share_budget ? [{ id: 'budget', label: t('shared.tabBudget'), Icon: Wallet }] : []),
|
||||||
|
...(permissions?.share_collab ? [{ id: 'collab', label: t('shared.tabChat'), Icon: MessageCircle }] : []),
|
||||||
|
].map(tab => (
|
||||||
|
<button key={tab.id} onClick={() => setActiveTab(tab.id)} style={{
|
||||||
|
padding: '8px 18px', borderRadius: 12, border: '1.5px solid', cursor: 'pointer',
|
||||||
|
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', transition: 'all 0.15s', whiteSpace: 'nowrap',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
background: activeTab === tab.id ? '#111827' : 'var(--bg-card, white)',
|
||||||
|
borderColor: activeTab === tab.id ? '#111827' : 'var(--border-faint, #e5e7eb)',
|
||||||
|
color: activeTab === tab.id ? 'white' : '#6b7280',
|
||||||
|
boxShadow: activeTab === tab.id ? '0 2px 8px rgba(0,0,0,0.15)' : '0 1px 3px rgba(0,0,0,0.04)',
|
||||||
|
}}><tab.Icon size={13} /><span className="hidden sm:inline">{tab.label}</span></button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map */}
|
||||||
|
{activeTab === 'plan' && (<>
|
||||||
|
<div style={{ borderRadius: 16, overflow: 'hidden', height: 300, marginBottom: 20, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||||
|
<MapContainer center={center as [number, number]} zoom={11} zoomControl={false} style={{ width: '100%', height: '100%' }}>
|
||||||
|
<TileLayer url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" />
|
||||||
|
<FitBoundsToPlaces places={mapPlaces} />
|
||||||
|
{mapPlaces.map((p: any) => (
|
||||||
|
<Marker key={p.id} position={[p.lat, p.lng]} icon={createMarkerIcon(p)}>
|
||||||
|
<Tooltip>{p.name}</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day Plan */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{sortedDays.map((day: any, di: number) => {
|
||||||
|
const da = assignments[String(day.id)] || []
|
||||||
|
const notes = (dayNotes[String(day.id)] || [])
|
||||||
|
const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
|
||||||
|
const dayAccs = (accommodations || []).filter((a: any) => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
||||||
|
|
||||||
|
const merged = [
|
||||||
|
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
|
||||||
|
...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
|
||||||
|
...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
|
||||||
|
].sort((a, b) => a.k - b.k)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
|
||||||
|
<div onClick={() => setSelectedDay(selectedDay === day.id ? null : day.id)}
|
||||||
|
style={{ padding: '12px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{ width: 28, height: 28, borderRadius: '50%', background: selectedDay === day.id ? '#111827' : '#f3f4f6', color: selectedDay === day.id ? 'white' : '#6b7280', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flexShrink: 0 }}>{di + 1}</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#111827' }}>{day.title || `Day ${day.day_number}`}</div>
|
||||||
|
{day.date && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>{new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>}
|
||||||
|
</div>
|
||||||
|
{dayAccs.map((acc: any) => (
|
||||||
|
<span key={acc.id} style={{ fontSize: 9, padding: '2px 6px', borderRadius: 4, background: '#f3f4f6', color: '#6b7280', display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||||
|
<Hotel size={8} /> {acc.place_name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af' }}>{da.length} {t('shared.places')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedDay === day.id && merged.length > 0 && (
|
||||||
|
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{merged.map((item: any, idx: number) => {
|
||||||
|
if (item.type === 'transport') {
|
||||||
|
const r = item.data
|
||||||
|
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||||
|
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||||
|
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||||
|
let sub = ''
|
||||||
|
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||||
|
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
||||||
|
return (
|
||||||
|
<div key={`t-${r.id}`} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, background: 'rgba(59,130,246,0.06)', border: '1px solid rgba(59,130,246,0.15)' }}>
|
||||||
|
<div style={{ width: 24, height: 24, borderRadius: '50%', background: 'rgba(59,130,246,0.12)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<TIcon size={12} color="#3b82f6" />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 500, color: '#111827' }}>{r.title}{time ? ` · ${time}` : ''}</div>
|
||||||
|
{sub && <div style={{ fontSize: 10, color: '#6b7280' }}>{sub}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.type === 'note') {
|
||||||
|
return (
|
||||||
|
<div key={`n-${item.data.id}`} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 8px', borderRadius: 6, background: '#f9fafb', border: '1px solid #f3f4f6' }}>
|
||||||
|
<FileText size={12} color="#9ca3af" />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: '#374151' }}>{item.data.text}</div>
|
||||||
|
{item.data.time && <div style={{ fontSize: 10, color: '#9ca3af' }}>{item.data.time}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const place = item.data.place
|
||||||
|
if (!place) return null
|
||||||
|
const cat = categories?.find((c: any) => c.id === place.category_id)
|
||||||
|
return (
|
||||||
|
<div key={`p-${item.data.id}`} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 8px', borderRadius: 6 }}>
|
||||||
|
<div style={{ width: 28, height: 28, borderRadius: '50%', background: cat?.color || '#6366f1', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
{place.image_url ? <img src={place.image_url} style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} /> : <MapPin size={13} color="white" />}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 12.5, fontWeight: 500, color: '#111827' }}>{place.name}</div>
|
||||||
|
{(place.address || place.description) && <div style={{ fontSize: 10, color: '#9ca3af', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{place.address || place.description}</div>}
|
||||||
|
</div>
|
||||||
|
{place.place_time && <span style={{ fontSize: 10, color: '#6b7280', display: 'flex', alignItems: 'center', gap: 3, flexShrink: 0 }}><Clock size={9} />{place.place_time}{place.end_time ? ` – ${place.end_time}` : ''}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{/* Bookings */}
|
||||||
|
{activeTab === 'bookings' && (reservations || []).length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{(reservations || []).map((r: any) => {
|
||||||
|
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||||
|
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||||
|
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||||
|
const date = r.reservation_time ? new Date(r.reservation_time.includes('T') ? r.reservation_time : r.reservation_time + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' }) : ''
|
||||||
|
return (
|
||||||
|
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<TIcon size={15} color="#6b7280" />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: '#111827' }}>{r.title}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#9ca3af', display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 2 }}>
|
||||||
|
{date && <span>{date}</span>}
|
||||||
|
{time && <span>{time}</span>}
|
||||||
|
{r.location && <span>{r.location}</span>}
|
||||||
|
{meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
|
||||||
|
{meta.train_number && <span>{meta.train_number}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 10, padding: '2px 8px', borderRadius: 20, fontWeight: 600, background: r.status === 'confirmed' ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)', color: r.status === 'confirmed' ? '#16a34a' : '#d97706' }}>
|
||||||
|
{r.status === 'confirmed' ? t('shared.confirmed') : t('shared.pending')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Packing */}
|
||||||
|
{activeTab === 'packing' && (packing || []).length > 0 && (
|
||||||
|
<div style={{ background: 'var(--bg-card, white)', borderRadius: 14, border: '1px solid var(--border-faint, #e5e7eb)', overflow: 'hidden' }}>
|
||||||
|
{Object.entries((packing || []).reduce((g: any, i: any) => { const c = i.category || t('shared.other'); (g[c] = g[c] || []).push(i); return g }, {})).map(([cat, items]: [string, any]) => (
|
||||||
|
<div key={cat}>
|
||||||
|
<div style={{ padding: '8px 16px', background: '#f9fafb', fontSize: 11, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '1px solid #f3f4f6' }}>{cat}</div>
|
||||||
|
{items.map((item: any) => (
|
||||||
|
<div key={item.id} style={{ padding: '6px 16px', display: 'flex', alignItems: 'center', gap: 8, borderBottom: '1px solid #f9fafb' }}>
|
||||||
|
<span style={{ fontSize: 13, color: item.checked ? '#9ca3af' : '#111827', textDecoration: item.checked ? 'line-through' : 'none' }}>{item.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Budget */}
|
||||||
|
{activeTab === 'budget' && (budget || []).length > 0 && (() => {
|
||||||
|
const grouped = (budget || []).reduce((g: any, i: any) => { const c = i.category || t('shared.other'); (g[c] = g[c] || []).push(i); return g }, {})
|
||||||
|
const total = (budget || []).reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0)
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{/* Total card */}
|
||||||
|
<div style={{ background: 'linear-gradient(135deg, #000 0%, #1a1a2e 100%)', borderRadius: 14, padding: '20px 24px', color: 'white' }}>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 500, letterSpacing: 1, textTransform: 'uppercase', opacity: 0.5 }}>{t('shared.totalBudget')}</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, marginTop: 4 }}>{total.toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || 'EUR'}</div>
|
||||||
|
</div>
|
||||||
|
{/* By category */}
|
||||||
|
{Object.entries(grouped).map(([cat, items]: [string, any]) => (
|
||||||
|
<div key={cat} style={{ background: 'var(--bg-card, white)', borderRadius: 12, border: '1px solid var(--border-faint, #e5e7eb)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '10px 16px', background: '#f9fafb', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #f3f4f6' }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{cat}</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#6b7280' }}>{items.reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0).toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || ''}</span>
|
||||||
|
</div>
|
||||||
|
{items.map((item: any) => (
|
||||||
|
<div key={item.id} style={{ padding: '8px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #fafafa' }}>
|
||||||
|
<span style={{ fontSize: 13, color: '#111827' }}>{item.name}</span>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: '#111827' }}>{item.total_price ? Number(item.total_price).toLocaleString(locale, { minimumFractionDigits: 2 }) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Collab Chat */}
|
||||||
|
{activeTab === 'collab' && (collab || []).length > 0 && (
|
||||||
|
<div style={{ background: 'var(--bg-card, white)', borderRadius: 14, border: '1px solid var(--border-faint, #e5e7eb)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '12px 16px', background: '#f9fafb', borderBottom: '1px solid #f3f4f6', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<MessageCircle size={14} color="#6b7280" />
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{t('shared.tabChat')} · {(collab || []).length} {t('shared.messages')}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ maxHeight: 500, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{(collab || []).map((msg: any, i: number) => {
|
||||||
|
const prevMsg = i > 0 ? collab[i - 1] : null
|
||||||
|
const showDate = !prevMsg || new Date(msg.created_at).toDateString() !== new Date(prevMsg.created_at).toDateString()
|
||||||
|
return (
|
||||||
|
<div key={msg.id}>
|
||||||
|
{showDate && (
|
||||||
|
<div style={{ textAlign: 'center', margin: '8px 0', fontSize: 10, fontWeight: 600, color: '#9ca3af' }}>
|
||||||
|
{new Date(msg.created_at).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700, color: '#6b7280', flexShrink: 0, overflow: 'hidden' }}>
|
||||||
|
{msg.avatar ? <img src={`/uploads/avatars/${msg.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (msg.username || '?')[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#111827' }}>{msg.username}</span>
|
||||||
|
<span style={{ fontSize: 10, color: '#9ca3af' }}>{new Date(msg.created_at).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#374151', marginTop: 3, lineHeight: 1.5, whiteSpace: 'pre-wrap' }}>{msg.text}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px 0 20px' }}>
|
||||||
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 16px', borderRadius: 20, background: 'var(--bg-card, white)', border: '1px solid var(--border-faint, #e5e7eb)', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
|
||||||
|
<img src="/icons/icon.svg" alt="TREK" width="18" height="18" style={{ borderRadius: 4 }} />
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af' }}>{t('shared.sharedVia')} <strong style={{ color: '#6b7280' }}>TREK</strong></span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8, fontSize: 10, color: '#d1d5db' }}>Made with <span style={{ color: '#ef4444' }}>♥</span> by Maurice · <a href="https://github.com/mauriceboe/TREK" style={{ color: '#9ca3af', textDecoration: 'none' }}>GitHub</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import PlaceFormModal from '../components/Planner/PlaceFormModal'
|
|||||||
import TripFormModal from '../components/Trips/TripFormModal'
|
import TripFormModal from '../components/Trips/TripFormModal'
|
||||||
import TripMembersModal from '../components/Trips/TripMembersModal'
|
import TripMembersModal from '../components/Trips/TripMembersModal'
|
||||||
import { ReservationModal } from '../components/Planner/ReservationModal'
|
import { ReservationModal } from '../components/Planner/ReservationModal'
|
||||||
|
import MemoriesPanel from '../components/Memories/MemoriesPanel'
|
||||||
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||||
import FileManager from '../components/Files/FileManager'
|
import FileManager from '../components/Files/FileManager'
|
||||||
@@ -54,7 +55,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
addonsApi.enabled().then(data => {
|
addonsApi.enabled().then(data => {
|
||||||
const map = {}
|
const map = {}
|
||||||
data.addons.forEach(a => { map[a.id] = true })
|
data.addons.forEach(a => { map[a.id] = true })
|
||||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
|
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: !!map.memories })
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
authApi.getAppConfig().then(config => {
|
authApi.getAppConfig().then(config => {
|
||||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||||
@@ -67,7 +68,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
|
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
|
||||||
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
|
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
|
||||||
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
|
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
|
||||||
...(enabledAddons.collab ? [{ id: 'collab', label: 'Collab' }] : []),
|
...(enabledAddons.memories ? [{ id: 'memories', label: t('memories.title') }] : []),
|
||||||
|
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name') }] : []),
|
||||||
]
|
]
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<string>(() => {
|
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||||
@@ -116,9 +118,15 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
|
|
||||||
useTripWebSocket(tripId)
|
useTripWebSocket(tripId)
|
||||||
|
|
||||||
const mapPlaces = useCallback(() => {
|
const [mapCategoryFilter, setMapCategoryFilter] = useState<string>('')
|
||||||
return places.filter(p => p.lat && p.lng)
|
|
||||||
}, [places])
|
const mapPlaces = useMemo(() => {
|
||||||
|
return places.filter(p => {
|
||||||
|
if (!p.lat || !p.lng) return false
|
||||||
|
if (mapCategoryFilter && String(p.category_id) !== String(mapCategoryFilter)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [places, mapCategoryFilter])
|
||||||
|
|
||||||
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation(tripStore, selectedDayId)
|
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation(tripStore, selectedDayId)
|
||||||
|
|
||||||
@@ -370,7 +378,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
{activeTab === 'plan' && (
|
{activeTab === 'plan' && (
|
||||||
<div style={{ position: 'absolute', inset: 0 }}>
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
<MapView
|
<MapView
|
||||||
places={mapPlaces()}
|
places={mapPlaces}
|
||||||
dayPlaces={dayPlaces}
|
dayPlaces={dayPlaces}
|
||||||
route={route}
|
route={route}
|
||||||
routeSegments={routeSegments}
|
routeSegments={routeSegments}
|
||||||
@@ -438,6 +446,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
||||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||||
accommodations={tripAccommodations}
|
accommodations={tripAccommodations}
|
||||||
|
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||||
/>
|
/>
|
||||||
{!leftCollapsed && (
|
{!leftCollapsed && (
|
||||||
<div
|
<div
|
||||||
@@ -486,6 +495,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', paddingLeft: 4 }}>
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', paddingLeft: 4 }}>
|
||||||
<PlacesSidebar
|
<PlacesSidebar
|
||||||
|
tripId={tripId}
|
||||||
places={places}
|
places={places}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
@@ -496,6 +506,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
onAssignToDay={handleAssignToDay}
|
onAssignToDay={handleAssignToDay}
|
||||||
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
|
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
|
||||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||||
|
onCategoryFilterChange={setMapCategoryFilter}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -533,6 +544,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
lng={geoPlace?.lng}
|
lng={geoPlace?.lng}
|
||||||
onClose={() => setShowDayDetail(null)}
|
onClose={() => setShowDayDetail(null)}
|
||||||
onAccommodationChange={loadAccommodations}
|
onAccommodationChange={loadAccommodations}
|
||||||
|
leftWidth={leftCollapsed ? 0 : leftWidth}
|
||||||
|
rightWidth={rightCollapsed ? 0 : rightWidth}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
@@ -579,6 +592,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}}
|
}}
|
||||||
onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }}
|
onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }}
|
||||||
|
leftWidth={leftCollapsed ? 0 : leftWidth}
|
||||||
|
rightWidth={rightCollapsed ? 0 : rightWidth}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -593,8 +608,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{mobileSidebarOpen === 'left'
|
{mobileSidebarOpen === 'left'
|
||||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} />
|
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} />
|
||||||
: <PlacesSidebar places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile />
|
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -649,6 +664,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'memories' && (
|
||||||
|
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||||
|
<MemoriesPanel tripId={Number(tripId)} startDate={trip?.start_date || null} endDate={trip?.end_date || null} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'collab' && (
|
{activeTab === 'collab' && (
|
||||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||||
<CollabPanel tripId={tripId} tripMembers={tripMembers} />
|
<CollabPanel tripId={tripId} tripMembers={tripMembers} />
|
||||||
|
|||||||
@@ -104,7 +104,12 @@ export default function VacayPage(): React.ReactElement {
|
|||||||
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.legend')}</span>
|
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.legend')}</span>
|
||||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1.5">
|
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1.5">
|
||||||
{plan?.holidays_enabled && <LegendItem color="#fecaca" label={t('vacay.publicHoliday')} />}
|
{plan?.holidays_enabled && (plan?.holiday_calendars ?? []).length === 0 && (
|
||||||
|
<LegendItem color="#fecaca" label={t('vacay.publicHoliday')} />
|
||||||
|
)}
|
||||||
|
{plan?.holidays_enabled && (plan?.holiday_calendars ?? []).map(cal => (
|
||||||
|
<LegendItem key={cal.id} color={cal.color} label={cal.label || cal.region} />
|
||||||
|
))}
|
||||||
{plan?.company_holidays_enabled && <LegendItem color="#fde68a" label={t('vacay.companyHoliday')} />}
|
{plan?.company_holidays_enabled && <LegendItem color="#fde68a" label={t('vacay.companyHoliday')} />}
|
||||||
{plan?.block_weekends && <LegendItem color="#e5e7eb" label={t('vacay.weekend')} />}
|
{plan?.block_weekends && <LegendItem color="#e5e7eb" label={t('vacay.weekend')} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -128,7 +133,7 @@ export default function VacayPage(): React.ReactElement {
|
|||||||
<CalendarDays size={18} style={{ color: 'var(--text-primary)' }} />
|
<CalendarDays size={18} style={{ color: 'var(--text-primary)' }} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg sm:text-xl font-bold" style={{ color: 'var(--text-primary)' }}>Vacay</h1>
|
<h1 className="text-lg sm:text-xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.catalog.vacay.name')}</h1>
|
||||||
<p className="text-xs hidden sm:block" style={{ color: 'var(--text-muted)' }}>{t('vacay.subtitle')}</p>
|
<p className="text-xs hidden sm:block" style={{ color: 'var(--text-muted)' }}>{t('vacay.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface AuthResponse {
|
|||||||
token: string
|
token: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LoginResult = AuthResponse | { mfa_required: true; mfa_token: string }
|
||||||
|
|
||||||
interface AvatarResponse {
|
interface AvatarResponse {
|
||||||
avatar_url: string
|
avatar_url: string
|
||||||
}
|
}
|
||||||
@@ -21,8 +23,10 @@ interface AuthState {
|
|||||||
error: string | null
|
error: string | null
|
||||||
demoMode: boolean
|
demoMode: boolean
|
||||||
hasMapsKey: boolean
|
hasMapsKey: boolean
|
||||||
|
serverTimezone: string
|
||||||
|
|
||||||
login: (email: string, password: string) => Promise<AuthResponse>
|
login: (email: string, password: string) => Promise<LoginResult>
|
||||||
|
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||||
register: (username: string, email: string, password: string) => Promise<AuthResponse>
|
register: (username: string, email: string, password: string) => Promise<AuthResponse>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
loadUser: () => Promise<void>
|
loadUser: () => Promise<void>
|
||||||
@@ -33,6 +37,7 @@ interface AuthState {
|
|||||||
deleteAvatar: () => Promise<void>
|
deleteAvatar: () => Promise<void>
|
||||||
setDemoMode: (val: boolean) => void
|
setDemoMode: (val: boolean) => void
|
||||||
setHasMapsKey: (val: boolean) => void
|
setHasMapsKey: (val: boolean) => void
|
||||||
|
setServerTimezone: (tz: string) => void
|
||||||
demoLogin: () => Promise<AuthResponse>
|
demoLogin: () => Promise<AuthResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,11 +49,16 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
error: null,
|
error: null,
|
||||||
demoMode: localStorage.getItem('demo_mode') === 'true',
|
demoMode: localStorage.getItem('demo_mode') === 'true',
|
||||||
hasMapsKey: false,
|
hasMapsKey: false,
|
||||||
|
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
|
||||||
login: async (email: string, password: string) => {
|
login: async (email: string, password: string) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
const data = await authApi.login({ email, password })
|
const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
|
||||||
|
if (data.mfa_required && data.mfa_token) {
|
||||||
|
set({ isLoading: false, error: null })
|
||||||
|
return { mfa_required: true as const, mfa_token: data.mfa_token }
|
||||||
|
}
|
||||||
localStorage.setItem('auth_token', data.token)
|
localStorage.setItem('auth_token', data.token)
|
||||||
set({
|
set({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
@@ -58,7 +68,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
connect(data.token)
|
connect(data.token)
|
||||||
return data
|
return data as AuthResponse
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = getApiErrorMessage(err, 'Login failed')
|
const error = getApiErrorMessage(err, 'Login failed')
|
||||||
set({ isLoading: false, error })
|
set({ isLoading: false, error })
|
||||||
@@ -66,10 +76,31 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
register: async (username: string, email: string, password: string) => {
|
completeMfaLogin: async (mfaToken: string, code: string) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
const data = await authApi.register({ username, email, password })
|
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') })
|
||||||
|
localStorage.setItem('auth_token', data.token)
|
||||||
|
set({
|
||||||
|
user: data.user,
|
||||||
|
token: data.token,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
connect(data.token)
|
||||||
|
return data as AuthResponse
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = getApiErrorMessage(err, 'Verification failed')
|
||||||
|
set({ isLoading: false, error })
|
||||||
|
throw new Error(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (username: string, email: string, password: string, invite_token?: string) => {
|
||||||
|
set({ isLoading: true, error: null })
|
||||||
|
try {
|
||||||
|
const data = await authApi.register({ username, email, password, invite_token })
|
||||||
localStorage.setItem('auth_token', data.token)
|
localStorage.setItem('auth_token', data.token)
|
||||||
set({
|
set({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
@@ -173,6 +204,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
|
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
|
||||||
|
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
|
||||||
|
|
||||||
demoLogin: async () => {
|
demoLogin: async () => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
|
|||||||
@@ -232,6 +232,11 @@ export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
|
|||||||
files: state.files.filter(f => f.id !== payload.fileId),
|
files: state.files.filter(f => f.id !== payload.fileId),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Memories / Photos
|
||||||
|
case 'memories:updated':
|
||||||
|
window.dispatchEvent(new CustomEvent('memories:updated', { detail: payload }))
|
||||||
|
return {}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
import type { AxiosResponse } from 'axios'
|
import type { AxiosResponse } from 'axios'
|
||||||
import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo } from '../types'
|
import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo, VacayHolidayCalendar } from '../types'
|
||||||
|
|
||||||
const ax = apiClient
|
const ax = apiClient
|
||||||
|
|
||||||
@@ -65,6 +65,9 @@ interface VacayApi {
|
|||||||
updateStats: (year: number, days: number, targetUserId?: number) => Promise<unknown>
|
updateStats: (year: number, days: number, targetUserId?: number) => Promise<unknown>
|
||||||
getCountries: () => Promise<{ countries: string[] }>
|
getCountries: () => Promise<{ countries: string[] }>
|
||||||
getHolidays: (year: number, country: string) => Promise<VacayHolidayRaw[]>
|
getHolidays: (year: number, country: string) => Promise<VacayHolidayRaw[]>
|
||||||
|
addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }>
|
||||||
|
updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }>
|
||||||
|
deleteHolidayCalendar: (id: number) => Promise<unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
const api: VacayApi = {
|
const api: VacayApi = {
|
||||||
@@ -87,6 +90,9 @@ const api: VacayApi = {
|
|||||||
updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId }).then((r: AxiosResponse) => r.data),
|
updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId }).then((r: AxiosResponse) => r.data),
|
||||||
getCountries: () => ax.get('/addons/vacay/holidays/countries').then((r: AxiosResponse) => r.data),
|
getCountries: () => ax.get('/addons/vacay/holidays/countries').then((r: AxiosResponse) => r.data),
|
||||||
getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then((r: AxiosResponse) => r.data),
|
getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then((r: AxiosResponse) => r.data),
|
||||||
|
addHolidayCalendar: (data) => ax.post('/addons/vacay/plan/holiday-calendars', data).then((r: AxiosResponse) => r.data),
|
||||||
|
updateHolidayCalendar: (id, data) => ax.put(`/addons/vacay/plan/holiday-calendars/${id}`, data).then((r: AxiosResponse) => r.data),
|
||||||
|
deleteHolidayCalendar: (id) => ax.delete(`/addons/vacay/plan/holiday-calendars/${id}`).then((r: AxiosResponse) => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VacayState {
|
interface VacayState {
|
||||||
@@ -124,6 +130,9 @@ interface VacayState {
|
|||||||
loadStats: (year?: number) => Promise<void>
|
loadStats: (year?: number) => Promise<void>
|
||||||
updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise<void>
|
updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise<void>
|
||||||
loadHolidays: (year?: number) => Promise<void>
|
loadHolidays: (year?: number) => Promise<void>
|
||||||
|
addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise<void>
|
||||||
|
updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise<void>
|
||||||
|
deleteHolidayCalendar: (id: number) => Promise<void>
|
||||||
loadAll: () => Promise<void>
|
loadAll: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,29 +256,47 @@ export const useVacayStore = create<VacayState>((set, get) => ({
|
|||||||
loadHolidays: async (year?: number) => {
|
loadHolidays: async (year?: number) => {
|
||||||
const y = year || get().selectedYear
|
const y = year || get().selectedYear
|
||||||
const plan = get().plan
|
const plan = get().plan
|
||||||
if (!plan?.holidays_enabled || !plan?.holidays_region) {
|
const calendars = plan?.holiday_calendars ?? []
|
||||||
|
if (!plan?.holidays_enabled || calendars.length === 0) {
|
||||||
set({ holidays: {} })
|
set({ holidays: {} })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const country = plan.holidays_region.split('-')[0]
|
const map: HolidaysMap = {}
|
||||||
const region = plan.holidays_region.includes('-') ? plan.holidays_region : null
|
for (const cal of calendars) {
|
||||||
try {
|
const country = cal.region.split('-')[0]
|
||||||
const data = await api.getHolidays(y, country)
|
const region = cal.region.includes('-') ? cal.region : null
|
||||||
const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0)
|
try {
|
||||||
if (hasRegions && !region) {
|
const data = await api.getHolidays(y, country)
|
||||||
set({ holidays: {} })
|
const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0)
|
||||||
return
|
if (hasRegions && !region) continue
|
||||||
}
|
data.forEach((h: VacayHolidayRaw) => {
|
||||||
const map: HolidaysMap = {}
|
if (h.global || !h.counties || (region && h.counties.includes(region))) {
|
||||||
data.forEach((h: VacayHolidayRaw) => {
|
if (!map[h.date]) {
|
||||||
if (h.global || !h.counties || (region && h.counties.includes(region))) {
|
map[h.date] = { name: h.name, localName: h.localName, color: cal.color, label: cal.label }
|
||||||
map[h.date] = { name: h.name, localName: h.localName }
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
set({ holidays: map })
|
} catch { /* API error, skip */ }
|
||||||
} catch {
|
|
||||||
set({ holidays: {} })
|
|
||||||
}
|
}
|
||||||
|
set({ holidays: map })
|
||||||
|
},
|
||||||
|
|
||||||
|
addHolidayCalendar: async (data) => {
|
||||||
|
await api.addHolidayCalendar(data)
|
||||||
|
await get().loadPlan()
|
||||||
|
await get().loadHolidays()
|
||||||
|
},
|
||||||
|
|
||||||
|
updateHolidayCalendar: async (id, data) => {
|
||||||
|
await api.updateHolidayCalendar(id, data)
|
||||||
|
await get().loadPlan()
|
||||||
|
await get().loadHolidays()
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteHolidayCalendar: async (id) => {
|
||||||
|
await api.deleteHolidayCalendar(id)
|
||||||
|
await get().loadPlan()
|
||||||
|
await get().loadHolidays()
|
||||||
},
|
},
|
||||||
|
|
||||||
loadAll: async () => {
|
loadAll: async () => {
|
||||||
|
|||||||
+34
-4
@@ -8,6 +8,8 @@ export interface User {
|
|||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
maps_api_key: string | null
|
maps_api_key: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
|
/** Present after load; true when TOTP MFA is enabled for password login */
|
||||||
|
mfa_enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Trip {
|
export interface Trip {
|
||||||
@@ -116,15 +118,22 @@ export interface Reservation {
|
|||||||
trip_id: number
|
trip_id: number
|
||||||
name: string
|
name: string
|
||||||
title?: string
|
title?: string
|
||||||
type: string | null
|
type: string
|
||||||
status: 'pending' | 'confirmed'
|
status: 'pending' | 'confirmed'
|
||||||
date: string | null
|
date: string | null
|
||||||
time: string | null
|
time: string | null
|
||||||
|
reservation_time?: string | null
|
||||||
|
reservation_end_time?: string | null
|
||||||
|
location?: string | null
|
||||||
confirmation_number: string | null
|
confirmation_number: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
url: string | null
|
url: string | null
|
||||||
|
day_id?: number | null
|
||||||
|
place_id?: number | null
|
||||||
|
assignment_id?: number | null
|
||||||
accommodation_id?: number | null
|
accommodation_id?: number | null
|
||||||
metadata?: Record<string, string> | null
|
day_plan_position?: number | null
|
||||||
|
metadata?: Record<string, string> | string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +155,7 @@ export interface TripFile {
|
|||||||
deleted_at?: string | null
|
deleted_at?: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
reservation_title?: string
|
reservation_title?: string
|
||||||
|
linked_reservation_ids?: number[]
|
||||||
url?: string
|
url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +171,7 @@ export interface Settings {
|
|||||||
time_format: string
|
time_format: string
|
||||||
show_place_description: boolean
|
show_place_description: boolean
|
||||||
route_calculation?: boolean
|
route_calculation?: boolean
|
||||||
|
blur_booking_codes?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssignmentsMap {
|
export interface AssignmentsMap {
|
||||||
@@ -269,6 +280,7 @@ export interface AppConfig {
|
|||||||
oidc_display_name?: string
|
oidc_display_name?: string
|
||||||
has_maps_key?: boolean
|
has_maps_key?: boolean
|
||||||
allowed_file_types?: string
|
allowed_file_types?: string
|
||||||
|
timezone?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translation function type
|
// Translation function type
|
||||||
@@ -281,10 +293,23 @@ export interface WebSocketEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Vacay types
|
// Vacay types
|
||||||
|
export interface VacayHolidayCalendar {
|
||||||
|
id: number
|
||||||
|
plan_id: number
|
||||||
|
region: string
|
||||||
|
label: string | null
|
||||||
|
color: string
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface VacayPlan {
|
export interface VacayPlan {
|
||||||
id: number
|
id: number
|
||||||
holidays_enabled: boolean
|
holidays_enabled: boolean
|
||||||
holidays_region: string | null
|
holidays_region: string | null
|
||||||
|
holiday_calendars: VacayHolidayCalendar[]
|
||||||
|
block_weekends: boolean
|
||||||
|
carry_over_enabled: boolean
|
||||||
|
company_holidays_enabled: boolean
|
||||||
name?: string
|
name?: string
|
||||||
year?: number
|
year?: number
|
||||||
owner_id?: number
|
owner_id?: number
|
||||||
@@ -301,6 +326,9 @@ export interface VacayUser {
|
|||||||
export interface VacayEntry {
|
export interface VacayEntry {
|
||||||
date: string
|
date: string
|
||||||
user_id: number
|
user_id: number
|
||||||
|
plan_id?: number
|
||||||
|
person_color?: string
|
||||||
|
person_name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VacayStat {
|
export interface VacayStat {
|
||||||
@@ -312,6 +340,8 @@ export interface VacayStat {
|
|||||||
export interface HolidayInfo {
|
export interface HolidayInfo {
|
||||||
name: string
|
name: string
|
||||||
localName: string
|
localName: string
|
||||||
|
color: string
|
||||||
|
label: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HolidaysMap {
|
export interface HolidaysMap {
|
||||||
@@ -341,7 +371,7 @@ export function getApiErrorMessage(err: unknown, fallback: string): string {
|
|||||||
|
|
||||||
// MergedItem used in day notes hook
|
// MergedItem used in day notes hook
|
||||||
export interface MergedItem {
|
export interface MergedItem {
|
||||||
type: 'assignment' | 'note'
|
type: 'assignment' | 'note' | 'place' | 'transport'
|
||||||
sortKey: number
|
sortKey: number
|
||||||
data: Assignment | DayNote
|
data: Assignment | DayNote | Reservation
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ export function currencyDecimals(currency: string): number {
|
|||||||
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
|
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(dateStr: string | null | undefined, locale: string): string | null {
|
export function formatDate(dateStr: string | null | undefined, locale: string, timeZone?: string): string | null {
|
||||||
if (!dateStr) return null
|
if (!dateStr) return null
|
||||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
|
const opts: Intl.DateTimeFormatOptions = {
|
||||||
weekday: 'short', day: 'numeric', month: 'short',
|
weekday: 'short', day: 'numeric', month: 'short',
|
||||||
})
|
}
|
||||||
|
if (timeZone) opts.timeZone = timeZone
|
||||||
|
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {
|
export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
|||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
workbox: {
|
workbox: {
|
||||||
|
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||||
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
||||||
navigateFallback: 'index.html',
|
navigateFallback: 'index.html',
|
||||||
navigateFallbackDenylist: [/^\/api/, /^\/uploads/],
|
navigateFallbackDenylist: [/^\/api/, /^\/uploads/],
|
||||||
|
|||||||
+19
-2
@@ -1,7 +1,23 @@
|
|||||||
services:
|
services:
|
||||||
|
init-permissions:
|
||||||
|
image: alpine:3.20
|
||||||
|
container_name: trek-init-permissions
|
||||||
|
user: "0:0"
|
||||||
|
command: >
|
||||||
|
sh -c "mkdir -p /app/data /app/uploads &&
|
||||||
|
chown -R 1000:1000 /app/data /app/uploads &&
|
||||||
|
chmod -R u+rwX /app/data /app/uploads"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: mauriceboe/nomad:2.5.5
|
image: mauriceboe/trek:latest
|
||||||
container_name: nomad
|
container_name: trek
|
||||||
|
depends_on:
|
||||||
|
init-permissions:
|
||||||
|
condition: service_completed_successfully
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
@@ -9,6 +25,7 @@ services:
|
|||||||
- JWT_SECRET=${JWT_SECRET:-}
|
- JWT_SECRET=${JWT_SECRET:-}
|
||||||
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
|
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
PORT=3001
|
PORT=3001
|
||||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
DEBUG=false
|
||||||
|
|||||||
Generated
+472
-95
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-server",
|
"name": "trek-server",
|
||||||
"version": "2.6.1",
|
"version": "2.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nomad-server",
|
"name": "trek-server",
|
||||||
"version": "2.6.1",
|
"version": "2.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -16,9 +16,12 @@
|
|||||||
"express": "^4.18.3",
|
"express": "^4.18.3",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^2.1.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
|
"nodemailer": "^8.0.4",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"unzipper": "^0.12.3",
|
"unzipper": "^0.12.3",
|
||||||
@@ -30,11 +33,13 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^4.17.25",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/unzipper": "^0.10.11",
|
"@types/unzipper": "^0.10.11",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
@@ -457,6 +462,56 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@otplib/core": {
|
||||||
|
"version": "12.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
||||||
|
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/plugin-crypto": {
|
||||||
|
"version": "12.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
|
||||||
|
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
|
||||||
|
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "^12.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/plugin-thirty-two": {
|
||||||
|
"version": "12.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
|
||||||
|
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
|
||||||
|
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "^12.0.1",
|
||||||
|
"thirty-two": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/preset-default": {
|
||||||
|
"version": "12.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
|
||||||
|
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
|
||||||
|
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "^12.0.1",
|
||||||
|
"@otplib/plugin-crypto": "^12.0.1",
|
||||||
|
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/preset-v11": {
|
||||||
|
"version": "12.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
|
||||||
|
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "^12.0.1",
|
||||||
|
"@otplib/plugin-crypto": "^12.0.1",
|
||||||
|
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/archiver": {
|
"node_modules/@types/archiver": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz",
|
||||||
@@ -516,21 +571,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/express": {
|
"node_modules/@types/express": {
|
||||||
"version": "5.0.6",
|
"version": "4.17.25",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
|
||||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^5.0.0",
|
"@types/express-serve-static-core": "^4.17.33",
|
||||||
"@types/serve-static": "^2"
|
"@types/qs": "*",
|
||||||
|
"@types/serve-static": "^1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/express-serve-static-core": {
|
"node_modules/@types/express-serve-static-core": {
|
||||||
"version": "5.1.1",
|
"version": "4.19.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
|
||||||
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -558,6 +614,13 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/ms": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
@@ -592,6 +655,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "7.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||||
|
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||||
@@ -627,13 +710,25 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/serve-static": {
|
"node_modules/@types/serve-static": {
|
||||||
"version": "2.2.0",
|
"version": "1.15.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
||||||
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/http-errors": "*",
|
"@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": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -677,6 +772,30 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/anymatch": {
|
"node_modules/anymatch": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||||
@@ -959,9 +1078,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1078,6 +1197,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@@ -1109,6 +1237,35 @@
|
|||||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/compress-commons": {
|
"node_modules/compress-commons": {
|
||||||
"version": "5.0.3",
|
"version": "5.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.3.tgz",
|
||||||
@@ -1125,50 +1282,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/concat-stream": {
|
"node_modules/concat-stream": {
|
||||||
"version": "1.6.2",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
"engines": [
|
"engines": [
|
||||||
"node >= 0.8"
|
"node >= 6.0"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-from": "^1.0.0",
|
"buffer-from": "^1.0.0",
|
||||||
"inherits": "^2.0.3",
|
"inherits": "^2.0.3",
|
||||||
"readable-stream": "^2.2.2",
|
"readable-stream": "^3.0.2",
|
||||||
"typedarray": "^0.0.6"
|
"typedarray": "^0.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/concat-stream/node_modules/readable-stream": {
|
|
||||||
"version": "2.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
|
||||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"core-util-is": "~1.0.0",
|
|
||||||
"inherits": "~2.0.3",
|
|
||||||
"isarray": "~1.0.0",
|
|
||||||
"process-nextick-args": "~2.0.0",
|
|
||||||
"safe-buffer": "~5.1.1",
|
|
||||||
"string_decoder": "~1.1.1",
|
|
||||||
"util-deprecate": "~1.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/concat-stream/node_modules/safe-buffer": {
|
|
||||||
"version": "5.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/concat-stream/node_modules/string_decoder": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "~5.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -1262,6 +1389,15 @@
|
|||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decompress-response": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@@ -1314,6 +1450,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -1394,6 +1536,12 @@
|
|||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
@@ -1605,6 +1753,19 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -1672,6 +1833,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -1767,9 +1937,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/glob/node_modules/brace-expansion": {
|
"node_modules/glob/node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
@@ -1962,6 +2132,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-glob": {
|
"node_modules/is-glob": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
@@ -2094,6 +2273,18 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.23",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
@@ -2248,18 +2439,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mkdirp": {
|
|
||||||
"version": "0.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
|
||||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"minimist": "^1.2.6"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"mkdirp": "bin/cmd.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mkdirp-classic": {
|
"node_modules/mkdirp-classic": {
|
||||||
"version": "0.5.3",
|
"version": "0.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||||
@@ -2273,22 +2452,22 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "1.4.5-lts.2",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||||
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
|
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||||
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"append-field": "^1.0.0",
|
"append-field": "^1.0.0",
|
||||||
"busboy": "^1.0.0",
|
"busboy": "^1.6.0",
|
||||||
"concat-stream": "^1.5.2",
|
"concat-stream": "^2.0.0",
|
||||||
"mkdirp": "^0.5.4",
|
"type-is": "^1.6.18"
|
||||||
"object-assign": "^4.1.1",
|
|
||||||
"type-is": "^1.6.4",
|
|
||||||
"xtend": "^4.0.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6.0.0"
|
"node": ">= 10.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/napi-build-utils": {
|
"node_modules/napi-build-utils": {
|
||||||
@@ -2353,6 +2532,15 @@
|
|||||||
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
|
||||||
|
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nodemon": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.14",
|
"version": "3.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
||||||
@@ -2458,6 +2646,53 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/otplib": {
|
||||||
|
"version": "12.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
|
||||||
|
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "^12.0.1",
|
||||||
|
"@otplib/preset-default": "^12.0.1",
|
||||||
|
"@otplib/preset-v11": "^12.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -2467,16 +2702,25 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-exists": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.12",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2486,6 +2730,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prebuild-install": {
|
"node_modules/prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
@@ -2549,6 +2802,23 @@
|
|||||||
"once": "^1.3.1"
|
"once": "^1.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.2",
|
"version": "6.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||||
@@ -2633,9 +2903,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/readdir-glob/node_modules/brace-expansion": {
|
"node_modules/readdir-glob/node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
@@ -2666,6 +2936,21 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/resolve-pkg-maps": {
|
"node_modules/resolve-pkg-maps": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
@@ -2758,6 +3043,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -2931,6 +3222,32 @@
|
|||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-json-comments": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||||
@@ -3011,6 +3328,14 @@
|
|||||||
"b4a": "^1.6.4"
|
"b4a": "^1.6.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/thirty-two": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.2.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -3210,6 +3535,26 @@
|
|||||||
"webidl-conversions": "^3.0.0"
|
"webidl-conversions": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrappy": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
@@ -3237,13 +3582,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/y18n": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4"
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zip-stream": {
|
"node_modules/zip-stream": {
|
||||||
|
|||||||
+8
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.6.2",
|
"version": "2.7.1",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --import tsx src/index.ts",
|
"start": "node --import tsx src/index.ts",
|
||||||
@@ -15,9 +15,12 @@
|
|||||||
"express": "^4.18.3",
|
"express": "^4.18.3",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^2.1.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
|
"nodemailer": "^8.0.4",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"unzipper": "^0.12.3",
|
"unzipper": "^0.12.3",
|
||||||
@@ -29,11 +32,13 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^4.17.25",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/unzipper": "^0.10.11",
|
"@types/unzipper": "^0.10.11",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
|||||||
@@ -205,6 +205,195 @@ function runMigrations(db: Database.Database): void {
|
|||||||
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 accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL'); } catch {}
|
||||||
try { db.exec('ALTER TABLE reservations ADD COLUMN metadata TEXT'); } catch {}
|
try { db.exec('ALTER TABLE reservations ADD COLUMN metadata TEXT'); } catch {}
|
||||||
},
|
},
|
||||||
|
() => {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS invite_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
max_uses INTEGER NOT NULL DEFAULT 1,
|
||||||
|
used_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
expires_at TEXT,
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
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 {}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS packing_category_assignees (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
category_name TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(trip_id, category_name, user_id)
|
||||||
|
)`);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS packing_templates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS packing_template_categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
template_id INTEGER NOT NULL REFERENCES packing_templates(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
)`);
|
||||||
|
// Recreate items table with category_id FK (replaces old template_id-based schema)
|
||||||
|
try { db.exec('DROP TABLE IF EXISTS packing_template_items'); } catch {}
|
||||||
|
db.exec(`CREATE TABLE packing_template_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
category_id INTEGER NOT NULL REFERENCES packing_template_categories(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
)`);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS packing_bags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '#6366f1',
|
||||||
|
weight_limit_grams INTEGER,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`);
|
||||||
|
try { db.exec('ALTER TABLE packing_items ADD COLUMN weight_grams INTEGER'); } catch {}
|
||||||
|
try { db.exec('ALTER TABLE packing_items ADD COLUMN bag_id INTEGER REFERENCES packing_bags(id) ON DELETE SET NULL'); } catch {}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS visited_countries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
country_code TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, country_code)
|
||||||
|
)`);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS bucket_list (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
lat REAL,
|
||||||
|
lng REAL,
|
||||||
|
country_code TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Configurable weekend days
|
||||||
|
try { db.exec("ALTER TABLE vacay_plans ADD COLUMN weekend_days TEXT DEFAULT '0,6'"); } catch {}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Immich integration
|
||||||
|
try { db.exec("ALTER TABLE users ADD COLUMN immich_url TEXT"); } catch {}
|
||||||
|
try { db.exec("ALTER TABLE users ADD COLUMN immich_api_key TEXT"); } catch {}
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS trip_photos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
immich_asset_id TEXT NOT NULL,
|
||||||
|
shared INTEGER NOT NULL DEFAULT 1,
|
||||||
|
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(trip_id, user_id, immich_asset_id)
|
||||||
|
)`);
|
||||||
|
// Add memories addon
|
||||||
|
try {
|
||||||
|
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)").run('memories', 'Photos', 'trip', 'Image', 0, 7);
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Allow files to be linked to multiple reservations/assignments
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS file_links (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
file_id INTEGER NOT NULL REFERENCES trip_files(id) ON DELETE CASCADE,
|
||||||
|
reservation_id INTEGER REFERENCES reservations(id) ON DELETE CASCADE,
|
||||||
|
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE,
|
||||||
|
place_id INTEGER REFERENCES places(id) ON DELETE CASCADE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(file_id, reservation_id),
|
||||||
|
UNIQUE(file_id, assignment_id),
|
||||||
|
UNIQUE(file_id, place_id)
|
||||||
|
)`);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Add day_plan_position to reservations for persistent transport ordering in day timeline
|
||||||
|
try { db.exec('ALTER TABLE reservations ADD COLUMN day_plan_position REAL DEFAULT NULL'); } catch {}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Add paid_by_user_id to budget_items for expense tracking / settlement
|
||||||
|
try { db.exec('ALTER TABLE budget_items ADD COLUMN paid_by_user_id INTEGER REFERENCES users(id)'); } catch {}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Add target_date to bucket_list for optional visit planning
|
||||||
|
try { db.exec('ALTER TABLE bucket_list ADD COLUMN target_date TEXT DEFAULT NULL'); } catch {}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Notification preferences per user
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
notify_trip_invite INTEGER DEFAULT 1,
|
||||||
|
notify_booking_change INTEGER DEFAULT 1,
|
||||||
|
notify_trip_reminder INTEGER DEFAULT 1,
|
||||||
|
notify_vacay_invite INTEGER DEFAULT 1,
|
||||||
|
notify_photos_shared INTEGER DEFAULT 1,
|
||||||
|
notify_collab_message INTEGER DEFAULT 1,
|
||||||
|
notify_packing_tagged INTEGER DEFAULT 1,
|
||||||
|
notify_webhook INTEGER DEFAULT 0,
|
||||||
|
UNIQUE(user_id)
|
||||||
|
)`);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Add missing notification preference columns for existing tables
|
||||||
|
try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_vacay_invite INTEGER DEFAULT 1'); } catch {}
|
||||||
|
try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_photos_shared INTEGER DEFAULT 1'); } catch {}
|
||||||
|
try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_collab_message INTEGER DEFAULT 1'); } catch {}
|
||||||
|
try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_packing_tagged INTEGER DEFAULT 1'); } catch {}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Public share links for read-only trip access
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS share_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
share_map INTEGER DEFAULT 1,
|
||||||
|
share_bookings INTEGER DEFAULT 1,
|
||||||
|
share_packing INTEGER DEFAULT 0,
|
||||||
|
share_budget INTEGER DEFAULT 0,
|
||||||
|
share_collab INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Add permission columns to share_tokens
|
||||||
|
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_map INTEGER DEFAULT 1'); } catch {}
|
||||||
|
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_bookings INTEGER DEFAULT 1'); } catch {}
|
||||||
|
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_packing INTEGER DEFAULT 0'); } catch {}
|
||||||
|
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_budget INTEGER DEFAULT 0'); } catch {}
|
||||||
|
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_collab INTEGER DEFAULT 0'); } catch {}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Audit log
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
resource TEXT,
|
||||||
|
details TEXT,
|
||||||
|
ip TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
|
||||||
|
`);
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ function createTables(db: Database.Database): void {
|
|||||||
oidc_sub TEXT,
|
oidc_sub TEXT,
|
||||||
oidc_issuer TEXT,
|
oidc_issuer TEXT,
|
||||||
last_login DATETIME,
|
last_login DATETIME,
|
||||||
|
mfa_enabled INTEGER DEFAULT 0,
|
||||||
|
mfa_secret TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -281,6 +283,15 @@ function createTables(db: Database.Database): void {
|
|||||||
UNIQUE(plan_id, date)
|
UNIQUE(plan_id, date)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vacay_holiday_calendars (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
|
||||||
|
region TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
color TEXT NOT NULL DEFAULT '#fecaca',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS day_accommodations (
|
CREATE TABLE IF NOT EXISTS day_accommodations (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
@@ -369,6 +380,17 @@ function createTables(db: Database.Database): void {
|
|||||||
UNIQUE(assignment_id, user_id)
|
UNIQUE(assignment_id, user_id)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id);
|
CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
resource TEXT,
|
||||||
|
details TEXT,
|
||||||
|
ip TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+58
-5
@@ -6,6 +6,7 @@ import path from 'path';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
const DEBUG = String(process.env.DEBUG || 'false').toLowerCase() === 'true';
|
||||||
|
|
||||||
// Trust first proxy (nginx/Docker) for correct req.ip
|
// Trust first proxy (nginx/Docker) for correct req.ip
|
||||||
if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
|
if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
|
||||||
@@ -44,6 +45,8 @@ if (allowedOrigins) {
|
|||||||
corsOrigin = true;
|
corsOrigin = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
|
||||||
|
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: corsOrigin,
|
origin: corsOrigin,
|
||||||
credentials: true
|
credentials: true
|
||||||
@@ -60,12 +63,15 @@ app.use(helmet({
|
|||||||
objectSrc: ["'self'"],
|
objectSrc: ["'self'"],
|
||||||
frameSrc: ["'self'"],
|
frameSrc: ["'self'"],
|
||||||
frameAncestors: ["'self'"],
|
frameAncestors: ["'self'"],
|
||||||
|
upgradeInsecureRequests: shouldForceHttps ? [] : null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
crossOriginEmbedderPolicy: false,
|
crossOriginEmbedderPolicy: false,
|
||||||
|
hsts: shouldForceHttps ? { maxAge: 31536000, includeSubDomains: false } : false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Redirect HTTP to HTTPS (opt-in via FORCE_HTTPS=true)
|
// Redirect HTTP to HTTPS (opt-in via FORCE_HTTPS=true)
|
||||||
if (process.env.FORCE_HTTPS === 'true') {
|
if (shouldForceHttps) {
|
||||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
|
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
|
||||||
res.redirect(301, 'https://' + req.headers.host + req.url);
|
res.redirect(301, 'https://' + req.headers.host + req.url);
|
||||||
@@ -74,10 +80,40 @@ if (process.env.FORCE_HTTPS === 'true') {
|
|||||||
app.use(express.json({ limit: '100kb' }));
|
app.use(express.json({ limit: '100kb' }));
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
// Avatars are public (shown on login, sharing screens)
|
if (DEBUG) {
|
||||||
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const requestId = Math.random().toString(36).slice(2, 10);
|
||||||
|
const redact = (value: unknown): unknown => {
|
||||||
|
if (!value || typeof value !== 'object') return value;
|
||||||
|
if (Array.isArray(value)) return value.map(redact);
|
||||||
|
const hidden = new Set(['password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code']);
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
out[k] = hidden.has(k.toLowerCase()) ? '[REDACTED]' : redact(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
// All other uploads require authentication
|
const safeQuery = redact(req.query);
|
||||||
|
const safeBody = redact(req.body);
|
||||||
|
console.log(`[DEBUG][REQ ${requestId}] ${req.method} ${req.originalUrl} ip=${req.ip} query=${JSON.stringify(safeQuery)} body=${JSON.stringify(safeBody)}`);
|
||||||
|
|
||||||
|
res.on('finish', () => {
|
||||||
|
const elapsedMs = Date.now() - startedAt;
|
||||||
|
console.log(`[DEBUG][RES ${requestId}] ${req.method} ${req.originalUrl} status=${res.statusCode} elapsed_ms=${elapsedMs}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avatars are public (shown on login, sharing screens)
|
||||||
|
import { authenticate } from './middleware/auth';
|
||||||
|
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
||||||
|
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
|
||||||
|
|
||||||
|
// Serve uploaded files (UUIDs are unguessable, path traversal protected)
|
||||||
app.get('/uploads/:type/:filename', (req: Request, res: Response) => {
|
app.get('/uploads/:type/:filename', (req: Request, res: Response) => {
|
||||||
const { type, filename } = req.params;
|
const { type, filename } = req.params;
|
||||||
const allowedTypes = ['covers', 'files', 'photos'];
|
const allowedTypes = ['covers', 'files', 'photos'];
|
||||||
@@ -147,17 +183,33 @@ import vacayRoutes from './routes/vacay';
|
|||||||
app.use('/api/addons/vacay', vacayRoutes);
|
app.use('/api/addons/vacay', vacayRoutes);
|
||||||
import atlasRoutes from './routes/atlas';
|
import atlasRoutes from './routes/atlas';
|
||||||
app.use('/api/addons/atlas', atlasRoutes);
|
app.use('/api/addons/atlas', atlasRoutes);
|
||||||
|
import immichRoutes from './routes/immich';
|
||||||
|
app.use('/api/integrations/immich', immichRoutes);
|
||||||
|
|
||||||
app.use('/api/maps', mapsRoutes);
|
app.use('/api/maps', mapsRoutes);
|
||||||
app.use('/api/weather', weatherRoutes);
|
app.use('/api/weather', weatherRoutes);
|
||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
app.use('/api/backup', backupRoutes);
|
app.use('/api/backup', backupRoutes);
|
||||||
|
|
||||||
|
import notificationRoutes from './routes/notifications';
|
||||||
|
app.use('/api/notifications', notificationRoutes);
|
||||||
|
|
||||||
|
import shareRoutes from './routes/share';
|
||||||
|
app.use('/api', shareRoutes);
|
||||||
|
|
||||||
// Serve static files in production
|
// Serve static files in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
const publicPath = path.join(__dirname, '../public');
|
const publicPath = path.join(__dirname, '../public');
|
||||||
app.use(express.static(publicPath));
|
app.use(express.static(publicPath, {
|
||||||
|
setHeaders: (res, filePath) => {
|
||||||
|
// Never cache index.html so version updates are picked up immediately
|
||||||
|
if (filePath.endsWith('index.html')) {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
app.get('*', (req: Request, res: Response) => {
|
app.get('*', (req: Request, res: Response) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
res.sendFile(path.join(publicPath, 'index.html'));
|
res.sendFile(path.join(publicPath, 'index.html'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -174,6 +226,7 @@ const PORT = process.env.PORT || 3001;
|
|||||||
const server = app.listen(PORT, () => {
|
const server = app.listen(PORT, () => {
|
||||||
console.log(`TREK API running on port ${PORT}`);
|
console.log(`TREK API running on port ${PORT}`);
|
||||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||||
|
console.log(`Debug logs: ${DEBUG ? 'ENABLED' : 'disabled'}`);
|
||||||
if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED');
|
if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED');
|
||||||
if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') {
|
if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') {
|
||||||
console.warn('[SECURITY WARNING] DEMO_MODE is enabled in production! Demo credentials are publicly exposed.');
|
console.warn('[SECURITY WARNING] DEMO_MODE is enabled in production! Demo credentials are publicly exposed.');
|
||||||
|
|||||||
+319
-6
@@ -1,16 +1,23 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { authenticate, adminOnly } from '../middleware/auth';
|
import { authenticate, adminOnly } from '../middleware/auth';
|
||||||
import { AuthRequest, User, Addon } from '../types';
|
import { AuthRequest, User, Addon } from '../types';
|
||||||
|
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.use(authenticate, adminOnly);
|
router.use(authenticate, adminOnly);
|
||||||
|
|
||||||
|
function utcSuffix(ts: string | null | undefined): string | null {
|
||||||
|
if (!ts) return null;
|
||||||
|
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/users', (req: Request, res: Response) => {
|
router.get('/users', (req: Request, res: Response) => {
|
||||||
const users = db.prepare(
|
const users = db.prepare(
|
||||||
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
|
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
|
||||||
@@ -20,7 +27,13 @@ router.get('/users', (req: Request, res: Response) => {
|
|||||||
const { getOnlineUserIds } = require('../websocket');
|
const { getOnlineUserIds } = require('../websocket');
|
||||||
onlineUserIds = getOnlineUserIds();
|
onlineUserIds = getOnlineUserIds();
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
const usersWithStatus = users.map(u => ({ ...u, online: onlineUserIds.has(u.id) }));
|
const usersWithStatus = users.map(u => ({
|
||||||
|
...u,
|
||||||
|
created_at: utcSuffix(u.created_at),
|
||||||
|
updated_at: utcSuffix(u.updated_at as string),
|
||||||
|
last_login: utcSuffix(u.last_login),
|
||||||
|
online: onlineUserIds.has(u.id),
|
||||||
|
}));
|
||||||
res.json({ users: usersWithStatus });
|
res.json({ users: usersWithStatus });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,6 +64,14 @@ router.post('/users', (req: Request, res: Response) => {
|
|||||||
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
|
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
|
||||||
).get(result.lastInsertRowid);
|
).get(result.lastInsertRowid);
|
||||||
|
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
writeAudit({
|
||||||
|
userId: authReq.user.id,
|
||||||
|
action: 'admin.user_create',
|
||||||
|
resource: String(result.lastInsertRowid),
|
||||||
|
ip: getClientIp(req),
|
||||||
|
details: { username: username.trim(), email: email.trim(), role: role || 'user' },
|
||||||
|
});
|
||||||
res.status(201).json({ user });
|
res.status(201).json({ user });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,12 +110,25 @@ router.put('/users/:id', (req: Request, res: Response) => {
|
|||||||
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
|
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
|
||||||
).get(req.params.id);
|
).get(req.params.id);
|
||||||
|
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const changed: string[] = [];
|
||||||
|
if (username) changed.push('username');
|
||||||
|
if (email) changed.push('email');
|
||||||
|
if (role) changed.push('role');
|
||||||
|
if (password) changed.push('password');
|
||||||
|
writeAudit({
|
||||||
|
userId: authReq.user.id,
|
||||||
|
action: 'admin.user_update',
|
||||||
|
resource: String(req.params.id),
|
||||||
|
ip: getClientIp(req),
|
||||||
|
details: { fields: changed },
|
||||||
|
});
|
||||||
res.json({ user: updated });
|
res.json({ user: updated });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/users/:id', (req: Request, res: Response) => {
|
router.delete('/users/:id', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
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' });
|
return res.status(400).json({ error: 'Cannot delete own account' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +136,12 @@ router.delete('/users/:id', (req: Request, res: Response) => {
|
|||||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
||||||
|
writeAudit({
|
||||||
|
userId: authReq.user.id,
|
||||||
|
action: 'admin.user_delete',
|
||||||
|
resource: String(req.params.id),
|
||||||
|
ip: getClientIp(req),
|
||||||
|
});
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,6 +154,48 @@ router.get('/stats', (_req: Request, res: Response) => {
|
|||||||
res.json({ totalUsers, totalTrips, totalPlaces, totalFiles });
|
res.json({ totalUsers, totalTrips, totalPlaces, totalFiles });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/audit-log', (req: Request, res: Response) => {
|
||||||
|
const limitRaw = parseInt(String(req.query.limit || '100'), 10);
|
||||||
|
const offsetRaw = parseInt(String(req.query.offset || '0'), 10);
|
||||||
|
const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500);
|
||||||
|
const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0);
|
||||||
|
type Row = {
|
||||||
|
id: number;
|
||||||
|
created_at: string;
|
||||||
|
user_id: number | null;
|
||||||
|
username: string | null;
|
||||||
|
user_email: string | null;
|
||||||
|
action: string;
|
||||||
|
resource: string | null;
|
||||||
|
details: string | null;
|
||||||
|
ip: string | null;
|
||||||
|
};
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
|
||||||
|
FROM audit_log a
|
||||||
|
LEFT JOIN users u ON u.id = a.user_id
|
||||||
|
ORDER BY a.id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(limit, offset) as Row[];
|
||||||
|
const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c;
|
||||||
|
res.json({
|
||||||
|
entries: rows.map((r) => {
|
||||||
|
let details: Record<string, unknown> | null = null;
|
||||||
|
if (r.details) {
|
||||||
|
try {
|
||||||
|
details = JSON.parse(r.details) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
details = { _parse_error: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...r, details };
|
||||||
|
}),
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/oidc', (_req: Request, res: Response) => {
|
router.get('/oidc', (_req: Request, res: Response) => {
|
||||||
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || '';
|
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || '';
|
||||||
const secret = get('oidc_client_secret');
|
const secret = get('oidc_client_secret');
|
||||||
@@ -122,26 +204,37 @@ router.get('/oidc', (_req: Request, res: Response) => {
|
|||||||
client_id: get('oidc_client_id'),
|
client_id: get('oidc_client_id'),
|
||||||
client_secret_set: !!secret,
|
client_secret_set: !!secret,
|
||||||
display_name: get('oidc_display_name'),
|
display_name: get('oidc_display_name'),
|
||||||
|
oidc_only: get('oidc_only') === 'true',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/oidc', (req: Request, res: Response) => {
|
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 || '');
|
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_issuer', issuer);
|
||||||
set('oidc_client_id', client_id);
|
set('oidc_client_id', client_id);
|
||||||
if (client_secret !== undefined) set('oidc_client_secret', client_secret);
|
if (client_secret !== undefined) set('oidc_client_secret', client_secret);
|
||||||
set('oidc_display_name', display_name);
|
set('oidc_display_name', display_name);
|
||||||
|
set('oidc_only', oidc_only ? 'true' : 'false');
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
writeAudit({
|
||||||
|
userId: authReq.user.id,
|
||||||
|
action: 'admin.oidc_update',
|
||||||
|
ip: getClientIp(req),
|
||||||
|
details: { oidc_only: !!oidc_only, issuer_set: !!issuer },
|
||||||
|
});
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/save-demo-baseline', (_req: Request, res: Response) => {
|
router.post('/save-demo-baseline', (req: Request, res: Response) => {
|
||||||
if (process.env.DEMO_MODE !== 'true') {
|
if (process.env.DEMO_MODE !== 'true') {
|
||||||
return res.status(404).json({ error: 'Not found' });
|
return res.status(404).json({ error: 'Not found' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { saveBaseline } = require('../demo/demo-reset');
|
const { saveBaseline } = require('../demo/demo-reset');
|
||||||
saveBaseline();
|
saveBaseline();
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
|
||||||
res.json({ success: true, message: 'Demo baseline saved. Hourly resets will restore to this state.' });
|
res.json({ success: true, message: 'Demo baseline saved. Hourly resets will restore to this state.' });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -166,11 +259,26 @@ function compareVersions(a: string, b: string): number {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
router.get('/github-releases', async (req: Request, res: Response) => {
|
||||||
|
const { per_page = '10', page = '1' } = req.query;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`https://api.github.com/repos/mauriceboe/TREK/releases?per_page=${per_page}&page=${page}`,
|
||||||
|
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
|
||||||
|
);
|
||||||
|
if (!resp.ok) return res.json([]);
|
||||||
|
const data = await resp.json();
|
||||||
|
res.json(Array.isArray(data) ? data : []);
|
||||||
|
} catch {
|
||||||
|
res.json([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/version-check', async (_req: Request, res: Response) => {
|
router.get('/version-check', async (_req: Request, res: Response) => {
|
||||||
const { version: currentVersion } = require('../../package.json');
|
const { version: currentVersion } = require('../../package.json');
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
'https://api.github.com/repos/mauriceboe/NOMAD/releases/latest',
|
'https://api.github.com/repos/mauriceboe/TREK/releases/latest',
|
||||||
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-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 });
|
if (!resp.ok) return res.json({ current: currentVersion, latest: currentVersion, update_available: false });
|
||||||
@@ -183,7 +291,7 @@ router.get('/version-check', async (_req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/update', async (_req: Request, res: Response) => {
|
router.post('/update', async (req: Request, res: Response) => {
|
||||||
const rootDir = path.resolve(__dirname, '../../..');
|
const rootDir = path.resolve(__dirname, '../../..');
|
||||||
const serverDir = path.resolve(__dirname, '../..');
|
const serverDir = path.resolve(__dirname, '../..');
|
||||||
const clientDir = path.join(rootDir, 'client');
|
const clientDir = path.join(rootDir, 'client');
|
||||||
@@ -206,6 +314,13 @@ router.post('/update', async (_req: Request, res: Response) => {
|
|||||||
const { version: newVersion } = require('../../package.json');
|
const { version: newVersion } = require('../../package.json');
|
||||||
steps.push({ step: 'version', version: newVersion });
|
steps.push({ step: 'version', version: newVersion });
|
||||||
|
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
writeAudit({
|
||||||
|
userId: authReq.user.id,
|
||||||
|
action: 'admin.system_update',
|
||||||
|
resource: newVersion,
|
||||||
|
ip: getClientIp(req),
|
||||||
|
});
|
||||||
res.json({ success: true, steps, restarting: true });
|
res.json({ success: true, steps, restarting: true });
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -219,6 +334,196 @@ router.post('/update', async (_req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Invite Tokens ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/invites', (_req: Request, res: Response) => {
|
||||||
|
const invites = db.prepare(`
|
||||||
|
SELECT i.*, u.username as created_by_name
|
||||||
|
FROM invite_tokens i
|
||||||
|
JOIN users u ON i.created_by = u.id
|
||||||
|
ORDER BY i.created_at DESC
|
||||||
|
`).all();
|
||||||
|
res.json({ invites });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/invites', (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { max_uses, expires_in_days } = req.body;
|
||||||
|
|
||||||
|
const rawUses = parseInt(max_uses);
|
||||||
|
const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5);
|
||||||
|
const token = crypto.randomBytes(16).toString('hex');
|
||||||
|
const expiresAt = expires_in_days
|
||||||
|
? new Date(Date.now() + parseInt(expires_in_days) * 86400000).toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const ins = db.prepare(
|
||||||
|
'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(token, uses, expiresAt, authReq.user.id);
|
||||||
|
|
||||||
|
const inviteId = Number(ins.lastInsertRowid);
|
||||||
|
const invite = db.prepare(`
|
||||||
|
SELECT i.*, u.username as created_by_name
|
||||||
|
FROM invite_tokens i
|
||||||
|
JOIN users u ON i.created_by = u.id
|
||||||
|
WHERE i.id = ?
|
||||||
|
`).get(inviteId);
|
||||||
|
|
||||||
|
writeAudit({
|
||||||
|
userId: authReq.user.id,
|
||||||
|
action: 'admin.invite_create',
|
||||||
|
resource: String(inviteId),
|
||||||
|
ip: getClientIp(req),
|
||||||
|
details: { max_uses: uses, expires_in_days: expires_in_days ?? null },
|
||||||
|
});
|
||||||
|
res.status(201).json({ invite });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/invites/:id', (req: Request, res: Response) => {
|
||||||
|
const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(req.params.id);
|
||||||
|
if (!invite) return res.status(404).json({ error: 'Invite not found' });
|
||||||
|
db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(req.params.id);
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
writeAudit({
|
||||||
|
userId: authReq.user.id,
|
||||||
|
action: 'admin.invite_delete',
|
||||||
|
resource: String(req.params.id),
|
||||||
|
ip: getClientIp(req),
|
||||||
|
});
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Bag Tracking Setting ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/bag-tracking', (_req: Request, res: Response) => {
|
||||||
|
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as { value: string } | undefined;
|
||||||
|
res.json({ enabled: row?.value === 'true' });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/bag-tracking', (req: Request, res: Response) => {
|
||||||
|
const { enabled } = req.body;
|
||||||
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false');
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
writeAudit({
|
||||||
|
userId: authReq.user.id,
|
||||||
|
action: 'admin.bag_tracking',
|
||||||
|
ip: getClientIp(req),
|
||||||
|
details: { enabled: !!enabled },
|
||||||
|
});
|
||||||
|
res.json({ enabled: !!enabled });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Packing Templates ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/packing-templates', (_req: Request, res: Response) => {
|
||||||
|
const templates = db.prepare(`
|
||||||
|
SELECT pt.*, u.username as created_by_name,
|
||||||
|
(SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count,
|
||||||
|
(SELECT COUNT(*) FROM packing_template_categories WHERE template_id = pt.id) as category_count
|
||||||
|
FROM packing_templates pt
|
||||||
|
JOIN users u ON pt.created_by = u.id
|
||||||
|
ORDER BY pt.created_at DESC
|
||||||
|
`).all();
|
||||||
|
res.json({ templates });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/packing-templates/:id', (_req: Request, res: Response) => {
|
||||||
|
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(_req.params.id);
|
||||||
|
if (!template) return res.status(404).json({ error: 'Template not found' });
|
||||||
|
const categories = db.prepare('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id').all(_req.params.id) as any[];
|
||||||
|
const items = db.prepare(`
|
||||||
|
SELECT ti.* FROM packing_template_items ti
|
||||||
|
JOIN packing_template_categories tc ON ti.category_id = tc.id
|
||||||
|
WHERE tc.template_id = ? ORDER BY ti.sort_order, ti.id
|
||||||
|
`).all(_req.params.id);
|
||||||
|
res.json({ template, categories, items });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/packing-templates', (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { name } = req.body;
|
||||||
|
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
|
||||||
|
const result = db.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run(name.trim(), authReq.user.id);
|
||||||
|
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
res.status(201).json({ template });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/packing-templates/:id', (req: Request, res: Response) => {
|
||||||
|
const { name } = req.body;
|
||||||
|
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id);
|
||||||
|
if (!template) return res.status(404).json({ error: 'Template not found' });
|
||||||
|
if (name?.trim()) db.prepare('UPDATE packing_templates SET name = ? WHERE id = ?').run(name.trim(), req.params.id);
|
||||||
|
res.json({ template: db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/packing-templates/:id', (req: Request, res: Response) => {
|
||||||
|
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id);
|
||||||
|
if (!template) return res.status(404).json({ error: 'Template not found' });
|
||||||
|
db.prepare('DELETE FROM packing_templates WHERE id = ?').run(req.params.id);
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const t = template as { name?: string };
|
||||||
|
writeAudit({
|
||||||
|
userId: authReq.user.id,
|
||||||
|
action: 'admin.packing_template_delete',
|
||||||
|
resource: String(req.params.id),
|
||||||
|
ip: getClientIp(req),
|
||||||
|
details: { name: t.name },
|
||||||
|
});
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Template categories
|
||||||
|
router.post('/packing-templates/:id/categories', (req: Request, res: Response) => {
|
||||||
|
const { name } = req.body;
|
||||||
|
if (!name?.trim()) return res.status(400).json({ error: 'Category name is required' });
|
||||||
|
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id);
|
||||||
|
if (!template) return res.status(404).json({ error: 'Template not found' });
|
||||||
|
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_categories WHERE template_id = ?').get(req.params.id) as { max: number | null };
|
||||||
|
const result = db.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)').run(req.params.id, name.trim(), (maxOrder.max ?? -1) + 1);
|
||||||
|
res.status(201).json({ category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(result.lastInsertRowid) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/packing-templates/:templateId/categories/:catId', (req: Request, res: Response) => {
|
||||||
|
const { name } = req.body;
|
||||||
|
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(req.params.catId, req.params.templateId);
|
||||||
|
if (!cat) return res.status(404).json({ error: 'Category not found' });
|
||||||
|
if (name?.trim()) db.prepare('UPDATE packing_template_categories SET name = ? WHERE id = ?').run(name.trim(), req.params.catId);
|
||||||
|
res.json({ category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(req.params.catId) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/packing-templates/:templateId/categories/:catId', (_req: Request, res: Response) => {
|
||||||
|
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(_req.params.catId, _req.params.templateId);
|
||||||
|
if (!cat) return res.status(404).json({ error: 'Category not found' });
|
||||||
|
db.prepare('DELETE FROM packing_template_categories WHERE id = ?').run(_req.params.catId);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Template items
|
||||||
|
router.post('/packing-templates/:templateId/categories/:catId/items', (req: Request, res: Response) => {
|
||||||
|
const { name } = req.body;
|
||||||
|
if (!name?.trim()) return res.status(400).json({ error: 'Item name is required' });
|
||||||
|
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(req.params.catId, req.params.templateId);
|
||||||
|
if (!cat) return res.status(404).json({ error: 'Category not found' });
|
||||||
|
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_items WHERE category_id = ?').get(req.params.catId) as { max: number | null };
|
||||||
|
const result = db.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(req.params.catId, name.trim(), (maxOrder.max ?? -1) + 1);
|
||||||
|
res.status(201).json({ item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(result.lastInsertRowid) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/packing-templates/:templateId/items/:itemId', (req: Request, res: Response) => {
|
||||||
|
const { name } = req.body;
|
||||||
|
const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(req.params.itemId);
|
||||||
|
if (!item) return res.status(404).json({ error: 'Item not found' });
|
||||||
|
if (name?.trim()) db.prepare('UPDATE packing_template_items SET name = ? WHERE id = ?').run(name.trim(), req.params.itemId);
|
||||||
|
res.json({ item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(req.params.itemId) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/packing-templates/:templateId/items/:itemId', (_req: Request, res: Response) => {
|
||||||
|
const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(_req.params.itemId);
|
||||||
|
if (!item) return res.status(404).json({ error: 'Item not found' });
|
||||||
|
db.prepare('DELETE FROM packing_template_items WHERE id = ?').run(_req.params.itemId);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/addons', (_req: Request, res: Response) => {
|
router.get('/addons', (_req: Request, res: Response) => {
|
||||||
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
|
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
|
||||||
res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })) });
|
res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })) });
|
||||||
@@ -231,6 +536,14 @@ router.put('/addons/:id', (req: Request, res: Response) => {
|
|||||||
if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id);
|
if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id);
|
||||||
if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id);
|
if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id);
|
||||||
const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon;
|
const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon;
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
writeAudit({
|
||||||
|
userId: authReq.user.id,
|
||||||
|
action: 'admin.addon_update',
|
||||||
|
resource: String(req.params.id),
|
||||||
|
ip: getClientIp(req),
|
||||||
|
details: { enabled: enabled !== undefined ? !!enabled : undefined, config_changed: config !== undefined },
|
||||||
|
});
|
||||||
res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } });
|
res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ router.use(authenticate);
|
|||||||
const COUNTRY_BOXES: Record<string, [number, number, number, number]> = {
|
const COUNTRY_BOXES: Record<string, [number, number, number, number]> = {
|
||||||
AF:[60.5,29.4,75,38.5],AL:[19,39.6,21.1,42.7],DZ:[-8.7,19,12,37.1],AD:[1.4,42.4,1.8,42.7],AO:[11.7,-18.1,24.1,-4.4],
|
AF:[60.5,29.4,75,38.5],AL:[19,39.6,21.1,42.7],DZ:[-8.7,19,12,37.1],AD:[1.4,42.4,1.8,42.7],AO:[11.7,-18.1,24.1,-4.4],
|
||||||
AR:[-73.6,-55.1,-53.6,-21.8],AM:[43.4,38.8,46.6,41.3],AU:[112.9,-43.6,153.6,-10.7],AT:[9.5,46.4,17.2,49],AZ:[44.8,38.4,50.4,41.9],
|
AR:[-73.6,-55.1,-53.6,-21.8],AM:[43.4,38.8,46.6,41.3],AU:[112.9,-43.6,153.6,-10.7],AT:[9.5,46.4,17.2,49],AZ:[44.8,38.4,50.4,41.9],
|
||||||
BR:[-73.9,-33.8,-34.8,5.3],BE:[2.5,49.5,6.4,51.5],BG:[22.4,41.2,28.6,44.2],CA:[-141,41.7,-52.6,83.1],CL:[-75.6,-55.9,-66.9,-17.5],
|
BD:[88.0,20.7,92.7,26.6],BR:[-73.9,-33.8,-34.8,5.3],BE:[2.5,49.5,6.4,51.5],BG:[22.4,41.2,28.6,44.2],CA:[-141,41.7,-52.6,83.1],CL:[-75.6,-55.9,-66.9,-17.5],
|
||||||
CN:[73.6,18.2,134.8,53.6],CO:[-79.1,-4.3,-66.9,12.5],HR:[13.5,42.4,19.5,46.6],CZ:[12.1,48.6,18.9,51.1],DK:[8,54.6,15.2,57.8],
|
CN:[73.6,18.2,134.8,53.6],CO:[-79.1,-4.3,-66.9,12.5],HR:[13.5,42.4,19.5,46.6],CZ:[12.1,48.6,18.9,51.1],DK:[8,54.6,15.2,57.8],
|
||||||
EG:[24.7,22,37,31.7],EE:[21.8,57.5,28.2,59.7],FI:[20.6,59.8,31.6,70.1],FR:[-5.1,41.3,9.6,51.1],DE:[5.9,47.3,15.1,55.1],
|
EG:[24.7,22,37,31.7],EE:[21.8,57.5,28.2,59.7],FI:[20.6,59.8,31.6,70.1],FR:[-5.1,41.3,9.6,51.1],DE:[5.9,47.3,15.1,55.1],
|
||||||
GR:[19.4,34.8,29.7,41.8],HU:[16,45.7,22.9,48.6],IS:[-24.5,63.4,-13.5,66.6],IN:[68.2,6.7,97.4,35.5],ID:[95.3,-11,141,5.9],
|
GR:[19.4,34.8,29.7,41.8],HU:[16,45.7,22.9,48.6],IS:[-24.5,63.4,-13.5,66.6],IN:[68.2,6.7,97.4,35.5],ID:[95.3,-11,141,5.9],
|
||||||
@@ -96,7 +96,10 @@ router.get('/stats', (req: Request, res: Response) => {
|
|||||||
|
|
||||||
const tripIds = trips.map(t => t.id);
|
const tripIds = trips.map(t => t.id);
|
||||||
if (tripIds.length === 0) {
|
if (tripIds.length === 0) {
|
||||||
return res.json({ countries: [], trips: [], stats: { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 } });
|
// Still include manually marked countries even without trips
|
||||||
|
const manualCountries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(userId) as { country_code: string }[];
|
||||||
|
const countries = manualCountries.map(mc => ({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }));
|
||||||
|
return res.json({ countries, trips: [], stats: { totalTrips: 0, totalPlaces: 0, totalCountries: countries.length, totalDays: 0 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeholders = tripIds.map(() => '?').join(',');
|
const placeholders = tripIds.map(() => '?').join(',');
|
||||||
@@ -153,6 +156,14 @@ router.get('/stats', (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
const totalCities = citySet.size;
|
const totalCities = citySet.size;
|
||||||
|
|
||||||
|
// Merge manually marked countries
|
||||||
|
const manualCountries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(userId) as { country_code: string }[];
|
||||||
|
for (const mc of manualCountries) {
|
||||||
|
if (!countries.find(c => c.code === mc.country_code)) {
|
||||||
|
countries.push({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mostVisited = countries.length > 0 ? countries.reduce((a, b) => a.placeCount > b.placeCount ? a : b) : null;
|
const mostVisited = countries.length > 0 ? countries.reduce((a, b) => a.placeCount > b.placeCount ? a : b) : null;
|
||||||
|
|
||||||
const continents: Record<string, number> = {};
|
const continents: Record<string, number> = {};
|
||||||
@@ -239,7 +250,72 @@ router.get('/country/:code', (req: Request, res: Response) => {
|
|||||||
|
|
||||||
const matchingTrips = trips.filter(t => matchingTripIds.has(t.id)).map(t => ({ id: t.id, title: t.title, start_date: t.start_date, end_date: t.end_date }));
|
const matchingTrips = trips.filter(t => matchingTripIds.has(t.id)).map(t => ({ id: t.id, title: t.title, start_date: t.start_date, end_date: t.end_date }));
|
||||||
|
|
||||||
res.json({ places: matchingPlaces, trips: matchingTrips });
|
const isManuallyMarked = !!(db.prepare('SELECT 1 FROM visited_countries WHERE user_id = ? AND country_code = ?').get(userId, code));
|
||||||
|
res.json({ places: matchingPlaces, trips: matchingTrips, manually_marked: isManuallyMarked });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark/unmark country as visited
|
||||||
|
router.post('/country/:code/mark', (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(authReq.user.id, req.params.code.toUpperCase());
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/country/:code/mark', (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(authReq.user.id, req.params.code.toUpperCase());
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Bucket List ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/bucket-list', (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const items = db.prepare('SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC').all(authReq.user.id);
|
||||||
|
res.json({ items });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/bucket-list', (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { name, lat, lng, country_code, notes, target_date } = req.body;
|
||||||
|
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
|
||||||
|
const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
|
||||||
|
authReq.user.id, name.trim(), lat ?? null, lng ?? null, country_code ?? null, notes ?? null, target_date ?? null
|
||||||
|
);
|
||||||
|
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
res.status(201).json({ item });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/bucket-list/:id', (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { name, notes, lat, lng, country_code, target_date } = req.body;
|
||||||
|
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(req.params.id, authReq.user.id);
|
||||||
|
if (!item) return res.status(404).json({ error: 'Item not found' });
|
||||||
|
db.prepare(`UPDATE bucket_list SET
|
||||||
|
name = COALESCE(?, name),
|
||||||
|
notes = CASE WHEN ? THEN ? ELSE notes END,
|
||||||
|
lat = CASE WHEN ? THEN ? ELSE lat END,
|
||||||
|
lng = CASE WHEN ? THEN ? ELSE lng END,
|
||||||
|
country_code = CASE WHEN ? THEN ? ELSE country_code END,
|
||||||
|
target_date = CASE WHEN ? THEN ? ELSE target_date END
|
||||||
|
WHERE id = ?`).run(
|
||||||
|
name?.trim() || null,
|
||||||
|
notes !== undefined ? 1 : 0, notes !== undefined ? (notes || null) : null,
|
||||||
|
lat !== undefined ? 1 : 0, lat !== undefined ? (lat || null) : null,
|
||||||
|
lng !== undefined ? 1 : 0, lng !== undefined ? (lng || null) : null,
|
||||||
|
country_code !== undefined ? 1 : 0, country_code !== undefined ? (country_code || null) : null,
|
||||||
|
target_date !== undefined ? 1 : 0, target_date !== undefined ? (target_date || null) : null,
|
||||||
|
req.params.id
|
||||||
|
);
|
||||||
|
res.json({ item: db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(req.params.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/bucket-list/:id', (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(req.params.id, authReq.user.id);
|
||||||
|
if (!item) return res.status(404).json({ error: 'Item not found' });
|
||||||
|
db.prepare('DELETE FROM bucket_list WHERE id = ?').run(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
+259
-40
@@ -6,10 +6,51 @@ import path from 'path';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
|
import { authenticator } from 'otplib';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { authenticate, demoUploadBlock } from '../middleware/auth';
|
import { authenticate, demoUploadBlock } from '../middleware/auth';
|
||||||
import { JWT_SECRET } from '../config';
|
import { JWT_SECRET } from '../config';
|
||||||
|
import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto';
|
||||||
import { AuthRequest, User } from '../types';
|
import { AuthRequest, User } from '../types';
|
||||||
|
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||||
|
|
||||||
|
authenticator.options = { window: 1 };
|
||||||
|
|
||||||
|
const MFA_SETUP_TTL_MS = 15 * 60 * 1000;
|
||||||
|
const mfaSetupPending = new Map<number, { secret: string; exp: number }>();
|
||||||
|
|
||||||
|
function getPendingMfaSecret(userId: number): string | null {
|
||||||
|
const row = mfaSetupPending.get(userId);
|
||||||
|
if (!row || Date.now() > row.exp) {
|
||||||
|
mfaSetupPending.delete(userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return row.secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcSuffix(ts: string | null | undefined): string | null {
|
||||||
|
if (!ts) return null;
|
||||||
|
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripUserForClient(user: User): Record<string, unknown> {
|
||||||
|
const {
|
||||||
|
password_hash: _p,
|
||||||
|
maps_api_key: _m,
|
||||||
|
openweather_api_key: _o,
|
||||||
|
unsplash_api_key: _u,
|
||||||
|
mfa_secret: _mf,
|
||||||
|
...rest
|
||||||
|
} = user;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
created_at: utcSuffix(rest.created_at),
|
||||||
|
updated_at: utcSuffix(rest.updated_at),
|
||||||
|
last_login: utcSuffix(rest.last_login),
|
||||||
|
mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -59,6 +100,17 @@ function rateLimiter(maxAttempts: number, windowMs: number) {
|
|||||||
}
|
}
|
||||||
const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW);
|
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 = process.env.OIDC_ONLY === 'true' || 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 {
|
function maskKey(key: string | null | undefined): string | null {
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
if (key.length <= 8) return '--------';
|
if (key.length <= 8) return '--------';
|
||||||
@@ -89,6 +141,8 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
|||||||
(process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").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)
|
(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 = process.env.OIDC_ONLY || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value;
|
||||||
|
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
|
||||||
res.json({
|
res.json({
|
||||||
allow_registration: isDemo ? false : allowRegistration,
|
allow_registration: isDemo ? false : allowRegistration,
|
||||||
has_users: userCount > 0,
|
has_users: userCount > 0,
|
||||||
@@ -96,10 +150,12 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
|||||||
has_maps_key: hasGoogleKey,
|
has_maps_key: hasGoogleKey,
|
||||||
oidc_configured: oidcConfigured,
|
oidc_configured: oidcConfigured,
|
||||||
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
|
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',
|
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_mode: isDemo,
|
||||||
demo_email: isDemo ? 'demo@trek.app' : undefined,
|
demo_email: isDemo ? 'demo@trek.app' : undefined,
|
||||||
demo_password: isDemo ? 'demo12345' : undefined,
|
demo_password: isDemo ? 'demo12345' : undefined,
|
||||||
|
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,15 +166,37 @@ router.post('/demo-login', (_req: Request, res: Response) => {
|
|||||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@trek.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' });
|
if (!user) return res.status(500).json({ error: 'Demo user not found' });
|
||||||
const token = generateToken(user);
|
const token = generateToken(user);
|
||||||
const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...safe } = user;
|
const safe = stripUserForClient(user) as Record<string, unknown>;
|
||||||
res.json({ token, user: { ...safe, avatar_url: avatarUrl(user) } });
|
res.json({ token, user: { ...safe, avatar_url: avatarUrl(user) } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Validate invite token (public, no auth needed, rate limited)
|
||||||
|
router.get('/invite/:token', authLimiter, (req: Request, res: Response) => {
|
||||||
|
const invite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(req.params.token) as any;
|
||||||
|
if (!invite) return res.status(404).json({ error: 'Invalid invite link' });
|
||||||
|
if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return res.status(410).json({ error: 'Invite link has been fully used' });
|
||||||
|
if (invite.expires_at && new Date(invite.expires_at) < new Date()) return res.status(410).json({ error: 'Invite link has expired' });
|
||||||
|
res.json({ valid: true, max_uses: invite.max_uses, used_count: invite.used_count, expires_at: invite.expires_at });
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/register', authLimiter, (req: Request, res: Response) => {
|
router.post('/register', authLimiter, (req: Request, res: Response) => {
|
||||||
const { username, email, password } = req.body;
|
const { username, email, password, invite_token } = req.body;
|
||||||
|
|
||||||
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
||||||
if (userCount > 0) {
|
|
||||||
|
// Check invite token first — valid token bypasses registration restrictions
|
||||||
|
let validInvite: any = null;
|
||||||
|
if (invite_token) {
|
||||||
|
validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(invite_token);
|
||||||
|
if (!validInvite) return res.status(400).json({ error: 'Invalid invite link' });
|
||||||
|
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) return res.status(410).json({ error: 'Invite link has been fully used' });
|
||||||
|
if (validInvite.expires_at && new Date(validInvite.expires_at) < new Date()) return res.status(410).json({ error: 'Invite link has expired' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userCount > 0 && !validInvite) {
|
||||||
|
if (isOidcOnlyMode()) {
|
||||||
|
return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' });
|
||||||
|
}
|
||||||
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
|
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
|
||||||
if (setting?.value === 'false') {
|
if (setting?.value === 'false') {
|
||||||
return res.status(403).json({ error: 'Registration is disabled. Contact your administrator.' });
|
return res.status(403).json({ error: 'Registration is disabled. Contact your administrator.' });
|
||||||
@@ -157,9 +235,20 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
|
|||||||
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
|
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
|
||||||
).run(username, email, password_hash, role);
|
).run(username, email, password_hash, role);
|
||||||
|
|
||||||
const user = { id: result.lastInsertRowid, username, email, role, avatar: null };
|
const user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false };
|
||||||
const token = generateToken(user);
|
const token = generateToken(user);
|
||||||
|
|
||||||
|
// Atomically increment invite token usage (prevents race condition)
|
||||||
|
if (validInvite) {
|
||||||
|
const updated = db.prepare(
|
||||||
|
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses) RETURNING used_count'
|
||||||
|
).get(validInvite.id);
|
||||||
|
if (!updated) {
|
||||||
|
// Race condition: token was used up between check and now — user was already created, so just log it
|
||||||
|
console.warn(`[Auth] Invite token ${validInvite.token.slice(0, 8)}... exceeded max_uses due to race condition`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.status(201).json({ token, user: { ...user, avatar_url: null } });
|
res.status(201).json({ token, user: { ...user, avatar_url: null } });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
res.status(500).json({ error: 'Error creating user' });
|
res.status(500).json({ error: 'Error creating user' });
|
||||||
@@ -167,6 +256,10 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/login', 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;
|
const { email, password } = req.body;
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
@@ -183,28 +276,41 @@ router.post('/login', authLimiter, (req: Request, res: Response) => {
|
|||||||
return res.status(401).json({ error: 'Invalid email or password' });
|
return res.status(401).json({ error: 'Invalid email or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.mfa_enabled === 1 || user.mfa_enabled === true) {
|
||||||
|
const mfa_token = jwt.sign(
|
||||||
|
{ id: Number(user.id), purpose: 'mfa_login' },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '5m' }
|
||||||
|
);
|
||||||
|
return res.json({ mfa_required: true, mfa_token });
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
||||||
const token = generateToken(user);
|
const token = generateToken(user);
|
||||||
const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...userWithoutSensitive } = user;
|
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||||
|
|
||||||
res.json({ token, user: { ...userWithoutSensitive, avatar_url: avatarUrl(user) } });
|
res.json({ token, user: { ...userSafe, avatar_url: avatarUrl(user) } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/me', authenticate, (req: Request, res: Response) => {
|
router.get('/me', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const user = db.prepare(
|
const user = db.prepare(
|
||||||
'SELECT id, username, email, role, avatar, oidc_issuer, created_at FROM users WHERE id = ?'
|
'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled FROM users WHERE id = ?'
|
||||||
).get(authReq.user.id) as User | undefined;
|
).get(authReq.user.id) as User | undefined;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(404).json({ error: 'User not found' });
|
return res.status(404).json({ error: 'User not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ user: { ...user, avatar_url: avatarUrl(user) } });
|
const base = stripUserForClient(user as User) as Record<string, unknown>;
|
||||||
|
res.json({ user: { ...base, avatar_url: avatarUrl(user) } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
|
router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
|
if (isOidcOnlyMode()) {
|
||||||
|
return res.status(403).json({ error: 'Password authentication is disabled.' });
|
||||||
|
}
|
||||||
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@trek.app') {
|
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@trek.app') {
|
||||||
return res.status(403).json({ error: 'Password change is disabled in demo mode.' });
|
return res.status(403).json({ error: 'Password change is disabled in demo mode.' });
|
||||||
}
|
}
|
||||||
@@ -267,10 +373,11 @@ router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const updated = db.prepare(
|
const updated = db.prepare(
|
||||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?'
|
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, mfa_enabled FROM users WHERE id = ?'
|
||||||
).get(authReq.user.id) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar'> | undefined;
|
).get(authReq.user.id) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | undefined;
|
||||||
|
|
||||||
res.json({ success: true, user: { ...updated, maps_api_key: maskKey(updated?.maps_api_key), openweather_api_key: maskKey(updated?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
|
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
|
||||||
|
res.json({ success: true, user: { ...u, maps_api_key: maskKey(u?.maps_api_key), openweather_api_key: maskKey(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/me/settings', authenticate, (req: Request, res: Response) => {
|
router.put('/me/settings', authenticate, (req: Request, res: Response) => {
|
||||||
@@ -314,10 +421,11 @@ router.put('/me/settings', authenticate, (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = db.prepare(
|
const updated = db.prepare(
|
||||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?'
|
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, mfa_enabled FROM users WHERE id = ?'
|
||||||
).get(authReq.user.id) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar'> | undefined;
|
).get(authReq.user.id) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | undefined;
|
||||||
|
|
||||||
res.json({ success: true, user: { ...updated, maps_api_key: maskKey(updated?.maps_api_key), openweather_api_key: maskKey(updated?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
|
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
|
||||||
|
res.json({ success: true, user: { ...u, maps_api_key: maskKey(u?.maps_api_key), openweather_api_key: maskKey(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/me/settings', authenticate, (req: Request, res: Response) => {
|
router.get('/me/settings', authenticate, (req: Request, res: Response) => {
|
||||||
@@ -408,18 +516,43 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ADMIN_SETTINGS_KEYS = ['allow_registration', 'allowed_file_types', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'notification_webhook_url', 'app_url'];
|
||||||
|
|
||||||
|
router.get('/app-settings', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
|
||||||
|
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const key of ADMIN_SETTINGS_KEYS) {
|
||||||
|
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined;
|
||||||
|
if (row) result[key] = key === 'smtp_pass' ? '••••••••' : row.value;
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
router.put('/app-settings', authenticate, (req: Request, res: Response) => {
|
router.put('/app-settings', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
|
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
|
||||||
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
|
||||||
const { allow_registration, allowed_file_types } = req.body;
|
for (const key of ADMIN_SETTINGS_KEYS) {
|
||||||
if (allow_registration !== undefined) {
|
if (req.body[key] !== undefined) {
|
||||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration));
|
const val = String(req.body[key]);
|
||||||
}
|
// Don't save masked password
|
||||||
if (allowed_file_types !== undefined) {
|
if (key === 'smtp_pass' && val === '••••••••') continue;
|
||||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', ?)").run(String(allowed_file_types));
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
writeAudit({
|
||||||
|
userId: authReq.user.id,
|
||||||
|
action: 'settings.app_update',
|
||||||
|
ip: getClientIp(req),
|
||||||
|
details: {
|
||||||
|
allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : undefined,
|
||||||
|
allowed_file_types_changed: allowed_file_types !== undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -497,30 +630,116 @@ router.get('/travel-stats', authenticate, (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// GitHub releases proxy (cached, avoids client-side rate limits)
|
router.post('/mfa/verify-login', authLimiter, (req: Request, res: Response) => {
|
||||||
let releasesCache: { data: unknown[]; fetchedAt: number } | null = null;
|
const { mfa_token, code } = req.body as { mfa_token?: string; code?: string };
|
||||||
const RELEASES_CACHE_TTL = 30 * 60 * 1000;
|
if (!mfa_token || !code) {
|
||||||
|
return res.status(400).json({ error: 'Verification token and code are required' });
|
||||||
router.get('/github-releases', authenticate, async (req: Request, res: Response) => {
|
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
|
||||||
const perPage = Math.min(parseInt(req.query.per_page as string) || 10, 30);
|
|
||||||
|
|
||||||
if (page === 1 && releasesCache && Date.now() - releasesCache.fetchedAt < RELEASES_CACHE_TTL) {
|
|
||||||
return res.json(releasesCache.data.slice(0, perPage));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const decoded = jwt.verify(mfa_token, JWT_SECRET) as { id: number; purpose?: string };
|
||||||
`https://api.github.com/repos/mauriceboe/NOMAD/releases?per_page=${perPage}&page=${page}`,
|
if (decoded.purpose !== 'mfa_login') {
|
||||||
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
|
return res.status(401).json({ error: 'Invalid verification token' });
|
||||||
);
|
}
|
||||||
if (!resp.ok) return res.json([]);
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id) as User | undefined;
|
||||||
const data = await resp.json();
|
if (!user || !(user.mfa_enabled === 1 || user.mfa_enabled === true) || !user.mfa_secret) {
|
||||||
if (page === 1) releasesCache = { data, fetchedAt: Date.now() };
|
return res.status(401).json({ error: 'Invalid session' });
|
||||||
res.json(data);
|
}
|
||||||
|
const secret = decryptMfaSecret(user.mfa_secret);
|
||||||
|
const tokenStr = String(code).replace(/\s/g, '');
|
||||||
|
const ok = authenticator.verify({ token: tokenStr, secret });
|
||||||
|
if (!ok) {
|
||||||
|
return res.status(401).json({ error: 'Invalid verification code' });
|
||||||
|
}
|
||||||
|
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
||||||
|
const sessionToken = generateToken(user);
|
||||||
|
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||||
|
res.json({ token: sessionToken, user: { ...userSafe, avatar_url: avatarUrl(user) } });
|
||||||
} catch {
|
} catch {
|
||||||
res.status(500).json({ error: 'Failed to fetch releases' });
|
return res.status(401).json({ error: 'Invalid or expired verification token' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/mfa/setup', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') {
|
||||||
|
return res.status(403).json({ error: 'MFA is not available in demo mode.' });
|
||||||
|
}
|
||||||
|
const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(authReq.user.id) as { mfa_enabled: number } | undefined;
|
||||||
|
if (row?.mfa_enabled) {
|
||||||
|
return res.status(400).json({ error: 'MFA is already enabled' });
|
||||||
|
}
|
||||||
|
let secret: string, otpauth_url: string;
|
||||||
|
try {
|
||||||
|
secret = authenticator.generateSecret();
|
||||||
|
mfaSetupPending.set(authReq.user.id, { secret, exp: Date.now() + MFA_SETUP_TTL_MS });
|
||||||
|
otpauth_url = authenticator.keyuri(authReq.user.email, 'TREK', secret);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MFA] Setup error:', err);
|
||||||
|
return res.status(500).json({ error: 'MFA setup failed' });
|
||||||
|
}
|
||||||
|
QRCode.toDataURL(otpauth_url)
|
||||||
|
.then((qr_data_url: string) => {
|
||||||
|
res.json({ secret, otpauth_url, qr_data_url });
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error('[MFA] QR code generation error:', err);
|
||||||
|
res.status(500).json({ error: 'Could not generate QR code' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/mfa/enable', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { code } = req.body as { code?: string };
|
||||||
|
if (!code) {
|
||||||
|
return res.status(400).json({ error: 'Verification code is required' });
|
||||||
|
}
|
||||||
|
const pending = getPendingMfaSecret(authReq.user.id);
|
||||||
|
if (!pending) {
|
||||||
|
return res.status(400).json({ error: 'No MFA setup in progress. Start the setup again.' });
|
||||||
|
}
|
||||||
|
const tokenStr = String(code).replace(/\s/g, '');
|
||||||
|
const ok = authenticator.verify({ token: tokenStr, secret: pending });
|
||||||
|
if (!ok) {
|
||||||
|
return res.status(401).json({ error: 'Invalid verification code' });
|
||||||
|
}
|
||||||
|
const enc = encryptMfaSecret(pending);
|
||||||
|
db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
|
||||||
|
enc,
|
||||||
|
authReq.user.id
|
||||||
|
);
|
||||||
|
mfaSetupPending.delete(authReq.user.id);
|
||||||
|
writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
|
||||||
|
res.json({ success: true, mfa_enabled: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/mfa/disable', 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') {
|
||||||
|
return res.status(403).json({ error: 'MFA cannot be changed in demo mode.' });
|
||||||
|
}
|
||||||
|
const { password, code } = req.body as { password?: string; code?: string };
|
||||||
|
if (!password || !code) {
|
||||||
|
return res.status(400).json({ error: 'Password and authenticator code are required' });
|
||||||
|
}
|
||||||
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(authReq.user.id) as User | undefined;
|
||||||
|
if (!user?.mfa_enabled || !user.mfa_secret) {
|
||||||
|
return res.status(400).json({ error: 'MFA is not enabled' });
|
||||||
|
}
|
||||||
|
if (!user.password_hash || !bcrypt.compareSync(password, user.password_hash)) {
|
||||||
|
return res.status(401).json({ error: 'Incorrect password' });
|
||||||
|
}
|
||||||
|
const secret = decryptMfaSecret(user.mfa_secret);
|
||||||
|
const tokenStr = String(code).replace(/\s/g, '');
|
||||||
|
const ok = authenticator.verify({ token: tokenStr, secret });
|
||||||
|
if (!ok) {
|
||||||
|
return res.status(401).json({ error: 'Invalid verification code' });
|
||||||
|
}
|
||||||
|
db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
|
||||||
|
authReq.user.id
|
||||||
|
);
|
||||||
|
mfaSetupPending.delete(authReq.user.id);
|
||||||
|
writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
|
||||||
|
res.json({ success: true, mfa_enabled: false });
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
+69
-15
@@ -7,6 +7,10 @@ import fs from 'fs';
|
|||||||
import { authenticate, adminOnly } from '../middleware/auth';
|
import { authenticate, adminOnly } from '../middleware/auth';
|
||||||
import * as scheduler from '../scheduler';
|
import * as scheduler from '../scheduler';
|
||||||
import { db, closeDb, reinitialize } from '../db/database';
|
import { db, closeDb, reinitialize } from '../db/database';
|
||||||
|
import { AuthRequest } from '../types';
|
||||||
|
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||||
|
|
||||||
|
type RestoreAuditInfo = { userId: number; ip: string | null; source: 'backup.restore' | 'backup.upload_restore'; label: string };
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -103,6 +107,14 @@ router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (_req: Re
|
|||||||
});
|
});
|
||||||
|
|
||||||
const stat = fs.statSync(outputPath);
|
const stat = fs.statSync(outputPath);
|
||||||
|
const authReq = _req as AuthRequest;
|
||||||
|
writeAudit({
|
||||||
|
userId: authReq.user.id,
|
||||||
|
action: 'backup.create',
|
||||||
|
resource: filename,
|
||||||
|
ip: getClientIp(_req),
|
||||||
|
details: { size: stat.size },
|
||||||
|
});
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
backup: {
|
backup: {
|
||||||
@@ -134,7 +146,7 @@ router.get('/download/:filename', (req: Request, res: Response) => {
|
|||||||
res.download(filePath, filename);
|
res.download(filePath, filename);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function restoreFromZip(zipPath: string, res: Response) {
|
async function restoreFromZip(zipPath: string, res: Response, audit?: RestoreAuditInfo) {
|
||||||
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
|
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
|
||||||
try {
|
try {
|
||||||
await fs.createReadStream(zipPath)
|
await fs.createReadStream(zipPath)
|
||||||
@@ -174,6 +186,14 @@ async function restoreFromZip(zipPath: string, res: Response) {
|
|||||||
|
|
||||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
if (audit) {
|
||||||
|
writeAudit({
|
||||||
|
userId: audit.userId,
|
||||||
|
action: audit.source,
|
||||||
|
resource: audit.label,
|
||||||
|
ip: audit.ip,
|
||||||
|
});
|
||||||
|
}
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Restore error:', err);
|
console.error('Restore error:', err);
|
||||||
@@ -191,7 +211,13 @@ router.post('/restore/:filename', async (req: Request, res: Response) => {
|
|||||||
if (!fs.existsSync(zipPath)) {
|
if (!fs.existsSync(zipPath)) {
|
||||||
return res.status(404).json({ error: 'Backup not found' });
|
return res.status(404).json({ error: 'Backup not found' });
|
||||||
}
|
}
|
||||||
await restoreFromZip(zipPath, res);
|
const authReq = req as AuthRequest;
|
||||||
|
await restoreFromZip(zipPath, res, {
|
||||||
|
userId: authReq.user.id,
|
||||||
|
ip: getClientIp(req),
|
||||||
|
source: 'backup.restore',
|
||||||
|
label: filename,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadTmp = multer({
|
const uploadTmp = multer({
|
||||||
@@ -206,23 +232,43 @@ const uploadTmp = multer({
|
|||||||
router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => {
|
router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
const zipPath = req.file.path;
|
const zipPath = req.file.path;
|
||||||
await restoreFromZip(zipPath, res);
|
const authReq = req as AuthRequest;
|
||||||
|
const origName = req.file.originalname || 'upload.zip';
|
||||||
|
await restoreFromZip(zipPath, res, {
|
||||||
|
userId: authReq.user.id,
|
||||||
|
ip: getClientIp(req),
|
||||||
|
source: 'backup.upload_restore',
|
||||||
|
label: origName,
|
||||||
|
});
|
||||||
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
|
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/auto-settings', (_req: Request, res: Response) => {
|
router.get('/auto-settings', (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
res.json({ settings: scheduler.loadSettings() });
|
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||||
|
res.json({ settings: scheduler.loadSettings(), timezone: tz });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('[backup] GET auto-settings:', err);
|
console.error('[backup] GET auto-settings:', err);
|
||||||
res.status(500).json({ error: 'Could not load backup settings' });
|
res.status(500).json({ error: 'Could not load backup settings' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function parseIntField(raw: unknown, fallback: number): number {
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
|
||||||
|
if (typeof raw === 'string' && raw.trim() !== '') {
|
||||||
|
const n = parseInt(raw, 10);
|
||||||
|
if (Number.isFinite(n)) return n;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function parseAutoBackupBody(body: Record<string, unknown>): {
|
function parseAutoBackupBody(body: Record<string, unknown>): {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
interval: string;
|
interval: string;
|
||||||
keep_days: number;
|
keep_days: number;
|
||||||
|
hour: number;
|
||||||
|
day_of_week: number;
|
||||||
|
day_of_month: number;
|
||||||
} {
|
} {
|
||||||
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
|
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
|
||||||
const rawInterval = body.interval;
|
const rawInterval = body.interval;
|
||||||
@@ -230,17 +276,11 @@ function parseAutoBackupBody(body: Record<string, unknown>): {
|
|||||||
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval)
|
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval)
|
||||||
? rawInterval
|
? rawInterval
|
||||||
: 'daily';
|
: 'daily';
|
||||||
const rawKeep = body.keep_days;
|
const keep_days = Math.max(0, parseIntField(body.keep_days, 7));
|
||||||
let keepNum: number;
|
const hour = Math.min(23, Math.max(0, parseIntField(body.hour, 2)));
|
||||||
if (typeof rawKeep === 'number' && Number.isFinite(rawKeep)) {
|
const day_of_week = Math.min(6, Math.max(0, parseIntField(body.day_of_week, 0)));
|
||||||
keepNum = Math.floor(rawKeep);
|
const day_of_month = Math.min(28, Math.max(1, parseIntField(body.day_of_month, 1)));
|
||||||
} else if (typeof rawKeep === 'string' && rawKeep.trim() !== '') {
|
return { enabled, interval, keep_days, hour, day_of_week, day_of_month };
|
||||||
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) => {
|
router.put('/auto-settings', (req: Request, res: Response) => {
|
||||||
@@ -248,6 +288,13 @@ router.put('/auto-settings', (req: Request, res: Response) => {
|
|||||||
const settings = parseAutoBackupBody((req.body || {}) as Record<string, unknown>);
|
const settings = parseAutoBackupBody((req.body || {}) as Record<string, unknown>);
|
||||||
scheduler.saveSettings(settings);
|
scheduler.saveSettings(settings);
|
||||||
scheduler.start();
|
scheduler.start();
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
writeAudit({
|
||||||
|
userId: authReq.user.id,
|
||||||
|
action: 'backup.auto_settings',
|
||||||
|
ip: getClientIp(req),
|
||||||
|
details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days },
|
||||||
|
});
|
||||||
res.json({ settings });
|
res.json({ settings });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('[backup] PUT auto-settings:', err);
|
console.error('[backup] PUT auto-settings:', err);
|
||||||
@@ -272,6 +319,13 @@ router.delete('/:filename', (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs.unlinkSync(filePath);
|
fs.unlinkSync(filePath);
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
writeAudit({
|
||||||
|
userId: authReq.user.id,
|
||||||
|
action: 'backup.delete',
|
||||||
|
resource: filename,
|
||||||
|
ip: getClientIp(req),
|
||||||
|
});
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,77 @@ router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Respon
|
|||||||
broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id'] as string);
|
broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id'] as string);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Settlement calculation: who owes whom
|
||||||
|
router.get('/settlement', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
|
||||||
|
const allMembers = db.prepare(`
|
||||||
|
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
|
||||||
|
FROM budget_item_members bm
|
||||||
|
JOIN users u ON bm.user_id = u.id
|
||||||
|
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
||||||
|
`).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
|
||||||
|
|
||||||
|
// Calculate net balance per user: positive = is owed money, negative = owes money
|
||||||
|
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const members = allMembers.filter(m => m.budget_item_id === item.id);
|
||||||
|
if (members.length === 0) continue;
|
||||||
|
|
||||||
|
const payers = members.filter(m => m.paid);
|
||||||
|
if (payers.length === 0) continue; // no one marked as paid
|
||||||
|
|
||||||
|
const sharePerMember = item.total_price / members.length;
|
||||||
|
const paidPerPayer = item.total_price / payers.length;
|
||||||
|
|
||||||
|
for (const m of members) {
|
||||||
|
if (!balances[m.user_id]) {
|
||||||
|
balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 };
|
||||||
|
}
|
||||||
|
// Everyone owes their share
|
||||||
|
balances[m.user_id].balance -= sharePerMember;
|
||||||
|
// Payers get credited what they paid
|
||||||
|
if (m.paid) balances[m.user_id].balance += paidPerPayer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate optimized payment flows (greedy algorithm)
|
||||||
|
const people = Object.values(balances).filter(b => Math.abs(b.balance) > 0.01);
|
||||||
|
const debtors = people.filter(p => p.balance < -0.01).map(p => ({ ...p, amount: -p.balance }));
|
||||||
|
const creditors = people.filter(p => p.balance > 0.01).map(p => ({ ...p, amount: p.balance }));
|
||||||
|
|
||||||
|
// Sort by amount descending for efficient matching
|
||||||
|
debtors.sort((a, b) => b.amount - a.amount);
|
||||||
|
creditors.sort((a, b) => b.amount - a.amount);
|
||||||
|
|
||||||
|
const flows: { from: { user_id: number; username: string; avatar_url: string | null }; to: { user_id: number; username: string; avatar_url: string | null }; amount: number }[] = [];
|
||||||
|
|
||||||
|
let di = 0, ci = 0;
|
||||||
|
while (di < debtors.length && ci < creditors.length) {
|
||||||
|
const transfer = Math.min(debtors[di].amount, creditors[ci].amount);
|
||||||
|
if (transfer > 0.01) {
|
||||||
|
flows.push({
|
||||||
|
from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url },
|
||||||
|
to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url },
|
||||||
|
amount: Math.round(transfer * 100) / 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
debtors[di].amount -= transfer;
|
||||||
|
creditors[ci].amount -= transfer;
|
||||||
|
if (debtors[di].amount < 0.01) di++;
|
||||||
|
if (creditors[ci].amount < 0.01) ci++;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
|
||||||
|
flows,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params;
|
||||||
|
|||||||
@@ -419,6 +419,13 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
|
|||||||
const formatted = formatMessage(message);
|
const formatted = formatMessage(message);
|
||||||
res.status(201).json({ message: formatted });
|
res.status(201).json({ message: formatted });
|
||||||
broadcast(tripId, 'collab:message:created', { message: formatted }, req.headers['x-socket-id'] as string);
|
broadcast(tripId, 'collab:message:created', { message: formatted }, req.headers['x-socket-id'] as string);
|
||||||
|
|
||||||
|
// Notify trip members about new chat message
|
||||||
|
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||||
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
|
const preview = text.trim().length > 80 ? text.trim().substring(0, 80) + '...' : text.trim();
|
||||||
|
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, preview }).catch(() => {});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/messages/:id/react', authenticate, (req: Request, res: Response) => {
|
router.post('/messages/:id/react', authenticate, (req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -82,7 +82,27 @@ router.get('/', authenticate, (req: Request, res: Response) => {
|
|||||||
|
|
||||||
const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL';
|
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[];
|
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) });
|
|
||||||
|
// Get all file_links for this trip's files
|
||||||
|
const fileIds = files.map(f => f.id);
|
||||||
|
let linksMap: Record<number, number[]> = {};
|
||||||
|
if (fileIds.length > 0) {
|
||||||
|
const placeholders = fileIds.map(() => '?').join(',');
|
||||||
|
const links = db.prepare(`SELECT file_id, reservation_id, place_id FROM file_links WHERE file_id IN (${placeholders})`).all(...fileIds) as { file_id: number; reservation_id: number | null; place_id: number | null }[];
|
||||||
|
for (const link of links) {
|
||||||
|
if (!linksMap[link.file_id]) linksMap[link.file_id] = [];
|
||||||
|
linksMap[link.file_id].push(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ files: files.map(f => {
|
||||||
|
const fileLinks = linksMap[f.id] || [];
|
||||||
|
return {
|
||||||
|
...formatFile(f),
|
||||||
|
linked_reservation_ids: fileLinks.filter(l => l.reservation_id).map(l => l.reservation_id),
|
||||||
|
linked_place_ids: fileLinks.filter(l => l.place_id).map(l => l.place_id),
|
||||||
|
};
|
||||||
|
})});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload file
|
// Upload file
|
||||||
@@ -239,4 +259,55 @@ router.delete('/trash/empty', authenticate, (req: Request, res: Response) => {
|
|||||||
res.json({ success: true, deleted: trashed.length });
|
res.json({ success: true, deleted: trashed.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Link a file to a reservation (many-to-many)
|
||||||
|
router.post('/:id/link', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId, id } = req.params;
|
||||||
|
const { reservation_id, assignment_id, place_id } = req.body;
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO file_links (file_id, reservation_id, assignment_id, place_id) VALUES (?, ?, ?, ?)').run(
|
||||||
|
id, reservation_id || null, assignment_id || null, place_id || null
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const links = db.prepare('SELECT * FROM file_links WHERE file_id = ?').all(id);
|
||||||
|
res.json({ success: true, links });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unlink a file from a reservation
|
||||||
|
router.delete('/:id/link/:linkId', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId, id, linkId } = req.params;
|
||||||
|
|
||||||
|
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM file_links WHERE id = ? AND file_id = ?').run(linkId, id);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all links for a file
|
||||||
|
router.get('/:id/links', 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 links = db.prepare(`
|
||||||
|
SELECT fl.*, r.title as reservation_title
|
||||||
|
FROM file_links fl
|
||||||
|
LEFT JOIN reservations r ON fl.reservation_id = r.id
|
||||||
|
WHERE fl.file_id = ?
|
||||||
|
`).all(id);
|
||||||
|
res.json({ links });
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import { db } from '../db/database';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { broadcast } from '../websocket';
|
||||||
|
import { AuthRequest } from '../types';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ── Immich Connection Settings ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/settings', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||||
|
res.json({
|
||||||
|
immich_url: user?.immich_url || '',
|
||||||
|
connected: !!(user?.immich_url && user?.immich_api_key),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/settings', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { immich_url, immich_api_key } = req.body;
|
||||||
|
db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
|
||||||
|
immich_url?.trim() || null,
|
||||||
|
immich_api_key?.trim() || null,
|
||||||
|
authReq.user.id
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/status', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||||
|
if (!user?.immich_url || !user?.immich_api_key) {
|
||||||
|
return res.json({ connected: false, error: 'Not configured' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${user.immich_url}/api/users/me`, {
|
||||||
|
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` });
|
||||||
|
const data = await resp.json() as { name?: string; email?: string };
|
||||||
|
res.json({ connected: true, user: { name: data.name, email: data.email } });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Browse Immich Library (for photo picker) ────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/browse', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { page = '1', size = '50' } = req.query;
|
||||||
|
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||||
|
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${user.immich_url}/api/timeline/buckets`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch from Immich' });
|
||||||
|
const buckets = await resp.json();
|
||||||
|
res.json({ buckets });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
res.status(502).json({ error: 'Could not reach Immich' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search photos by date range (for the date-filter in picker)
|
||||||
|
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { from, to } = req.body;
|
||||||
|
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||||
|
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Paginate through all results (Immich limits per-page to 1000)
|
||||||
|
const allAssets: any[] = [];
|
||||||
|
let page = 1;
|
||||||
|
const pageSize = 1000;
|
||||||
|
while (true) {
|
||||||
|
const resp = await fetch(`${user.immich_url}/api/search/metadata`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'x-api-key': user.immich_api_key, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
|
||||||
|
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
|
||||||
|
type: 'IMAGE',
|
||||||
|
size: pageSize,
|
||||||
|
page,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return res.status(resp.status).json({ error: 'Search failed' });
|
||||||
|
const data = await resp.json() as { assets?: { items?: any[] } };
|
||||||
|
const items = data.assets?.items || [];
|
||||||
|
allAssets.push(...items);
|
||||||
|
if (items.length < pageSize) break; // Last page
|
||||||
|
page++;
|
||||||
|
if (page > 20) break; // Safety limit (20k photos max)
|
||||||
|
}
|
||||||
|
const assets = allAssets.map((a: any) => ({
|
||||||
|
id: a.id,
|
||||||
|
takenAt: a.fileCreatedAt || a.createdAt,
|
||||||
|
city: a.exifInfo?.city || null,
|
||||||
|
country: a.exifInfo?.country || null,
|
||||||
|
}));
|
||||||
|
res.json({ assets });
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: 'Could not reach Immich' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Trip Photos (selected by user) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
// Get all photos for a trip (own + shared by others)
|
||||||
|
router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
|
||||||
|
const photos = db.prepare(`
|
||||||
|
SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at,
|
||||||
|
u.username, u.avatar, u.immich_url
|
||||||
|
FROM trip_photos tp
|
||||||
|
JOIN users u ON tp.user_id = u.id
|
||||||
|
WHERE tp.trip_id = ?
|
||||||
|
AND (tp.user_id = ? OR tp.shared = 1)
|
||||||
|
ORDER BY tp.added_at ASC
|
||||||
|
`).all(tripId, authReq.user.id);
|
||||||
|
|
||||||
|
res.json({ photos });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add photos to a trip
|
||||||
|
router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const { asset_ids, shared = true } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(asset_ids) || asset_ids.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'asset_ids required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const insert = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
let added = 0;
|
||||||
|
for (const assetId of asset_ids) {
|
||||||
|
const result = insert.run(tripId, authReq.user.id, assetId, shared ? 1 : 0);
|
||||||
|
if (result.changes > 0) added++;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, added });
|
||||||
|
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||||
|
|
||||||
|
// Notify trip members about shared photos
|
||||||
|
if (shared && added > 0) {
|
||||||
|
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||||
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
|
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, count: String(added) }).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove a photo from a trip (own photos only)
|
||||||
|
router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
|
||||||
|
.run(req.params.tripId, authReq.user.id, req.params.assetId);
|
||||||
|
res.json({ success: true });
|
||||||
|
broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle sharing for a specific photo
|
||||||
|
router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { shared } = req.body;
|
||||||
|
db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
|
||||||
|
.run(shared ? 1 : 0, req.params.tripId, authReq.user.id, req.params.assetId);
|
||||||
|
res.json({ success: true });
|
||||||
|
broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Asset Details ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { assetId } = req.params;
|
||||||
|
const { userId } = req.query;
|
||||||
|
|
||||||
|
const targetUserId = userId ? Number(userId) : authReq.user.id;
|
||||||
|
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
|
||||||
|
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).json({ error: 'Not found' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${user.immich_url}/api/assets/${assetId}`, {
|
||||||
|
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed' });
|
||||||
|
const asset = await resp.json() as any;
|
||||||
|
res.json({
|
||||||
|
id: asset.id,
|
||||||
|
takenAt: asset.fileCreatedAt || asset.createdAt,
|
||||||
|
width: asset.exifInfo?.exifImageWidth || null,
|
||||||
|
height: asset.exifInfo?.exifImageHeight || null,
|
||||||
|
camera: asset.exifInfo?.make && asset.exifInfo?.model ? `${asset.exifInfo.make} ${asset.exifInfo.model}` : null,
|
||||||
|
lens: asset.exifInfo?.lensModel || null,
|
||||||
|
focalLength: asset.exifInfo?.focalLength ? `${asset.exifInfo.focalLength}mm` : null,
|
||||||
|
aperture: asset.exifInfo?.fNumber ? `f/${asset.exifInfo.fNumber}` : null,
|
||||||
|
shutter: asset.exifInfo?.exposureTime || null,
|
||||||
|
iso: asset.exifInfo?.iso || null,
|
||||||
|
city: asset.exifInfo?.city || null,
|
||||||
|
state: asset.exifInfo?.state || null,
|
||||||
|
country: asset.exifInfo?.country || null,
|
||||||
|
lat: asset.exifInfo?.latitude || null,
|
||||||
|
lng: asset.exifInfo?.longitude || null,
|
||||||
|
fileSize: asset.exifInfo?.fileSizeInByte || null,
|
||||||
|
fileName: asset.originalFileName || null,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: 'Proxy error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Proxy Immich Assets ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Asset proxy routes accept token via query param (for <img> src usage)
|
||||||
|
function authFromQuery(req: Request, res: Response, next: Function) {
|
||||||
|
const token = req.query.token as string;
|
||||||
|
if (token && !req.headers.authorization) {
|
||||||
|
req.headers.authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return (authenticate as any)(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { assetId } = req.params;
|
||||||
|
const { userId } = req.query;
|
||||||
|
|
||||||
|
const targetUserId = userId ? Number(userId) : authReq.user.id;
|
||||||
|
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
|
||||||
|
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${user.immich_url}/api/assets/${assetId}/thumbnail`, {
|
||||||
|
headers: { 'x-api-key': user.immich_api_key },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return res.status(resp.status).send('Failed');
|
||||||
|
res.set('Content-Type', resp.headers.get('content-type') || 'image/webp');
|
||||||
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||||
|
res.send(buffer);
|
||||||
|
} catch {
|
||||||
|
res.status(502).send('Proxy error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { assetId } = req.params;
|
||||||
|
const { userId } = req.query;
|
||||||
|
|
||||||
|
const targetUserId = userId ? Number(userId) : authReq.user.id;
|
||||||
|
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
|
||||||
|
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${user.immich_url}/api/assets/${assetId}/original`, {
|
||||||
|
headers: { 'x-api-key': user.immich_api_key },
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return res.status(resp.status).send('Failed');
|
||||||
|
res.set('Content-Type', resp.headers.get('content-type') || 'image/jpeg');
|
||||||
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||||
|
res.send(buffer);
|
||||||
|
} catch {
|
||||||
|
res.status(502).send('Proxy error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -474,4 +474,68 @@ router.get('/reverse', authenticate, async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resolve a Google Maps URL to place data (coordinates, name, address)
|
||||||
|
router.post('/resolve-url', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const { url } = req.body;
|
||||||
|
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let resolvedUrl = url;
|
||||||
|
|
||||||
|
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl)
|
||||||
|
if (url.includes('goo.gl') || url.includes('maps.app')) {
|
||||||
|
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
|
||||||
|
resolvedUrl = redirectRes.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract coordinates from Google Maps URL patterns:
|
||||||
|
// /@48.8566,2.3522,15z or /place/.../@48.8566,2.3522
|
||||||
|
// ?q=48.8566,2.3522 or ?ll=48.8566,2.3522
|
||||||
|
let lat: number | null = null;
|
||||||
|
let lng: number | null = null;
|
||||||
|
let placeName: string | null = null;
|
||||||
|
|
||||||
|
// Pattern: /@lat,lng
|
||||||
|
const atMatch = resolvedUrl.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
||||||
|
if (atMatch) { lat = parseFloat(atMatch[1]); lng = parseFloat(atMatch[2]); }
|
||||||
|
|
||||||
|
// Pattern: !3dlat!4dlng (Google Maps data params)
|
||||||
|
if (!lat) {
|
||||||
|
const dataMatch = resolvedUrl.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
|
||||||
|
if (dataMatch) { lat = parseFloat(dataMatch[1]); lng = parseFloat(dataMatch[2]); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern: ?q=lat,lng or &q=lat,lng
|
||||||
|
if (!lat) {
|
||||||
|
const qMatch = resolvedUrl.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
||||||
|
if (qMatch) { lat = parseFloat(qMatch[1]); lng = parseFloat(qMatch[2]); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract place name from URL path: /place/Place+Name/@...
|
||||||
|
const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
|
||||||
|
if (placeMatch) {
|
||||||
|
placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lat || !lng || isNaN(lat) || isNaN(lng)) {
|
||||||
|
return res.status(400).json({ error: 'Could not extract coordinates from URL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse geocode to get address
|
||||||
|
const nominatimRes = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`,
|
||||||
|
{ headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) }
|
||||||
|
);
|
||||||
|
const nominatim = await nominatimRes.json() as { display_name?: string; name?: string; address?: Record<string, string> };
|
||||||
|
|
||||||
|
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
|
||||||
|
const address = nominatim.display_name || null;
|
||||||
|
|
||||||
|
res.json({ lat, lng, name, address });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('[Maps] URL resolve error:', err instanceof Error ? err.message : err);
|
||||||
|
res.status(400).json({ error: 'Failed to resolve URL' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import { db } from '../db/database';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { AuthRequest } from '../types';
|
||||||
|
import { testSmtp } from '../services/notifications';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get user's notification preferences
|
||||||
|
router.get('/preferences', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
|
||||||
|
if (!prefs) {
|
||||||
|
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(authReq.user.id);
|
||||||
|
prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
|
||||||
|
}
|
||||||
|
res.json({ preferences: prefs });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user's notification preferences
|
||||||
|
router.put('/preferences', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook } = req.body;
|
||||||
|
|
||||||
|
// Ensure row exists
|
||||||
|
const existing = db.prepare('SELECT id FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
|
||||||
|
if (!existing) {
|
||||||
|
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(authReq.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`UPDATE notification_preferences SET
|
||||||
|
notify_trip_invite = COALESCE(?, notify_trip_invite),
|
||||||
|
notify_booking_change = COALESCE(?, notify_booking_change),
|
||||||
|
notify_trip_reminder = COALESCE(?, notify_trip_reminder),
|
||||||
|
notify_webhook = COALESCE(?, notify_webhook)
|
||||||
|
WHERE user_id = ?`).run(
|
||||||
|
notify_trip_invite !== undefined ? (notify_trip_invite ? 1 : 0) : null,
|
||||||
|
notify_booking_change !== undefined ? (notify_booking_change ? 1 : 0) : null,
|
||||||
|
notify_trip_reminder !== undefined ? (notify_trip_reminder ? 1 : 0) : null,
|
||||||
|
notify_webhook !== undefined ? (notify_webhook ? 1 : 0) : null,
|
||||||
|
authReq.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
|
||||||
|
res.json({ preferences: prefs });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin: test SMTP configuration
|
||||||
|
router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
|
||||||
|
|
||||||
|
const { email } = req.body;
|
||||||
|
const result = await testSmtp(email || authReq.user.email);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -24,6 +24,9 @@ interface OidcUserInfo {
|
|||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
preferred_username?: string;
|
preferred_username?: string;
|
||||||
|
groups?: string[];
|
||||||
|
roles?: string[];
|
||||||
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -41,7 +44,7 @@ setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, AUTH_CODE_CLEANUP);
|
}, AUTH_CODE_CLEANUP);
|
||||||
|
|
||||||
const pendingStates = new Map<string, { createdAt: number; redirectUri: string }>();
|
const pendingStates = new Map<string, { createdAt: number; redirectUri: string; inviteToken?: string }>();
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -85,6 +88,23 @@ function generateToken(user: { id: number; username: string; email: string; role
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user should be admin based on OIDC claims
|
||||||
|
// Env: OIDC_ADMIN_CLAIM (default: "groups"), OIDC_ADMIN_VALUE (required, e.g. "app-trek-admins")
|
||||||
|
function resolveOidcRole(userInfo: OidcUserInfo, isFirstUser: boolean): 'admin' | 'user' {
|
||||||
|
if (isFirstUser) return 'admin';
|
||||||
|
const adminValue = process.env.OIDC_ADMIN_VALUE;
|
||||||
|
if (!adminValue) return 'user'; // No claim mapping configured
|
||||||
|
const claimKey = process.env.OIDC_ADMIN_CLAIM || 'groups';
|
||||||
|
const claimData = userInfo[claimKey];
|
||||||
|
if (Array.isArray(claimData)) {
|
||||||
|
return claimData.some(v => String(v) === adminValue) ? 'admin' : 'user';
|
||||||
|
}
|
||||||
|
if (typeof claimData === 'string') {
|
||||||
|
return claimData === adminValue ? 'admin' : 'user';
|
||||||
|
}
|
||||||
|
return 'user';
|
||||||
|
}
|
||||||
|
|
||||||
function frontendUrl(path: string): string {
|
function frontendUrl(path: string): string {
|
||||||
const base = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5173';
|
const base = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5173';
|
||||||
return base + path;
|
return base + path;
|
||||||
@@ -104,8 +124,9 @@ router.get('/login', async (req: Request, res: Response) => {
|
|||||||
const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol;
|
const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol;
|
||||||
const host = (req.headers['x-forwarded-host'] as string) || req.headers.host;
|
const host = (req.headers['x-forwarded-host'] as string) || req.headers.host;
|
||||||
const redirectUri = `${proto}://${host}/api/auth/oidc/callback`;
|
const redirectUri = `${proto}://${host}/api/auth/oidc/callback`;
|
||||||
|
const inviteToken = req.query.invite as string | undefined;
|
||||||
|
|
||||||
pendingStates.set(state, { createdAt: Date.now(), redirectUri });
|
pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken });
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
@@ -190,18 +211,35 @@ router.get('/callback', async (req: Request, res: Response) => {
|
|||||||
if (!user.oidc_sub) {
|
if (!user.oidc_sub) {
|
||||||
db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id);
|
db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id);
|
||||||
}
|
}
|
||||||
|
// Update role based on OIDC claims on every login (if claim mapping is configured)
|
||||||
|
if (process.env.OIDC_ADMIN_VALUE) {
|
||||||
|
const newRole = resolveOidcRole(userInfo, false);
|
||||||
|
if (user.role !== newRole) {
|
||||||
|
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
|
||||||
|
user = { ...user, role: newRole } as User;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
||||||
const isFirstUser = userCount === 0;
|
const isFirstUser = userCount === 0;
|
||||||
|
|
||||||
if (!isFirstUser) {
|
let validInvite: any = null;
|
||||||
|
if (pending.inviteToken) {
|
||||||
|
validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(pending.inviteToken);
|
||||||
|
if (validInvite) {
|
||||||
|
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) validInvite = null;
|
||||||
|
if (validInvite?.expires_at && new Date(validInvite.expires_at) < new Date()) validInvite = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFirstUser && !validInvite) {
|
||||||
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
|
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
|
||||||
if (setting?.value === 'false') {
|
if (setting?.value === 'false') {
|
||||||
return res.redirect(frontendUrl('/login?oidc_error=registration_disabled'));
|
return res.redirect(frontendUrl('/login?oidc_error=registration_disabled'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = isFirstUser ? 'admin' : 'user';
|
const role = resolveOidcRole(userInfo, isFirstUser);
|
||||||
const randomPass = crypto.randomBytes(32).toString('hex');
|
const randomPass = crypto.randomBytes(32).toString('hex');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const hash = bcrypt.hashSync(randomPass, 10);
|
const hash = bcrypt.hashSync(randomPass, 10);
|
||||||
@@ -214,6 +252,15 @@ router.get('/callback', async (req: Request, res: Response) => {
|
|||||||
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)'
|
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
).run(username, email, hash, role, sub, config.issuer);
|
).run(username, email, hash, role, sub, config.issuer);
|
||||||
|
|
||||||
|
if (validInvite) {
|
||||||
|
const updated = db.prepare(
|
||||||
|
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)'
|
||||||
|
).run(validInvite.id);
|
||||||
|
if (updated.changes === 0) {
|
||||||
|
console.warn(`[OIDC] Invite token ${pending.inviteToken?.slice(0, 8)}... exceeded max_uses (race condition)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
|
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user