mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||||
|
|||||||
@@ -2,26 +2,26 @@
|
|||||||
<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>
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
- **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** — Categorized checklists with progress tracking, color coding, and smart suggestions
|
||||||
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
|
- **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
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
|
|
||||||
### Customization & Admin
|
### Customization & Admin
|
||||||
- **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, Chinese (Simplified), Dutch, Russian (i18n)
|
||||||
- **Admin Panel** — User management, global categories, addon management, API keys, backups, and GitHub release history
|
- **Admin Panel** — User management, 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
|
||||||
@@ -92,19 +92,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,8 +112,8 @@ 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:
|
||||||
@@ -142,20 +142,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>
|
||||||
@@ -220,14 +220,14 @@ API keys are configured in the **Admin Panel** after login. Keys set by the admi
|
|||||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
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 NOMAD
|
||||||
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/nomad`).
|
||||||
|
|
||||||
Third-party dependencies are monitored via GitHub Dependabot.
|
Third-party dependencies are monitored via GitHub Dependabot.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.6.1",
|
"version": "2.6.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.6.1",
|
"version": "2.6.2",
|
||||||
"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.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-1
@@ -107,7 +107,7 @@ 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="/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),
|
||||||
@@ -106,6 +111,13 @@ export const packingApi = {
|
|||||||
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 +147,22 @@ 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),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addonsApi = {
|
export const addonsApi = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,9 +23,9 @@ const POPULAR_ZONES = [
|
|||||||
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function getTime(tz) {
|
function getTime(tz, locale) {
|
||||||
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' })
|
||||||
} catch { return '—' }
|
} catch { return '—' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ function getOffset(tz) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TimezoneWidget() {
|
export default function TimezoneWidget() {
|
||||||
const { t } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
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 +51,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 +64,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 +87,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' })
|
||||||
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 +113,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)}</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 +125,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 +155,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)}</span>
|
||||||
</button>
|
</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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,7 +165,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 +377,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,11 @@
|
|||||||
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 {
|
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,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { PackingItem } from '../../types'
|
import type { PackingItem } from '../../types'
|
||||||
|
|
||||||
@@ -64,19 +65,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 +112,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 +151,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 +291,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 +311,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 +388,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 +509,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 +584,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 +643,20 @@ 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) => {
|
// Create a first item in the new category to make it appear
|
||||||
try { await addPackingItem(tripId, { name: v.name, category: v.category || v.kategorie }) }
|
try {
|
||||||
catch { toast.error(t('packing.toast.addError')) }
|
await addPackingItem(tripId, { name: '...', category: newCatName.trim() })
|
||||||
|
setNewCatName('')
|
||||||
|
setAddingCategory(false)
|
||||||
|
} catch { toast.error(t('packing.toast.addError')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRenameCategory = async (oldName, newName) => {
|
const handleRenameCategory = async (oldName, newName) => {
|
||||||
@@ -389,8 +679,79 @@ 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 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 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 +777,57 @@ 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={{
|
{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,
|
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', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
background: zeigeVorschlaege ? 'var(--text-primary)' : 'var(--bg-card)',
|
background: showTemplateDropdown ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||||
borderColor: zeigeVorschlaege ? 'var(--text-primary)' : 'var(--border-primary)',
|
borderColor: showTemplateDropdown ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
color: zeigeVorschlaege ? 'var(--bg-primary)' : 'var(--text-muted)',
|
color: showTemplateDropdown ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
}}>
|
}}>
|
||||||
<Sparkles size={12} /> {t('packing.suggestions')}
|
<Package size={12} /> <span className="hidden sm:inline">{t('packing.applyTemplate')}</span><span className="sm:hidden">{t('packing.template')}</span>
|
||||||
</button>
|
</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 +846,33 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleAdd} style={{ display: 'flex', gap: 6 }}>
|
{addingCategory ? (
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
<input
|
<input
|
||||||
type="text" value={neuerName} onChange={e => setNeuerName(e.target.value)}
|
autoFocus
|
||||||
placeholder={t('packing.addPlaceholder')}
|
type="text" value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleAddNewCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
|
||||||
|
placeholder={t('packing.newCategoryPlaceholder')}
|
||||||
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)' }}
|
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' }}>
|
<button onClick={handleAddNewCategory} disabled={!newCatName.trim()}
|
||||||
<input
|
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' }}>
|
||||||
ref={katInputRef}
|
<Check size={16} />
|
||||||
type="text" value={neueKategorie}
|
</button>
|
||||||
onChange={e => { setNeueKategorie(e.target.value); setShowKatDropdown(true) }}
|
<button onClick={() => { setAddingCategory(false); setNewCatName('') }}
|
||||||
onFocus={() => setShowKatDropdown(true)}
|
style={{ padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
|
||||||
onBlur={() => setTimeout(() => setShowKatDropdown(false), 150)}
|
<X size={16} />
|
||||||
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 && (
|
|
||||||
<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 }}>
|
|
||||||
{allCategories.filter(c => !neueKategorie || c.toLowerCase().includes(neueKategorie.toLowerCase())).map(cat => (
|
|
||||||
<button key={cat} type="button" onMouseDown={() => setNeueKategorie(cat)} style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
|
||||||
padding: '6px 10px', background: 'none', border: 'none', cursor: 'pointer',
|
|
||||||
fontSize: 12.5, 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'}
|
|
||||||
>
|
|
||||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, allCategories), flexShrink: 0 }} />
|
|
||||||
{cat}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setAddingCategory(true)}
|
||||||
|
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' }}
|
||||||
|
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)' }}>
|
||||||
|
<FolderPlus size={14} /> {t('packing.addCategory')}
|
||||||
|
</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>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, maxHeight: 110, overflowY: 'auto' }}>
|
|
||||||
{verfuegbareVorschlaege.map((v, i) => (
|
|
||||||
<button key={i} onClick={() => handleVorschlag(v)} style={{
|
|
||||||
fontSize: 12, padding: '4px 10px', borderRadius: 99, border: '1px solid var(--border-primary)',
|
|
||||||
background: 'var(--bg-card)', cursor: 'pointer', color: 'var(--text-secondary)', fontFamily: 'inherit', transition: 'all 0.1s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--text-primary)'; e.currentTarget.style.color = 'white'; e.currentTarget.style.borderColor = 'var(--text-primary)' }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.color = 'var(--text-secondary)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
|
||||||
>
|
|
||||||
+ {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 +888,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 +912,192 @@ 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>
|
||||||
</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 = {
|
||||||
@@ -53,7 +53,7 @@ interface DayDetailPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 }: 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 fmtTime = (v) => formatTime12(v, is12h)
|
const fmtTime = (v) => formatTime12(v, is12h)
|
||||||
@@ -138,7 +138,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
|
||||||
|
|
||||||
@@ -270,7 +270,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>
|
||||||
)}
|
)}
|
||||||
@@ -423,7 +423,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 +435,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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -763,7 +763,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 && (
|
||||||
|
|||||||
@@ -281,6 +281,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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -312,7 +312,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)"
|
||||||
|
|||||||
@@ -22,17 +22,23 @@ 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,
|
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 ctxMenu = useContextMenu()
|
const ctxMenu = useContextMenu()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [filter, setFilter] = useState('all')
|
const [filter, setFilter] = useState('all')
|
||||||
const [categoryFilter, setCategoryFilter] = useState('')
|
const [categoryFilter, setCategoryFilterLocal] = useState('')
|
||||||
|
|
||||||
|
const setCategoryFilter = (val: string) => {
|
||||||
|
setCategoryFilterLocal(val)
|
||||||
|
onCategoryFilterChange?.(val)
|
||||||
|
}
|
||||||
const [dayPickerPlace, setDayPickerPlace] = useState(null)
|
const [dayPickerPlace, setDayPickerPlace] = useState(null)
|
||||||
|
|
||||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -5,8 +5,19 @@ import type { HolidaysMap, VacayEntry } from '../../types'
|
|||||||
|
|
||||||
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||||
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||||
|
const WEEKDAYS_ES = ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sa', 'Do']
|
||||||
|
const WEEKDAYS_AR = ['اث', 'ثل', 'أر', 'خم', 'جم', 'سب', 'أح']
|
||||||
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
||||||
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
||||||
|
const MONTHS_ES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']
|
||||||
|
const MONTHS_AR = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر']
|
||||||
|
|
||||||
|
function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
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
|
||||||
@@ -25,8 +36,8 @@ export default function VacayMonthCard({
|
|||||||
onCellClick, companyMode, blockWeekends
|
onCellClick, companyMode, blockWeekends
|
||||||
}: VacayMonthCardProps) {
|
}: VacayMonthCardProps) {
|
||||||
const { language } = useTranslation()
|
const { language } = useTranslation()
|
||||||
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
|
const weekdays = language === 'de' ? WEEKDAYS_DE : language === 'es' ? WEEKDAYS_ES : language === 'ar' ? WEEKDAYS_AR : WEEKDAYS_EN
|
||||||
const monthNames = language === 'de' ? MONTHS_DE : MONTHS_EN
|
const monthNames = language === 'de' ? MONTHS_DE : language === 'es' ? MONTHS_ES : language === 'ar' ? MONTHS_AR : MONTHS_EN
|
||||||
|
|
||||||
const weeks = useMemo(() => {
|
const weeks = useMemo(() => {
|
||||||
const firstDay = new Date(year, month, 1)
|
const firstDay = new Date(year, month, 1)
|
||||||
@@ -86,7 +97,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 +126,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">
|
||||||
@@ -136,21 +88,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}
|
||||||
|
countries={countries}
|
||||||
|
language={language}
|
||||||
|
onUpdate={(data) => updateHolidayCalendar(cal.id, data)}
|
||||||
|
onDelete={() => deleteHolidayCalendar(cal.id)}
|
||||||
/>
|
/>
|
||||||
{regions.length > 0 && (
|
))}
|
||||||
<CustomSelect
|
{showAddForm ? (
|
||||||
value={selectedRegion}
|
<AddCalendarForm
|
||||||
onChange={handleRegionChange}
|
countries={countries}
|
||||||
options={regions}
|
language={language}
|
||||||
placeholder={t('vacay.selectRegion')}
|
onAdd={async (data) => { await addHolidayCalendar(data); setShowAddForm(false) }}
|
||||||
searchable
|
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 +163,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 +189,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -1,11 +1,42 @@
|
|||||||
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 ru from './translations/ru'
|
||||||
|
import zh from './translations/zh'
|
||||||
|
import nl from './translations/nl'
|
||||||
|
import ar from './translations/ar'
|
||||||
|
|
||||||
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: 'nl', label: 'Nederlands' },
|
||||||
|
{ value: 'ru', label: 'Русский' },
|
||||||
|
{ value: 'zh', label: '中文' },
|
||||||
|
{ value: 'ar', label: 'العربية' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const translations: Record<string, TranslationStrings> = { de, en, es, fr, ru, zh, nl, ar }
|
||||||
|
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA' }
|
||||||
|
const RTL_LANGUAGES = new Set(['ar'])
|
||||||
|
|
||||||
|
export function getLocaleForLanguage(language: string): string {
|
||||||
|
return LOCALES[language] || LOCALES.en
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIntlLanguage(language: string): string {
|
||||||
|
return ['de', 'es', 'fr', 'ru', 'zh', 'nl', 'ar'].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 +44,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 +72,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
@@ -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',
|
||||||
@@ -164,6 +175,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 +233,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',
|
||||||
@@ -264,6 +299,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 +338,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 +347,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.memories.name': 'Erinnerungen',
|
||||||
|
'admin.addons.catalog.memories.description': 'Geteilte Fotoalben für jede Reise',
|
||||||
|
'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.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',
|
||||||
@@ -331,6 +423,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}.',
|
||||||
@@ -387,6 +480,10 @@ const de: Record<string, string> = {
|
|||||||
'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 +528,21 @@ 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.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',
|
||||||
@@ -598,13 +710,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',
|
||||||
@@ -741,6 +853,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?',
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -164,6 +175,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 +233,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',
|
||||||
@@ -264,6 +299,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 +338,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 +347,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.memories.name': 'Memories',
|
||||||
|
'admin.addons.catalog.memories.description': 'Shared photo albums for each trip',
|
||||||
|
'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.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',
|
||||||
@@ -331,6 +423,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}.',
|
||||||
@@ -387,6 +480,10 @@ const en: Record<string, string> = {
|
|||||||
'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 +528,21 @@ 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.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',
|
||||||
@@ -598,13 +710,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',
|
||||||
@@ -741,6 +853,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?',
|
||||||
|
|||||||
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
@@ -12,7 +12,8 @@ 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 { 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 +40,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 {
|
||||||
@@ -55,7 +57,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
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') },
|
||||||
@@ -71,13 +73,22 @@ 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)
|
||||||
@@ -113,12 +124,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 +252,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 +291,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,
|
||||||
@@ -500,9 +545,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)}`}
|
||||||
|
{` · ${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 +876,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'))
|
||||||
|
|||||||
+365
-17
@@ -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 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 } 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,11 +153,26 @@ 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(new Date().getMonth() + 1)
|
||||||
|
const [bucketYear, setBucketYear] = useState(new Date().getFullYear())
|
||||||
|
|
||||||
// Load atlas data
|
// Bucket list
|
||||||
|
interface BucketItem { id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null }
|
||||||
|
const [bucketList, setBucketList] = useState<BucketItem[]>([])
|
||||||
|
const [showBucketAdd, setShowBucketAdd] = useState(false)
|
||||||
|
const [bucketForm, setBucketForm] = useState({ name: '', notes: '' })
|
||||||
|
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))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -162,7 +181,17 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
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_110m_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 +251,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 +274,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 +284,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 +311,128 @@ 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 r = await apiClient.post('/addons/atlas/bucket-list', { name: bucketForm.name.trim(), notes: bucketForm.notes.trim() || null })
|
||||||
|
setBucketList(prev => [r.data.item, ...prev])
|
||||||
|
setBucketForm({ name: '', notes: '' })
|
||||||
|
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 { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 +491,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 +512,139 @@ 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}
|
||||||
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))}
|
||||||
|
options={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))}
|
||||||
|
options={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' }}>
|
||||||
|
<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 monthStr = new Date(bucketYear, bucketMonth - 1).toLocaleString(language, { month: 'short', year: 'numeric' })
|
||||||
|
try {
|
||||||
|
const r = await apiClient.post('/addons/atlas/bucket-list', { name: confirmAction.name, country_code: confirmAction.code, notes: monthStr })
|
||||||
|
setBucketList(prev => [r.data.item, ...prev])
|
||||||
|
} catch {}
|
||||||
|
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 +658,21 @@ 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 }
|
||||||
|
setBucketForm: (f: { name: string; notes: string }) => void
|
||||||
|
onAddBucket: () => Promise<void>
|
||||||
|
onDeleteBucket: (id: number) => Promise<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, t, dark }: SidebarContentProps): React.ReactElement {
|
||||||
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 +685,75 @@ 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 (
|
||||||
|
<>
|
||||||
|
{tabBar}
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<Globe size={28} className="mx-auto mb-2" style={{ color: tf, opacity: 0.4 }} />
|
<Globe size={28} className="mx-auto mb-2" style={{ color: tf, opacity: 0.4 }} />
|
||||||
<p className="text-sm font-medium" style={{ color: tm }}>{t('atlas.noData')}</p>
|
<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>
|
<p className="text-xs mt-1" style={{ color: tf }}>{t('atlas.noDataHint')}</p>
|
||||||
</div>
|
</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.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 && (
|
||||||
|
<div className="flex items-center justify-center py-4 px-6" style={{ color: tf, fontSize: 12 }}>
|
||||||
|
{t('atlas.bucketEmptyHint')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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 +842,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,8 +789,9 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rest grid */}
|
{/* Trips — grid or list */}
|
||||||
{!isLoading && rest.length > 0 && (
|
{!isLoading && (viewMode === 'grid' ? rest : trips).length > 0 && (
|
||||||
|
viewMode === 'grid' ? (
|
||||||
<div className="trip-grid" style={{ display: 'grid', gap: 16, marginBottom: 40 }}>
|
<div className="trip-grid" style={{ display: 'grid', gap: 16, marginBottom: 40 }}>
|
||||||
{rest.map(trip => (
|
{rest.map(trip => (
|
||||||
<TripCard
|
<TripCard
|
||||||
@@ -682,6 +805,21 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</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 */}
|
||||||
|
|||||||
+134
-17
@@ -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,8 +25,10 @@ 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()
|
||||||
|
|
||||||
@@ -37,8 +40,23 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle OIDC callback via short-lived auth code (secure exchange)
|
// Handle query params (invite token, OIDC callback)
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
|
// Check for invite token in URL (/register?invite=xxx or /login?invite=xxx)
|
||||||
|
const invite = params.get('invite')
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle OIDC callback via short-lived auth code (secure exchange)
|
||||||
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 (oidcCode) {
|
if (oidcCode) {
|
||||||
@@ -83,18 +101,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 +143,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 +308,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 +329,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 +481,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"
|
||||||
|
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 +531,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 +577,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 +590,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 +612,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 +624,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,16 +634,17 @@ 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' }} />
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ 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 } 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'
|
||||||
@@ -46,7 +46,7 @@ function Section({ title, icon: Icon, children }: SectionProps): React.ReactElem
|
|||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
@@ -71,6 +71,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 || '')
|
||||||
@@ -258,11 +272,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 () => {
|
||||||
@@ -398,7 +409,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 +458,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 }}>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
|
...(enabledAddons.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.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name') }] : []),
|
||||||
]
|
]
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<string>(() => {
|
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||||
@@ -116,9 +116,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 +376,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}
|
||||||
@@ -496,6 +502,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>
|
||||||
@@ -594,7 +601,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
<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} />
|
||||||
: <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 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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -22,7 +24,8 @@ interface AuthState {
|
|||||||
demoMode: boolean
|
demoMode: boolean
|
||||||
hasMapsKey: boolean
|
hasMapsKey: boolean
|
||||||
|
|
||||||
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>
|
||||||
@@ -48,7 +51,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
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 +65,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 +73,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,
|
||||||
|
|||||||
@@ -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 ?? []
|
||||||
set({ holidays: {} })
|
if (!plan?.holidays_enabled || calendars.length === 0) {
|
||||||
return
|
|
||||||
}
|
|
||||||
const country = plan.holidays_region.split('-')[0]
|
|
||||||
const region = plan.holidays_region.includes('-') ? plan.holidays_region : null
|
|
||||||
try {
|
|
||||||
const data = await api.getHolidays(y, country)
|
|
||||||
const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0)
|
|
||||||
if (hasRegions && !region) {
|
|
||||||
set({ holidays: {} })
|
set({ holidays: {} })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const map: HolidaysMap = {}
|
const map: HolidaysMap = {}
|
||||||
|
for (const cal of calendars) {
|
||||||
|
const country = cal.region.split('-')[0]
|
||||||
|
const region = cal.region.includes('-') ? cal.region : null
|
||||||
|
try {
|
||||||
|
const data = await api.getHolidays(y, country)
|
||||||
|
const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0)
|
||||||
|
if (hasRegions && !region) continue
|
||||||
data.forEach((h: VacayHolidayRaw) => {
|
data.forEach((h: VacayHolidayRaw) => {
|
||||||
if (h.global || !h.counties || (region && h.counties.includes(region))) {
|
if (h.global || !h.counties || (region && h.counties.includes(region))) {
|
||||||
map[h.date] = { name: h.name, localName: h.localName }
|
if (!map[h.date]) {
|
||||||
|
map[h.date] = { name: h.name, localName: h.localName, color: cal.color, label: cal.label }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
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 () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -281,10 +283,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 +316,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 +330,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 {
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: mauriceboe/nomad:2.5.5
|
image: mauriceboe/trek:latest
|
||||||
container_name: nomad
|
container_name: trek
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
Generated
+451
-95
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-server",
|
"name": "trek-server",
|
||||||
"version": "2.6.1",
|
"version": "2.6.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nomad-server",
|
"name": "trek-server",
|
||||||
"version": "2.6.1",
|
"version": "2.6.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -16,9 +16,11 @@
|
|||||||
"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",
|
||||||
|
"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 +32,12 @@
|
|||||||
"@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/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 +460,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 +569,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 +612,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 +653,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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 +698,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 +760,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 +1066,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 +1185,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 +1225,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 +1270,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 +1377,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 +1438,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 +1524,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 +1741,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 +1821,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 +1925,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 +2120,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 +2261,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 +2427,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 +2440,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": {
|
||||||
@@ -2458,6 +2625,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 +2681,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 +2709,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 +2781,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 +2882,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 +2915,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 +3022,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 +3201,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 +3307,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 +3514,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 +3561,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": {
|
||||||
|
|||||||
+6
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.6.2",
|
"version": "2.7.0",
|
||||||
"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,8 +15,10 @@
|
|||||||
"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",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
@@ -29,11 +31,12 @@
|
|||||||
"@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/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,86 @@ 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
|
||||||
|
)`);
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
+6
-1
@@ -44,6 +44,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 +62,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);
|
||||||
|
|||||||
+164
-2
@@ -1,5 +1,6 @@
|
|||||||
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';
|
||||||
@@ -94,7 +95,7 @@ router.put('/users/:id', (req: Request, res: Response) => {
|
|||||||
|
|
||||||
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' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,16 +123,18 @@ 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');
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,6 +222,165 @@ 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;
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(token, uses, expiresAt, authReq.user.id);
|
||||||
|
|
||||||
|
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 = last_insert_rowid()
|
||||||
|
`).get();
|
||||||
|
|
||||||
|
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);
|
||||||
|
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');
|
||||||
|
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);
|
||||||
|
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 || '{}') })) });
|
||||||
|
|||||||
@@ -153,6 +153,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 +247,57 @@ 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 } = 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) VALUES (?, ?, ?, ?, ?, ?)').run(
|
||||||
|
authReq.user.id, name.trim(), lat ?? null, lng ?? null, country_code ?? null, notes ?? 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 } = 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 = COALESCE(?, notes) WHERE id = ?').run(name?.trim() || null, notes ?? 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;
|
||||||
|
|||||||
+216
-34
@@ -6,11 +6,43 @@ 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';
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
||||||
@@ -59,6 +91,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 +132,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,6 +141,7 @@ 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,
|
||||||
@@ -110,15 +156,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.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 +225,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 +246,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 +266,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 +363,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 +411,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) => {
|
||||||
@@ -497,30 +595,114 @@ 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);
|
||||||
|
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);
|
||||||
|
res.json({ success: true, mfa_enabled: false });
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
|||||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
router.put('/: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;
|
||||||
const { name, checked, category } = req.body;
|
const { name, checked, category, weight_grams, bag_id } = req.body;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
@@ -61,13 +61,19 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
|||||||
UPDATE packing_items SET
|
UPDATE packing_items SET
|
||||||
name = COALESCE(?, name),
|
name = COALESCE(?, name),
|
||||||
checked = CASE WHEN ? IS NOT NULL THEN ? ELSE checked END,
|
checked = CASE WHEN ? IS NOT NULL THEN ? ELSE checked END,
|
||||||
category = COALESCE(?, category)
|
category = COALESCE(?, category),
|
||||||
|
weight_grams = CASE WHEN ? THEN ? ELSE weight_grams END,
|
||||||
|
bag_id = CASE WHEN ? THEN ? ELSE bag_id END
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
name || null,
|
name || null,
|
||||||
checked !== undefined ? 1 : null,
|
checked !== undefined ? 1 : null,
|
||||||
checked ? 1 : 0,
|
checked ? 1 : 0,
|
||||||
category || null,
|
category || null,
|
||||||
|
'weight_grams' in req.body ? 1 : 0,
|
||||||
|
weight_grams ?? null,
|
||||||
|
'bag_id' in req.body ? 1 : 0,
|
||||||
|
bag_id ?? null,
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -91,6 +97,142 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
|||||||
broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string);
|
broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Bags CRUD ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/bags', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
const bags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ? ORDER BY sort_order, id').all(tripId);
|
||||||
|
res.json({ bags });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/bags', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const { name, color } = req.body;
|
||||||
|
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
|
||||||
|
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_bags WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||||
|
const result = db.prepare('INSERT INTO packing_bags (trip_id, name, color, sort_order) VALUES (?, ?, ?, ?)').run(tripId, name.trim(), color || '#6366f1', (maxOrder.max ?? -1) + 1);
|
||||||
|
const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
res.status(201).json({ bag });
|
||||||
|
broadcast(tripId, 'packing:bag-created', { bag }, req.headers['x-socket-id'] as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/bags/:bagId', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId, bagId } = req.params;
|
||||||
|
const { name, color, weight_limit_grams } = req.body;
|
||||||
|
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId);
|
||||||
|
if (!bag) return res.status(404).json({ error: 'Bag not found' });
|
||||||
|
db.prepare('UPDATE packing_bags SET name = COALESCE(?, name), color = COALESCE(?, color), weight_limit_grams = ? WHERE id = ?').run(name?.trim() || null, color || null, weight_limit_grams ?? null, bagId);
|
||||||
|
const updated = db.prepare('SELECT * FROM packing_bags WHERE id = ?').get(bagId);
|
||||||
|
res.json({ bag: updated });
|
||||||
|
broadcast(tripId, 'packing:bag-updated', { bag: updated }, req.headers['x-socket-id'] as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/bags/:bagId', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId, bagId } = req.params;
|
||||||
|
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId);
|
||||||
|
if (!bag) return res.status(404).json({ error: 'Bag not found' });
|
||||||
|
db.prepare('DELETE FROM packing_bags WHERE id = ?').run(bagId);
|
||||||
|
res.json({ success: true });
|
||||||
|
broadcast(tripId, 'packing:bag-deleted', { bagId: Number(bagId) }, req.headers['x-socket-id'] as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Apply template ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.post('/apply-template/:templateId', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId, templateId } = req.params;
|
||||||
|
|
||||||
|
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
const templateItems = db.prepare(`
|
||||||
|
SELECT ti.name, tc.name as category
|
||||||
|
FROM packing_template_items ti
|
||||||
|
JOIN packing_template_categories tc ON ti.category_id = tc.id
|
||||||
|
WHERE tc.template_id = ?
|
||||||
|
ORDER BY tc.sort_order, ti.sort_order
|
||||||
|
`).all(templateId) as { name: string; category: string }[];
|
||||||
|
if (templateItems.length === 0) return res.status(404).json({ error: 'Template not found or empty' });
|
||||||
|
|
||||||
|
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||||
|
let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||||
|
|
||||||
|
const insert = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, 0, ?, ?)');
|
||||||
|
const added: any[] = [];
|
||||||
|
for (const ti of templateItems) {
|
||||||
|
const result = insert.run(tripId, ti.name, ti.category, sortOrder++);
|
||||||
|
const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
added.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ items: added, count: added.length });
|
||||||
|
broadcast(tripId, 'packing:template-applied', { items: added }, req.headers['x-socket-id'] as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Category assignees ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/category-assignees', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT pca.category_name, pca.user_id, u.username, u.avatar
|
||||||
|
FROM packing_category_assignees pca
|
||||||
|
JOIN users u ON pca.user_id = u.id
|
||||||
|
WHERE pca.trip_id = ?
|
||||||
|
`).all(tripId);
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const assignees: Record<string, { user_id: number; username: string; avatar: string | null }[]> = {};
|
||||||
|
for (const row of rows as any[]) {
|
||||||
|
if (!assignees[row.category_name]) assignees[row.category_name] = [];
|
||||||
|
assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ assignees });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/category-assignees/:categoryName', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId, categoryName } = req.params;
|
||||||
|
const { user_ids } = req.body;
|
||||||
|
|
||||||
|
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
const cat = decodeURIComponent(categoryName);
|
||||||
|
db.prepare('DELETE FROM packing_category_assignees WHERE trip_id = ? AND category_name = ?').run(tripId, cat);
|
||||||
|
|
||||||
|
if (Array.isArray(user_ids) && user_ids.length > 0) {
|
||||||
|
const insert = db.prepare('INSERT OR IGNORE INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)');
|
||||||
|
for (const uid of user_ids) insert.run(tripId, cat, uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT pca.user_id, u.username, u.avatar
|
||||||
|
FROM packing_category_assignees pca
|
||||||
|
JOIN users u ON pca.user_id = u.id
|
||||||
|
WHERE pca.trip_id = ? AND pca.category_name = ?
|
||||||
|
`).all(tripId, cat);
|
||||||
|
|
||||||
|
res.json({ assignees: rows });
|
||||||
|
broadcast(tripId, 'packing:assignees', { category: cat, assignees: rows }, req.headers['x-socket-id'] as string);
|
||||||
|
});
|
||||||
|
|
||||||
router.put('/reorder', authenticate, (req: Request, res: Response) => {
|
router.put('/reorder', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ interface UnsplashSearchResponse {
|
|||||||
const router = express.Router({ mergeParams: true });
|
const router = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params
|
||||||
const { search, category, tag } = req.query;
|
const { search, category, tag } = req.query;
|
||||||
|
|
||||||
let query = `
|
let query = `
|
||||||
@@ -41,12 +41,12 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) =
|
|||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
query += ' AND p.category_id = ?';
|
query += ' AND p.category_id = ?';
|
||||||
params.push(category);
|
params.push(category as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tag) {
|
if (tag) {
|
||||||
query += ' AND p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)';
|
query += ' AND p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)';
|
||||||
params.push(tag);
|
params.push(tag as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
query += ' ORDER BY p.created_at DESC';
|
query += ' ORDER BY p.created_at DESC';
|
||||||
@@ -73,7 +73,7 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) =
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
|
router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params
|
||||||
|
|
||||||
const {
|
const {
|
||||||
name, description, lat, lng, address, category_id, price, currency,
|
name, description, lat, lng, address, category_id, price, currency,
|
||||||
@@ -107,13 +107,13 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const place = getPlaceWithTags(placeId);
|
const place = getPlaceWithTags(Number(placeId));
|
||||||
res.status(201).json({ place });
|
res.status(201).json({ place });
|
||||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params
|
||||||
|
|
||||||
const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!placeCheck) {
|
if (!placeCheck) {
|
||||||
@@ -126,7 +126,7 @@ router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response
|
|||||||
|
|
||||||
router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, res: Response) => {
|
router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params
|
||||||
|
|
||||||
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined;
|
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined;
|
||||||
if (!place) {
|
if (!place) {
|
||||||
@@ -166,7 +166,7 @@ router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, r
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
|
router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
|
||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params
|
||||||
|
|
||||||
const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined;
|
const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined;
|
||||||
if (!existingPlace) {
|
if (!existingPlace) {
|
||||||
@@ -238,7 +238,7 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params
|
||||||
|
|
||||||
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!place) {
|
if (!place) {
|
||||||
|
|||||||
+103
-25
@@ -43,9 +43,59 @@ interface Holiday {
|
|||||||
counties?: string[] | null;
|
counties?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VacayHolidayCalendar {
|
||||||
|
id: number;
|
||||||
|
plan_id: number;
|
||||||
|
region: string;
|
||||||
|
label: string | null;
|
||||||
|
color: string;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
const holidayCache = new Map<string, { data: unknown; time: number }>();
|
const holidayCache = new Map<string, { data: unknown; time: number }>();
|
||||||
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
async function applyHolidayCalendars(planId: number): Promise<void> {
|
||||||
|
const plan = db.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?').get(planId) as { holidays_enabled: number } | undefined;
|
||||||
|
if (!plan?.holidays_enabled) return;
|
||||||
|
const calendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[];
|
||||||
|
if (calendars.length === 0) return;
|
||||||
|
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[];
|
||||||
|
for (const cal of calendars) {
|
||||||
|
const country = cal.region.split('-')[0];
|
||||||
|
const region = cal.region.includes('-') ? cal.region : null;
|
||||||
|
for (const { year } of years) {
|
||||||
|
try {
|
||||||
|
const cacheKey = `${year}-${country}`;
|
||||||
|
let holidays = holidayCache.get(cacheKey)?.data as Holiday[] | undefined;
|
||||||
|
if (!holidays) {
|
||||||
|
const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`);
|
||||||
|
holidays = await resp.json() as Holiday[];
|
||||||
|
holidayCache.set(cacheKey, { data: holidays, time: Date.now() });
|
||||||
|
}
|
||||||
|
const hasRegions = holidays.some((h: Holiday) => h.counties && h.counties.length > 0);
|
||||||
|
if (hasRegions && !region) continue;
|
||||||
|
for (const h of holidays) {
|
||||||
|
if (h.global || !h.counties || (region && h.counties.includes(region))) {
|
||||||
|
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date);
|
||||||
|
db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* API error, skip */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateHolidayCalendars(planId: number, plan: VacayPlan): Promise<void> {
|
||||||
|
const existing = db.prepare('SELECT id FROM vacay_holiday_calendars WHERE plan_id = ?').get(planId);
|
||||||
|
if (existing) return;
|
||||||
|
if (plan.holidays_enabled && plan.holidays_region) {
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, NULL, ?, 0)'
|
||||||
|
).run(planId, plan.holidays_region, '#fecaca');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
@@ -124,6 +174,8 @@ router.get('/plan', (req: Request, res: Response) => {
|
|||||||
WHERE m.user_id = ? AND m.status = 'pending'
|
WHERE m.user_id = ? AND m.status = 'pending'
|
||||||
`).all(authReq.user.id);
|
`).all(authReq.user.id);
|
||||||
|
|
||||||
|
const holidayCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(activePlanId) as VacayHolidayCalendar[];
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
plan: {
|
plan: {
|
||||||
...plan,
|
...plan,
|
||||||
@@ -131,6 +183,7 @@ router.get('/plan', (req: Request, res: Response) => {
|
|||||||
holidays_enabled: !!plan.holidays_enabled,
|
holidays_enabled: !!plan.holidays_enabled,
|
||||||
company_holidays_enabled: !!plan.company_holidays_enabled,
|
company_holidays_enabled: !!plan.company_holidays_enabled,
|
||||||
carry_over_enabled: !!plan.carry_over_enabled,
|
carry_over_enabled: !!plan.carry_over_enabled,
|
||||||
|
holiday_calendars: holidayCalendars,
|
||||||
},
|
},
|
||||||
users,
|
users,
|
||||||
pendingInvites,
|
pendingInvites,
|
||||||
@@ -166,30 +219,8 @@ router.put('/plan', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedPlan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan;
|
const updatedPlan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan;
|
||||||
if (updatedPlan.holidays_enabled && updatedPlan.holidays_region) {
|
await migrateHolidayCalendars(planId, updatedPlan);
|
||||||
const country = updatedPlan.holidays_region.split('-')[0];
|
await applyHolidayCalendars(planId);
|
||||||
const region = updatedPlan.holidays_region.includes('-') ? updatedPlan.holidays_region : null;
|
|
||||||
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[];
|
|
||||||
for (const { year } of years) {
|
|
||||||
try {
|
|
||||||
const cacheKey = `${year}-${country}`;
|
|
||||||
let holidays = holidayCache.get(cacheKey)?.data as Holiday[] | undefined;
|
|
||||||
if (!holidays) {
|
|
||||||
const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`);
|
|
||||||
holidays = await resp.json() as Holiday[];
|
|
||||||
holidayCache.set(cacheKey, { data: holidays, time: Date.now() });
|
|
||||||
}
|
|
||||||
const hasRegions = (holidays as Holiday[]).some((h: Holiday) => h.counties && h.counties.length > 0);
|
|
||||||
if (hasRegions && !region) continue;
|
|
||||||
for (const h of holidays) {
|
|
||||||
if (h.global || !h.counties || (region && h.counties.includes(region))) {
|
|
||||||
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date);
|
|
||||||
db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { /* API error, skip */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (carry_over_enabled === false) {
|
if (carry_over_enabled === false) {
|
||||||
db.prepare('UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?').run(planId);
|
db.prepare('UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?').run(planId);
|
||||||
@@ -217,11 +248,58 @@ router.put('/plan', async (req: Request, res: Response) => {
|
|||||||
notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
|
notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
|
||||||
|
|
||||||
const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan;
|
const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan;
|
||||||
|
const updatedCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[];
|
||||||
res.json({
|
res.json({
|
||||||
plan: { ...updated, block_weekends: !!updated.block_weekends, holidays_enabled: !!updated.holidays_enabled, company_holidays_enabled: !!updated.company_holidays_enabled, carry_over_enabled: !!updated.carry_over_enabled }
|
plan: { ...updated, block_weekends: !!updated.block_weekends, holidays_enabled: !!updated.holidays_enabled, company_holidays_enabled: !!updated.company_holidays_enabled, carry_over_enabled: !!updated.carry_over_enabled, holiday_calendars: updatedCalendars }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/plan/holiday-calendars', (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { region, label, color, sort_order } = req.body;
|
||||||
|
if (!region) return res.status(400).json({ error: 'region required' });
|
||||||
|
const planId = getActivePlanId(authReq.user.id);
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).run(planId, region, label || null, color || '#fecaca', sort_order ?? 0);
|
||||||
|
const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(result.lastInsertRowid) as VacayHolidayCalendar;
|
||||||
|
notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
|
||||||
|
res.json({ calendar: cal });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/plan/holiday-calendars/:id', (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const planId = getActivePlanId(authReq.user.id);
|
||||||
|
const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(id, planId) as VacayHolidayCalendar | undefined;
|
||||||
|
if (!cal) return res.status(404).json({ error: 'Calendar not found' });
|
||||||
|
const { region, label, color, sort_order } = req.body;
|
||||||
|
const updates: string[] = [];
|
||||||
|
const params: (string | number | null)[] = [];
|
||||||
|
if (region !== undefined) { updates.push('region = ?'); params.push(region); }
|
||||||
|
if (label !== undefined) { updates.push('label = ?'); params.push(label); }
|
||||||
|
if (color !== undefined) { updates.push('color = ?'); params.push(color); }
|
||||||
|
if (sort_order !== undefined) { updates.push('sort_order = ?'); params.push(sort_order); }
|
||||||
|
if (updates.length > 0) {
|
||||||
|
params.push(id);
|
||||||
|
db.prepare(`UPDATE vacay_holiday_calendars SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
||||||
|
}
|
||||||
|
const updated = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(id) as VacayHolidayCalendar;
|
||||||
|
notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
|
||||||
|
res.json({ calendar: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/plan/holiday-calendars/:id', (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const planId = getActivePlanId(authReq.user.id);
|
||||||
|
const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(id, planId);
|
||||||
|
if (!cal) return res.status(404).json({ error: 'Calendar not found' });
|
||||||
|
db.prepare('DELETE FROM vacay_holiday_calendars WHERE id = ?').run(id);
|
||||||
|
notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
router.put('/color', (req: Request, res: Response) => {
|
router.put('/color', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { color, target_user_id } = req.body;
|
const { color, target_user_id } = req.body;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import cron from 'node-cron';
|
import cron, { type ScheduledTask } from 'node-cron';
|
||||||
import archiver from 'archiver';
|
import archiver from 'archiver';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -23,7 +23,7 @@ interface BackupSettings {
|
|||||||
keep_days: number;
|
keep_days: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentTask: cron.ScheduledTask | null = null;
|
let currentTask: ScheduledTask | null = null;
|
||||||
|
|
||||||
function loadSettings(): BackupSettings {
|
function loadSettings(): BackupSettings {
|
||||||
try {
|
try {
|
||||||
@@ -110,7 +110,7 @@ function start(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Demo mode: hourly reset of demo user data
|
// Demo mode: hourly reset of demo user data
|
||||||
let demoTask: cron.ScheduledTask | null = null;
|
let demoTask: ScheduledTask | null = null;
|
||||||
|
|
||||||
function startDemoReset(): void {
|
function startDemoReset(): void {
|
||||||
if (demoTask) { demoTask.stop(); demoTask = null; }
|
if (demoTask) { demoTask.stop(); demoTask = null; }
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { JWT_SECRET } from '../config';
|
||||||
|
|
||||||
|
function getKey(): Buffer {
|
||||||
|
return crypto.createHash('sha256').update(`${JWT_SECRET}:mfa:v1`).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt TOTP secret for storage in SQLite. */
|
||||||
|
export function encryptMfaSecret(plain: string): string {
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv);
|
||||||
|
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return Buffer.concat([iv, tag, enc]).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptMfaSecret(blob: string): string {
|
||||||
|
const buf = Buffer.from(blob, 'base64');
|
||||||
|
const iv = buf.subarray(0, 12);
|
||||||
|
const tag = buf.subarray(12, 28);
|
||||||
|
const enc = buf.subarray(28);
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ export interface User {
|
|||||||
oidc_sub?: string | null;
|
oidc_sub?: string | null;
|
||||||
oidc_issuer?: string | null;
|
oidc_issuer?: string | null;
|
||||||
last_login?: string | null;
|
last_login?: string | null;
|
||||||
|
mfa_enabled?: number | boolean;
|
||||||
|
mfa_secret?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<Container version="2">
|
||||||
|
<Name>TREK</Name>
|
||||||
|
<Repository>mauriceboe/trek</Repository>
|
||||||
|
<Registry>https://hub.docker.com/r/mauriceboe/trek</Registry>
|
||||||
|
<Branch>
|
||||||
|
<Tag>latest</Tag>
|
||||||
|
<TagDescription>Latest stable release</TagDescription>
|
||||||
|
</Branch>
|
||||||
|
<Network>bridge</Network>
|
||||||
|
<Privileged>false</Privileged>
|
||||||
|
<Support>https://github.com/mauriceboe/TREK/issues</Support>
|
||||||
|
<Project>https://github.com/mauriceboe/TREK</Project>
|
||||||
|
<Overview>TREK is a self-hosted, real-time collaborative travel planner with interactive maps, budgets, bookings, packing lists, file management, and more. Plan trips together with your group — changes sync instantly across all connected users. Includes OIDC/SSO support, dark mode, PWA, and a modular addon system (Vacay, Atlas, Collab, Budget, Packing).</Overview>
|
||||||
|
<Category>Productivity: Tools:</Category>
|
||||||
|
<WebUI>http://[IP]:[PORT:3000]</WebUI>
|
||||||
|
<TemplateURL>https://raw.githubusercontent.com/mauriceboe/TREK/main/unraid-template.xml</TemplateURL>
|
||||||
|
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/client/public/icons/icon-dark.svg</Icon>
|
||||||
|
<ExtraParams/>
|
||||||
|
<PostArgs/>
|
||||||
|
<DonateText>Support TREK development</DonateText>
|
||||||
|
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
|
||||||
|
<Requires/>
|
||||||
|
<Config Name="Web UI Port" Target="3000" Default="3000" Mode="tcp" Description="Port for the web interface" Type="Port" Display="always" Required="true" Mask="false">3000</Config>
|
||||||
|
<Config Name="Data" Target="/app/data" Default="/mnt/user/appdata/trek/data" Mode="rw" Description="Database and app data" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/trek/data</Config>
|
||||||
|
<Config Name="Uploads" Target="/app/uploads" Default="/mnt/user/appdata/trek/uploads" Mode="rw" Description="Uploaded files (photos, documents)" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/trek/uploads</Config>
|
||||||
|
<Config Name="NODE_ENV" Target="NODE_ENV" Default="production" Mode="" Description="Node environment" Type="Variable" Display="advanced" Required="false" Mask="false">production</Config>
|
||||||
|
<Config Name="JWT_SECRET" Target="JWT_SECRET" Default="" Mode="" Description="JWT secret key (auto-generated if empty)" Type="Variable" Display="advanced" Required="false" Mask="true"/>
|
||||||
|
<Config Name="PORT" Target="PORT" Default="3000" Mode="" Description="Internal port" Type="Variable" Display="advanced" Required="false" Mask="false">3000</Config>
|
||||||
|
</Container>
|
||||||
Reference in New Issue
Block a user