mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7272e0bbfd | |||
| c7eaf3aa79 | |||
| deef5e6b81 | |||
| 6d72006b28 | |||
| 26c1676cdd | |||
| 4ddfa92c14 | |||
| 19c9e17884 | |||
| 14ef2d4a4a | |||
| de859318fa | |||
| bcbb516448 | |||
| 71870e4567 | |||
| 9819473157 | |||
| eb7984f40d | |||
| 9caa0acc24 | |||
| 8ddfa8fde0 | |||
| 41d4b2a8be | |||
| 10ebf46a98 | |||
| 70809d6c27 | |||
| a314ba2b80 | |||
| d8f03f6bea | |||
| 533d6f84d8 | |||
| 095cb1b9d1 | |||
| 0a0205fcf9 | |||
| 9aed5ff2ed | |||
| d189d6d776 | |||
| 262905e357 | |||
| 4a4643f33f | |||
| a6a7edf0b2 | |||
| 949d0967d2 | |||
| cd634093af | |||
| 7201380504 | |||
| 1166a09835 | |||
| 6f2d7c8f5e | |||
| e6c4c22a1d | |||
| 9a044ada28 | |||
| da5e77f78d | |||
| cc8be328f9 | |||
| f1c4155d81 | |||
| d4899a8dee | |||
| a973a1b4f8 | |||
| 73b0534053 | |||
| 931c5bd990 | |||
| ee54308819 | |||
| 66b00c24e2 | |||
| f6d08582ec | |||
| 8d9a511edf | |||
| 3059d53d11 | |||
| 3074724f2f | |||
| 21ed7ea4a2 | |||
| 267271d97a | |||
| 874c1292c7 | |||
| a9948499e4 | |||
| 90301e62ce | |||
| 377422a9d5 | |||
| d90a059dfa | |||
| 1e20f024d5 | |||
| 9a81baa809 | |||
| 11b85a2d70 | |||
| d04629605e | |||
| 187989cc1d | |||
| 6444b2b4ce | |||
| 42ebc7c298 | |||
| 8bca921b30 | |||
| 12f8b6eb55 | |||
| 202cfb6a63 | |||
| b6f9664ec2 | |||
| 9f8075171d | |||
| 02b907e764 | |||
| e05e021f41 | |||
| 615c6bae58 | |||
| 62fbc26811 | |||
| 2171203a4c | |||
| b28b483b90 | |||
| 020cafade1 | |||
| e4b2262d4d |
+5
-2
@@ -11,9 +11,9 @@ FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Server-Dependencies installieren (better-sqlite3 braucht Build-Tools)
|
||||
# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools)
|
||||
COPY server/package*.json ./
|
||||
RUN apk add --no-cache python3 make g++ && \
|
||||
RUN apk add --no-cache tzdata python3 make g++ && \
|
||||
npm ci --production && \
|
||||
apk del python3 make g++
|
||||
|
||||
@@ -30,6 +30,9 @@ COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||
RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data
|
||||
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
|
||||
# Umgebung setzen
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</p>
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
<details>
|
||||
<summary>More Screenshots</summary>
|
||||
@@ -44,11 +44,14 @@
|
||||
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
|
||||
- **Route Optimization** — Auto-optimize place order and export to Google Maps
|
||||
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
|
||||
- **Map Category Filter** — Filter places by category and see only matching pins on the map
|
||||
|
||||
### Travel Management
|
||||
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
|
||||
- **Reservations & Bookings** — Track flights, accommodations, restaurants with status, confirmation numbers, and file attachments
|
||||
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
|
||||
- **Packing Lists** — Categorized checklists with progress tracking, color coding, and smart suggestions
|
||||
- **Packing Lists** — Category-based checklists with user assignment, packing templates, and progress tracking
|
||||
- **Packing Templates** — Create reusable packing templates in the admin panel with categories and items, apply to any trip
|
||||
- **Bag Tracking** — Optional weight tracking and bag assignment for packing items with iOS-style weight distribution (admin-toggleable)
|
||||
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
|
||||
- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and TREK branding
|
||||
|
||||
@@ -61,19 +64,22 @@
|
||||
### Collaboration
|
||||
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
|
||||
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
|
||||
- **Invite Links** — Create one-time registration links with configurable max uses and expiry for easy onboarding
|
||||
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **Two-Factor Authentication (MFA)** — TOTP-based 2FA with QR code setup, works with Google Authenticator, Authy, etc.
|
||||
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
||||
|
||||
### Addons (modular, admin-toggleable)
|
||||
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking
|
||||
- **Atlas** — Interactive world map with visited countries, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
|
||||
- **Atlas** — Interactive world map with visited countries, bucket list with planned travel dates, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
|
||||
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
||||
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
|
||||
|
||||
### Customization & Admin
|
||||
- **Dashboard Views** — Toggle between card grid and compact list view on the My Trips page
|
||||
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
||||
- **Multilingual** — English, German, Chinese (Simplified), Dutch, Russian (i18n)
|
||||
- **Admin Panel** — User management, global categories, addon management, API keys, backups, and GitHub release history
|
||||
- **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Arabic (with RTL support)
|
||||
- **Admin Panel** — User management, invite links, packing templates, global categories, addon management, API keys, backups, and GitHub release history
|
||||
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
||||
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
||||
|
||||
@@ -84,7 +90,7 @@
|
||||
- **PWA**: vite-plugin-pwa + Workbox
|
||||
- **Real-Time**: WebSocket (`ws`)
|
||||
- **State**: Zustand
|
||||
- **Auth**: JWT + OIDC
|
||||
- **Auth**: JWT + OIDC + TOTP (MFA)
|
||||
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
||||
- **Weather**: Open-Meteo API (free, no key required)
|
||||
- **Icons**: lucide-react
|
||||
@@ -119,6 +125,11 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
# - OIDC_ISSUER=https://auth.example.com
|
||||
# - OIDC_CLIENT_ID=trek
|
||||
# - OIDC_CLIENT_SECRET=supersecret
|
||||
# - OIDC_DISPLAY_NAME="SSO"
|
||||
# - OIDC_ONLY=true # disable password auth entirely
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
@@ -163,13 +174,13 @@ For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, T
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name nomad.yourdomain.com;
|
||||
server_name trek.yourdomain.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name nomad.yourdomain.com;
|
||||
server_name trek.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/fullchain.pem;
|
||||
ssl_certificate_key /path/to/privkey.pem;
|
||||
@@ -204,13 +215,29 @@ server {
|
||||
Caddy handles WebSocket upgrades automatically:
|
||||
|
||||
```
|
||||
nomad.yourdomain.com {
|
||||
trek.yourdomain.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PORT` | Server port | `3000` |
|
||||
| `NODE_ENV` | Environment | `production` |
|
||||
| `JWT_SECRET` | JWT signing secret | Auto-generated |
|
||||
| `FORCE_HTTPS` | Redirect HTTP to HTTPS | `false` |
|
||||
| `OIDC_ISSUER` | OIDC provider URL | — |
|
||||
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
|
||||
| `OIDC_DISPLAY_NAME` | SSO button label | `SSO` |
|
||||
| `OIDC_ONLY` | Disable password auth | `false` |
|
||||
| `TRUST_PROXY` | Trust proxy headers | `1` |
|
||||
| `DEMO_MODE` | Enable demo mode | `false` |
|
||||
|
||||
## Optional API Keys
|
||||
|
||||
API keys are configured in the **Admin Panel** after login. Keys set by the admin are automatically shared with all users — no per-user configuration needed.
|
||||
@@ -226,7 +253,7 @@ API keys are configured in the **Admin Panel** after login. Keys set by the admi
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mauriceboe/TREK.git
|
||||
cd NOMAD
|
||||
cd TREK
|
||||
docker build -t trek .
|
||||
```
|
||||
|
||||
|
||||
+1
-1
@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers the TREK application and its Docker image (`mauriceboe/nomad`).
|
||||
This policy covers the TREK application and its Docker image (`mauriceboe/trek`).
|
||||
|
||||
Third-party dependencies are monitored via GitHub Dependabot.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 0.1.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "latest"
|
||||
@@ -0,0 +1,33 @@
|
||||
# TREK Helm Chart
|
||||
|
||||
This is a minimal Helm chart for deploying the TREK app.
|
||||
|
||||
## Features
|
||||
- Deploys the TREK container
|
||||
- Exposes port 3000 via Service
|
||||
- Optional persistent storage for `/app/data` and `/app/uploads`
|
||||
- Configurable environment variables and secrets
|
||||
- Optional generic Ingress support
|
||||
- Health checks on `/api/health`
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
helm install trek ./chart \
|
||||
--set secretEnv.JWT_SECRET=your_jwt_secret \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=yourdomain.com
|
||||
```
|
||||
|
||||
See `values.yaml` for more options.
|
||||
|
||||
## Files
|
||||
- `Chart.yaml` — chart metadata
|
||||
- `values.yaml` — configuration values
|
||||
- `templates/` — Kubernetes manifests
|
||||
|
||||
## Notes
|
||||
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||
- PVCs require a default StorageClass or specify one as needed.
|
||||
- JWT_SECRET must be set for production use.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
@@ -0,0 +1,13 @@
|
||||
1. JWT_SECRET handling:
|
||||
- By default, the chart creates a secret with the value from `values.yaml: secretEnv.JWT_SECRET`.
|
||||
- To generate a random JWT_SECRET at install, set `generateJwtSecret: true`.
|
||||
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must have a key matching `existingSecretKey` (defaults to `JWT_SECRET`).
|
||||
|
||||
2. Example usage:
|
||||
- Set a custom secret: `--set secretEnv.JWT_SECRET=your_secret`
|
||||
- Generate a random secret: `--set generateJwtSecret=true`
|
||||
- Use an existing secret: `--set existingSecret=my-k8s-secret`
|
||||
- Use a custom key in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_KEY`
|
||||
|
||||
3. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence.
|
||||
If using `existingSecret`, ensure the referenced secret and key exist in the target namespace.
|
||||
@@ -0,0 +1,18 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "trek.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
*/}}
|
||||
{{- define "trek.fullname" -}}
|
||||
{{- if .Values.fullnameOverride -}}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||
{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-config
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
data:
|
||||
NODE_ENV: {{ .Values.env.NODE_ENV | quote }}
|
||||
PORT: {{ .Values.env.PORT | quote }}
|
||||
{{- if .Values.env.ALLOWED_ORIGINS }}
|
||||
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,61 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ include "trek.name" . }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- range .Values.imagePullSecrets }}
|
||||
- name: {{ .name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: trek
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "trek.fullname" . }}-config
|
||||
env:
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: {{ .Values.existingSecretKey | default "JWT_SECRET" }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "trek.fullname" . }}-data
|
||||
- name: uploads
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "trek.fullname" . }}-uploads
|
||||
@@ -0,0 +1,32 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ . }}
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "trek.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,25 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-data
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.data.size }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-uploads
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.uploads.size }}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{- if and (not .Values.existingSecret) (not .Values.generateJwtSecret) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-secret
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and (not .Values.existingSecret) (.Values.generateJwtSecret) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-secret
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: {{ include "trek.name" . }}
|
||||
@@ -0,0 +1,53 @@
|
||||
|
||||
image:
|
||||
repository: mauriceboe/trek
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Optional image pull secrets for private registries
|
||||
imagePullSecrets: []
|
||||
# - name: my-registry-secret
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 3000
|
||||
|
||||
env:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
# ALLOWED_ORIGINS: ""
|
||||
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
||||
|
||||
|
||||
# JWT secret configuration
|
||||
secretEnv:
|
||||
# If set, use this value for JWT_SECRET (base64-encoded in secret.yaml)
|
||||
JWT_SECRET: ""
|
||||
|
||||
# If true, a random JWT_SECRET will be generated during install (overrides secretEnv.JWT_SECRET)
|
||||
generateJwtSecret: false
|
||||
|
||||
# If set, use an existing Kubernetes secret for JWT_SECRET
|
||||
existingSecret: ""
|
||||
existingSecretKey: JWT_SECRET
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
data:
|
||||
size: 1Gi
|
||||
uploads:
|
||||
size: 1Gi
|
||||
|
||||
resources: {}
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
annotations: {}
|
||||
hosts:
|
||||
- host: chart-example.local
|
||||
paths:
|
||||
- /
|
||||
tls: []
|
||||
# - secretName: chart-example-tls
|
||||
# hosts:
|
||||
# - chart-example.local
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "2.6.2",
|
||||
"version": "2.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-client",
|
||||
"version": "2.6.2",
|
||||
"version": "2.7.0",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+25
-2
@@ -11,6 +11,7 @@ import AdminPage from './pages/AdminPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import VacayPage from './pages/VacayPage'
|
||||
import AtlasPage from './pages/AtlasPage'
|
||||
import SharedTripPage from './pages/SharedTripPage'
|
||||
import { ToastContainer } from './components/shared/Toast'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import DemoBanner from './components/Layout/DemoBanner'
|
||||
@@ -62,16 +63,37 @@ function RootRedirect() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey } = useAuthStore()
|
||||
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
loadUser()
|
||||
}
|
||||
authApi.getAppConfig().then((config: { demo_mode?: boolean; has_maps_key?: boolean }) => {
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string }) => {
|
||||
if (config?.demo_mode) setDemoMode(true)
|
||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||
if (config?.timezone) setServerTimezone(config.timezone)
|
||||
|
||||
if (config?.version) {
|
||||
const storedVersion = localStorage.getItem('trek_app_version')
|
||||
if (storedVersion && storedVersion !== config.version) {
|
||||
try {
|
||||
if ('caches' in window) {
|
||||
const names = await caches.keys()
|
||||
await Promise.all(names.map(n => caches.delete(n)))
|
||||
}
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(regs.map(r => r.unregister()))
|
||||
}
|
||||
} catch {}
|
||||
localStorage.setItem('trek_app_version', config.version)
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
localStorage.setItem('trek_app_version', config.version)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
@@ -107,6 +129,7 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/shared/:token" element={<SharedTripPage />} />
|
||||
<Route path="/register" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
|
||||
@@ -91,6 +91,10 @@ export const placesApi = {
|
||||
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||
importGpx: (tripId: number | string, file: File) => {
|
||||
const fd = new FormData(); fd.append('file', file)
|
||||
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
@@ -108,6 +112,7 @@ export const assignmentsApi = {
|
||||
export const packingApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
||||
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||
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),
|
||||
@@ -163,6 +168,8 @@ export const adminApi = {
|
||||
listInvites: () => apiClient.get('/admin/invites').then(r => r.data),
|
||||
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
||||
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
||||
auditLog: (params?: { limit?: number; offset?: number }) =>
|
||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const addonsApi = {
|
||||
@@ -174,6 +181,7 @@ export const mapsApi = {
|
||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const budgetApi = {
|
||||
@@ -184,6 +192,7 @@ export const budgetApi = {
|
||||
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
|
||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
|
||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
@@ -197,6 +206,9 @@ export const filesApi = {
|
||||
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
|
||||
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
|
||||
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
|
||||
addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
|
||||
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
|
||||
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const reservationsApi = {
|
||||
@@ -204,6 +216,7 @@ export const reservationsApi = {
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[]) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
@@ -278,4 +291,17 @@ export const backupApi = {
|
||||
setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
|
||||
}
|
||||
|
||||
export const shareApi = {
|
||||
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/share-link`).then(r => r.data),
|
||||
createLink: (tripId: number | string, perms?: Record<string, boolean>) => apiClient.post(`/trips/${tripId}/share-link`, perms || {}).then(r => r.data),
|
||||
deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/share-link`).then(r => r.data),
|
||||
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const notificationsApi = {
|
||||
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||
updatePreferences: (prefs: Record<string, boolean>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
|
||||
@@ -3,10 +3,10 @@ import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image } from 'lucide-react'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase,
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image,
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { RefreshCw, ClipboardList } from 'lucide-react'
|
||||
|
||||
interface AuditEntry {
|
||||
id: number
|
||||
created_at: string
|
||||
user_id: number | null
|
||||
username: string | null
|
||||
user_email: string | null
|
||||
action: string
|
||||
resource: string | null
|
||||
details: Record<string, unknown> | null
|
||||
ip: string | null
|
||||
}
|
||||
|
||||
export default function AuditLogPanel(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const limit = 100
|
||||
|
||||
const loadFirstPage = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.auditLog({ limit, offset: 0 }) as {
|
||||
entries: AuditEntry[]
|
||||
total: number
|
||||
}
|
||||
setEntries(data.entries || [])
|
||||
setTotal(data.total ?? 0)
|
||||
setOffset(0)
|
||||
} catch {
|
||||
setEntries([])
|
||||
setTotal(0)
|
||||
setOffset(0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
const nextOffset = offset + limit
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.auditLog({ limit, offset: nextOffset }) as {
|
||||
entries: AuditEntry[]
|
||||
total: number
|
||||
}
|
||||
setEntries((prev) => [...prev, ...(data.entries || [])])
|
||||
setTotal(data.total ?? 0)
|
||||
setOffset(nextOffset)
|
||||
} catch {
|
||||
/* keep existing */
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [offset])
|
||||
|
||||
useEffect(() => {
|
||||
loadFirstPage()
|
||||
}, [loadFirstPage])
|
||||
|
||||
const fmtTime = (iso: string) => {
|
||||
try {
|
||||
return new Date(iso).toLocaleString(locale, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
})
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
const fmtDetails = (d: Record<string, unknown> | null) => {
|
||||
if (!d || Object.keys(d).length === 0) return '—'
|
||||
try {
|
||||
return JSON.stringify(d)
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
const userLabel = (e: AuditEntry) => {
|
||||
if (e.username) return e.username
|
||||
if (e.user_email) return e.user_email
|
||||
if (e.user_id != null) return `#${e.user_id}`
|
||||
return '—'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg m-0 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
|
||||
<ClipboardList size={20} />
|
||||
{t('admin.tabs.audit')}
|
||||
</h2>
|
||||
<p className="text-sm m-0 mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadFirstPage()}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-primary)', background: 'var(--bg-card)' }}
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
{t('admin.audit.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs m-0" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('admin.audit.showing', { count: entries.length, total })}
|
||||
</p>
|
||||
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('common.loading')}</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.empty')}</div>
|
||||
) : (
|
||||
<div className="rounded-xl border overflow-x-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
<table className="w-full text-sm border-collapse min-w-[720px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.time')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.user')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.action')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.resource')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.ip')}</th>
|
||||
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.details')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="border-b align-top" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<td className="p-3 whitespace-nowrap font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{fmtTime(e.created_at)}</td>
|
||||
<td className="p-3" style={{ color: 'var(--text-primary)' }}>{userLabel(e)}</td>
|
||||
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{e.action}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[140px]" style={{ color: 'var(--text-muted)' }}>{e.resource || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs whitespace-nowrap" style={{ color: 'var(--text-muted)' }}>{e.ip || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[280px]" style={{ color: 'var(--text-faint)' }}>{fmtDetails(e.details)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length < total && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadMore()}
|
||||
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('admin.audit.loadMore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { backupApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
@@ -21,19 +23,35 @@ const KEEP_OPTIONS = [
|
||||
{ value: 0, labelKey: 'backup.keep.forever' },
|
||||
]
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
{ value: 0, labelKey: 'backup.dow.sunday' },
|
||||
{ value: 1, labelKey: 'backup.dow.monday' },
|
||||
{ value: 2, labelKey: 'backup.dow.tuesday' },
|
||||
{ value: 3, labelKey: 'backup.dow.wednesday' },
|
||||
{ value: 4, labelKey: 'backup.dow.thursday' },
|
||||
{ value: 5, labelKey: 'backup.dow.friday' },
|
||||
{ value: 6, labelKey: 'backup.dow.saturday' },
|
||||
]
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||
|
||||
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
|
||||
|
||||
export default function BackupPanel() {
|
||||
const [backups, setBackups] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [restoringFile, setRestoringFile] = useState(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
|
||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
|
||||
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
||||
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
||||
const [serverTimezone, setServerTimezone] = useState('')
|
||||
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
||||
const fileInputRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
|
||||
const loadBackups = async () => {
|
||||
setIsLoading(true)
|
||||
@@ -51,6 +69,7 @@ export default function BackupPanel() {
|
||||
try {
|
||||
const data = await backupApi.getAutoSettings()
|
||||
setAutoSettings(data.settings)
|
||||
if (data.timezone) setServerTimezone(data.timezone)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -147,10 +166,12 @@ export default function BackupPanel() {
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString(locale, {
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
if (serverTimezone) opts.timeZone = serverTimezone
|
||||
return new Date(dateStr).toLocaleString(locale, opts)
|
||||
} catch { return dateStr }
|
||||
}
|
||||
|
||||
@@ -331,6 +352,68 @@ export default function BackupPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hour picker (for daily, weekly, monthly) */}
|
||||
{autoSettings.interval !== 'hourly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.hour)}
|
||||
onChange={v => handleAutoSettingsChange('hour', parseInt(v, 10))}
|
||||
size="sm"
|
||||
options={HOURS.map(h => {
|
||||
let label: string
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
label = `${h12}:00 ${period}`
|
||||
} else {
|
||||
label = `${String(h).padStart(2, '0')}:00`
|
||||
}
|
||||
return { value: String(h), label }
|
||||
})}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Day of week (for weekly) */}
|
||||
{autoSettings.interval === 'weekly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAYS_OF_WEEK.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
autoSettings.day_of_week === opt.value
|
||||
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Day of month (for monthly) */}
|
||||
{autoSettings.interval === 'monthly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.day_of_month)}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
|
||||
size="sm"
|
||||
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">{t('backup.auto.dayOfMonthHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keep duration */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
||||
|
||||
@@ -18,8 +18,8 @@ export default function GitHubPanel() {
|
||||
|
||||
const fetchReleases = async (pageNum = 1, append = false) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/auth/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
|
||||
const data = res.data
|
||||
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
|
||||
const data = Array.isArray(res.data) ? res.data : []
|
||||
setReleases(prev => append ? [...prev, ...data] : data)
|
||||
setHasMore(data.length === PER_PAGE)
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check } from 'lucide-react'
|
||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import type { BudgetItem, BudgetMember } from '../../types'
|
||||
@@ -29,8 +29,23 @@ interface PerPersonSummaryEntry {
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
|
||||
const SYMBOLS = { EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$' }
|
||||
const CURRENCIES = [
|
||||
'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
|
||||
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
|
||||
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
|
||||
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
|
||||
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||
]
|
||||
const SYMBOLS = {
|
||||
EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł',
|
||||
SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$',
|
||||
NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM',
|
||||
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
|
||||
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
|
||||
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
|
||||
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
|
||||
PEN: 'S/.', ARS: 'AR$',
|
||||
}
|
||||
const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
||||
|
||||
const fmtNum = (v, locale, cur) => {
|
||||
@@ -145,9 +160,11 @@ interface ChipWithTooltipProps {
|
||||
label: string
|
||||
avatarUrl: string | null
|
||||
size?: number
|
||||
paid?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps) {
|
||||
function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const ref = useRef(null)
|
||||
@@ -160,13 +177,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps)
|
||||
setHover(true)
|
||||
}
|
||||
|
||||
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
|
||||
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)',
|
||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
|
||||
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
|
||||
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}>
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
@@ -177,11 +200,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps)
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{label}
|
||||
{paid && (
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
|
||||
background: 'rgba(34,197,94,0.15)', color: '#16a34a',
|
||||
textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||
}}>Paid</span>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
@@ -194,10 +225,11 @@ interface BudgetMemberChipsProps {
|
||||
members?: BudgetMember[]
|
||||
tripMembers?: TripMember[]
|
||||
onSetMembers: (memberIds: number[]) => void
|
||||
onTogglePaid?: (userId: number, paid: boolean) => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }: BudgetMemberChipsProps) {
|
||||
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true }: BudgetMemberChipsProps) {
|
||||
const chipSize = compact ? 20 : 30
|
||||
const btnSize = compact ? 18 : 28
|
||||
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
||||
@@ -237,7 +269,10 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compa
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
{members.map(m => (
|
||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize} />
|
||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
|
||||
paid={!!m.paid}
|
||||
onClick={onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||
/>
|
||||
))}
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
@@ -376,15 +411,23 @@ interface BudgetPanelProps {
|
||||
}
|
||||
|
||||
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers } = useTripStore()
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
||||
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
|
||||
const [settlementOpen, setSettlementOpen] = useState(false)
|
||||
const currency = trip?.currency || 'EUR'
|
||||
|
||||
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
||||
const hasMultipleMembers = tripMembers.length > 1
|
||||
|
||||
// Load settlement data whenever budget items change
|
||||
useEffect(() => {
|
||||
if (!hasMultipleMembers) return
|
||||
budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
|
||||
}, [tripId, budgetItems, hasMultipleMembers])
|
||||
|
||||
const setCurrency = (cur) => {
|
||||
if (tripId) updateTrip(tripId, { currency: cur })
|
||||
}
|
||||
@@ -539,6 +582,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||
compact={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -553,6 +597,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||
/>
|
||||
) : (
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
@@ -628,6 +673,91 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
|
||||
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
|
||||
)}
|
||||
|
||||
{/* Settlement dropdown inside the total card */}
|
||||
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
|
||||
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 12 }}>
|
||||
<button onClick={() => setSettlementOpen(v => !v)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
|
||||
color: 'rgba(255,255,255,0.6)', fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||
}}>
|
||||
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
{t('budget.settlement')}
|
||||
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
|
||||
<span style={{ display: 'flex', cursor: 'help' }}
|
||||
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
|
||||
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Info size={11} strokeWidth={2} />
|
||||
</span>
|
||||
<div style={{
|
||||
display: 'none', position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||
marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
||||
fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
|
||||
}}>
|
||||
{t('budget.settlementInfo')}
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{settlementOpen && (
|
||||
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{settlement.flows.map((flow, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
padding: '8px 10px', borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
<ChipWithTooltip label={flow.from.username} avatarUrl={flow.from.avatar_url} size={28} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>→</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#f87171', whiteSpace: 'nowrap' }}>
|
||||
{fmt(flow.amount, currency)}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>→</span>
|
||||
</div>
|
||||
<ChipWithTooltip label={flow.to.username} avatarUrl={flow.to.avatar_url} size={28} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
|
||||
<div style={{ marginTop: 4, borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 8 }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'rgba(255,255,255,0.35)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
|
||||
{t('budget.netBalances')}
|
||||
</div>
|
||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => (
|
||||
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0' }}>
|
||||
<div style={{
|
||||
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
|
||||
background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 8, fontWeight: 700, color: 'rgba(255,255,255,0.6)', overflow: 'hidden',
|
||||
}}>
|
||||
{b.avatar_url
|
||||
? <img src={b.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: b.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: 11, color: 'rgba(255,255,255,0.6)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{b.username}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600, flexShrink: 0,
|
||||
color: b.balance > 0 ? '#4ade80' : '#f87171',
|
||||
}}>
|
||||
{b.balance > 0 ? '+' : ''}{fmt(b.balance, currency)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pieSegments.length > 0 && (
|
||||
@@ -641,27 +771,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
|
||||
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
||||
|
||||
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{pieSegments.map(seg => {
|
||||
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
|
||||
return (
|
||||
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap' }}>{pct}%</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(seg.value, currency)}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap', minWidth: 38, textAlign: 'right' }}>{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12, borderTop: '1px solid var(--border-secondary)', paddingTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{pieSegments.map(seg => (
|
||||
<div key={seg.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{seg.name}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600 }}>{fmt(seg.value, currency)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const CURRENCIES = [
|
||||
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
||||
|
||||
export default function CurrencyWidget() {
|
||||
const { t } = useTranslation()
|
||||
const { t, locale } = useTranslation()
|
||||
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
|
||||
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
|
||||
const [amount, setAmount] = useState('100')
|
||||
@@ -40,7 +40,7 @@ export default function CurrencyWidget() {
|
||||
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
|
||||
const formatNumber = (num) => {
|
||||
if (!num || num === '—') return '—'
|
||||
return parseFloat(num).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
const result = rawResult
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Clock, Plus, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
const POPULAR_ZONES = [
|
||||
{ label: 'New York', tz: 'America/New_York' },
|
||||
@@ -23,9 +24,9 @@ const POPULAR_ZONES = [
|
||||
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
||||
]
|
||||
|
||||
function getTime(tz, locale) {
|
||||
function getTime(tz, locale, is12h) {
|
||||
try {
|
||||
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit' })
|
||||
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||
} catch { return '—' }
|
||||
}
|
||||
|
||||
@@ -42,6 +43,7 @@ function getOffset(tz) {
|
||||
|
||||
export default function TimezoneWidget() {
|
||||
const { t, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const [zones, setZones] = useState(() => {
|
||||
const saved = localStorage.getItem('dashboard_timezones')
|
||||
return saved ? JSON.parse(saved) : [
|
||||
@@ -87,7 +89,7 @@ export default function TimezoneWidget() {
|
||||
|
||||
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
|
||||
|
||||
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
|
||||
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
|
||||
// Show abbreviated timezone name (e.g. CET, CEST, EST)
|
||||
@@ -113,7 +115,7 @@ export default function TimezoneWidget() {
|
||||
{zones.map(z => (
|
||||
<div key={z.tz} className="flex items-center justify-between group">
|
||||
<div>
|
||||
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale)}</p>
|
||||
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale, is12h)}</p>
|
||||
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
|
||||
</div>
|
||||
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
|
||||
@@ -155,7 +157,7 @@ export default function TimezoneWidget() {
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<span className="font-medium">{z.label}</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale)}</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -302,10 +302,15 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
|
||||
const renderFileRow = (file: TripFile, isTrash = false) => {
|
||||
const FileIcon = getFileIcon(file.mime_type)
|
||||
const linkedPlace = places?.find(p => p.id === file.place_id)
|
||||
const linkedReservation = file.reservation_id
|
||||
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
|
||||
: null
|
||||
const allLinkedPlaceIds = new Set<number>()
|
||||
if (file.place_id) allLinkedPlaceIds.add(file.place_id)
|
||||
for (const pid of (file.linked_place_ids || [])) allLinkedPlaceIds.add(pid)
|
||||
const linkedPlaces = [...allLinkedPlaceIds].map(pid => places?.find(p => p.id === pid)).filter(Boolean)
|
||||
// All linked reservations (primary + file_links)
|
||||
const allLinkedResIds = new Set<number>()
|
||||
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
|
||||
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
|
||||
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
|
||||
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
|
||||
|
||||
return (
|
||||
@@ -365,12 +370,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
||||
|
||||
{linkedPlace && (
|
||||
<SourceBadge icon={MapPin} label={`${t('files.sourcePlan')} · ${linkedPlace.name}`} />
|
||||
)}
|
||||
{linkedReservation && (
|
||||
<SourceBadge icon={Ticket} label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`} />
|
||||
)}
|
||||
{linkedPlaces.map(p => (
|
||||
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
||||
))}
|
||||
{linkedReservations.map(r => (
|
||||
<SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
||||
))}
|
||||
{file.note_id && (
|
||||
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
||||
)}
|
||||
@@ -477,20 +482,45 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
}
|
||||
}
|
||||
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
|
||||
const placeBtn = (p: Place) => (
|
||||
<button key={p.id} onClick={() => handleAssign(file.id, { place_id: file.place_id === p.id ? null : p.id })} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: file.place_id === p.id ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: file.place_id === p.id ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = file.place_id === p.id ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
{file.place_id === p.id && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
const placeBtn = (p: Place) => {
|
||||
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
|
||||
return (
|
||||
<button key={p.id} onClick={async () => {
|
||||
if (isLinked) {
|
||||
if (file.place_id === p.id) {
|
||||
await handleAssign(file.id, { place_id: null })
|
||||
} else {
|
||||
try {
|
||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||
const link = (linksRes.links || []).find((l: any) => l.place_id === p.id)
|
||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
if (!file.place_id) {
|
||||
await handleAssign(file.id, { place_id: p.id })
|
||||
} else {
|
||||
try {
|
||||
await filesApi.addLink(tripId, file.id, { place_id: p.id })
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const placesSection = places.length > 0 && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -519,20 +549,47 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{t('files.assignBooking')}
|
||||
</div>
|
||||
{reservations.map(r => (
|
||||
<button key={r.id} onClick={() => handleAssign(file.id, { reservation_id: file.reservation_id === r.id ? null : r.id })} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: file.reservation_id === r.id ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: file.reservation_id === r.id ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = file.reservation_id === r.id ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
||||
{file.reservation_id === r.id && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
))}
|
||||
{reservations.map(r => {
|
||||
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
||||
return (
|
||||
<button key={r.id} onClick={async () => {
|
||||
if (isLinked) {
|
||||
// Unlink: if primary reservation_id, clear it; if via file_links, remove link
|
||||
if (file.reservation_id === r.id) {
|
||||
await handleAssign(file.id, { reservation_id: null })
|
||||
} else {
|
||||
try {
|
||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
|
||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
// Link: if no primary, set it; otherwise use file_links
|
||||
if (!file.reservation_id) {
|
||||
await handleAssign(file.id, { reservation_id: r.id })
|
||||
} else {
|
||||
try {
|
||||
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, useMemo } from 'react'
|
||||
import { useEffect, useRef, useState, useMemo, useCallback } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
@@ -65,7 +65,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
cursor:pointer;flex-shrink:0;position:relative;
|
||||
">
|
||||
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
||||
<img src="${escAttr(place.image_url)}" style="width:100%;height:100%;object-fit:cover;" />
|
||||
<img src="${escAttr(place.image_url)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" />
|
||||
</div>
|
||||
${badgeHtml}
|
||||
</div>`,
|
||||
@@ -240,6 +240,96 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
||||
const mapPhotoCache = new Map()
|
||||
const mapPhotoInFlight = new Set()
|
||||
|
||||
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
|
||||
function LocationTracker() {
|
||||
const map = useMap()
|
||||
const [position, setPosition] = useState<[number, number] | null>(null)
|
||||
const [accuracy, setAccuracy] = useState(0)
|
||||
const [tracking, setTracking] = useState(false)
|
||||
const watchId = useRef<number | null>(null)
|
||||
|
||||
const startTracking = useCallback(() => {
|
||||
if (!('geolocation' in navigator)) return
|
||||
setTracking(true)
|
||||
watchId.current = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
const latlng: [number, number] = [pos.coords.latitude, pos.coords.longitude]
|
||||
setPosition(latlng)
|
||||
setAccuracy(pos.coords.accuracy)
|
||||
},
|
||||
() => setTracking(false),
|
||||
{ enableHighAccuracy: true, maximumAge: 5000 }
|
||||
)
|
||||
}, [])
|
||||
|
||||
const stopTracking = useCallback(() => {
|
||||
if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current)
|
||||
watchId.current = null
|
||||
setTracking(false)
|
||||
setPosition(null)
|
||||
}, [])
|
||||
|
||||
const toggleTracking = useCallback(() => {
|
||||
if (tracking) { stopTracking() } else { startTracking() }
|
||||
}, [tracking, startTracking, stopTracking])
|
||||
|
||||
// Center map on position when first acquired
|
||||
const centered = useRef(false)
|
||||
useEffect(() => {
|
||||
if (position && !centered.current) {
|
||||
map.setView(position, 15)
|
||||
centered.current = true
|
||||
}
|
||||
}, [position, map])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Location button */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 20, right: 10, zIndex: 1000,
|
||||
}}>
|
||||
<button onClick={toggleTracking} style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
border: 'none', cursor: 'pointer',
|
||||
background: tracking ? '#3b82f6' : 'var(--bg-card, white)',
|
||||
color: tracking ? 'white' : 'var(--text-muted, #6b7280)',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background 0.2s, color 0.2s',
|
||||
}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Blue dot + accuracy circle */}
|
||||
{position && (
|
||||
<>
|
||||
{accuracy < 500 && (
|
||||
<Circle center={position} radius={accuracy} pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.06, weight: 0.5, opacity: 0.3 }} />
|
||||
)}
|
||||
<CircleMarker center={position} radius={7} pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 2.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pulse animation CSS */}
|
||||
{position && (
|
||||
<style>{`
|
||||
@keyframes location-pulse {
|
||||
0% { transform: scale(1); opacity: 0.6; }
|
||||
100% { transform: scale(2.5); opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
@@ -270,33 +360,48 @@ export function MapView({
|
||||
}, [leftWidth, rightWidth, hasInspector])
|
||||
const [photoUrls, setPhotoUrls] = useState({})
|
||||
|
||||
// Fetch photos for places (Google or Wikimedia Commons fallback)
|
||||
// Fetch photos for places with concurrency limit to avoid blocking map rendering
|
||||
useEffect(() => {
|
||||
places.forEach(place => {
|
||||
if (place.image_url) return
|
||||
const queue = places.filter(place => {
|
||||
if (place.image_url) return false
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
if (!cacheKey) return
|
||||
if (!cacheKey) return false
|
||||
if (mapPhotoCache.has(cacheKey)) {
|
||||
const cached = mapPhotoCache.get(cacheKey)
|
||||
if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached }))
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (mapPhotoInFlight.has(cacheKey)) return
|
||||
if (mapPhotoInFlight.has(cacheKey)) return false
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
if (!photoId && !(place.lat && place.lng)) return
|
||||
mapPhotoInFlight.add(cacheKey)
|
||||
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
.then(data => {
|
||||
if (data.photoUrl) {
|
||||
mapPhotoCache.set(cacheKey, data.photoUrl)
|
||||
setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl }))
|
||||
} else {
|
||||
mapPhotoCache.set(cacheKey, null)
|
||||
}
|
||||
mapPhotoInFlight.delete(cacheKey)
|
||||
})
|
||||
.catch(() => { mapPhotoCache.set(cacheKey, null); mapPhotoInFlight.delete(cacheKey) })
|
||||
if (!photoId && !(place.lat && place.lng)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
let active = 0
|
||||
const MAX_CONCURRENT = 3
|
||||
let idx = 0
|
||||
|
||||
const fetchNext = () => {
|
||||
while (active < MAX_CONCURRENT && idx < queue.length) {
|
||||
const place = queue[idx++]
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
mapPhotoInFlight.add(cacheKey)
|
||||
active++
|
||||
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
.then(data => {
|
||||
if (data.photoUrl) {
|
||||
mapPhotoCache.set(cacheKey, data.photoUrl)
|
||||
setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl }))
|
||||
} else {
|
||||
mapPhotoCache.set(cacheKey, null)
|
||||
}
|
||||
})
|
||||
.catch(() => { mapPhotoCache.set(cacheKey, null) })
|
||||
.finally(() => { mapPhotoInFlight.delete(cacheKey); active--; fetchNext() })
|
||||
}
|
||||
}
|
||||
fetchNext()
|
||||
}, [places])
|
||||
|
||||
return (
|
||||
@@ -318,6 +423,7 @@ export function MapView({
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<LocationTracker />
|
||||
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter } from 'lucide-react'
|
||||
import apiClient from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TripPhoto {
|
||||
immich_asset_id: string
|
||||
user_id: number
|
||||
username: string
|
||||
shared: number
|
||||
added_at: string
|
||||
}
|
||||
|
||||
interface ImmichAsset {
|
||||
id: string
|
||||
takenAt: string
|
||||
city: string | null
|
||||
country: string | null
|
||||
}
|
||||
|
||||
interface MemoriesPanelProps {
|
||||
tripId: number
|
||||
startDate: string | null
|
||||
endDate: string | null
|
||||
}
|
||||
|
||||
// ── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
const currentUser = useAuthStore(s => s.user)
|
||||
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Trip photos (saved selections)
|
||||
const [tripPhotos, setTripPhotos] = useState<TripPhoto[]>([])
|
||||
|
||||
// Photo picker
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [pickerPhotos, setPickerPhotos] = useState<ImmichAsset[]>([])
|
||||
const [pickerLoading, setPickerLoading] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// Confirm share popup
|
||||
const [showConfirmShare, setShowConfirmShare] = useState(false)
|
||||
|
||||
// Filters & sort
|
||||
const [sortAsc, setSortAsc] = useState(true)
|
||||
const [locationFilter, setLocationFilter] = useState('')
|
||||
|
||||
// Lightbox
|
||||
const [lightboxId, setLightboxId] = useState<string | null>(null)
|
||||
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
|
||||
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
|
||||
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
loadInitial()
|
||||
}, [tripId])
|
||||
|
||||
// WebSocket: reload photos when another user adds/removes/shares
|
||||
useEffect(() => {
|
||||
const handler = () => loadPhotos()
|
||||
window.addEventListener('memories:updated', handler)
|
||||
return () => window.removeEventListener('memories:updated', handler)
|
||||
}, [tripId])
|
||||
|
||||
const loadPhotos = async () => {
|
||||
try {
|
||||
const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`)
|
||||
setTripPhotos(photosRes.data.photos || [])
|
||||
} catch {
|
||||
setTripPhotos([])
|
||||
}
|
||||
}
|
||||
|
||||
const loadInitial = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const statusRes = await apiClient.get('/integrations/immich/status')
|
||||
setConnected(statusRes.data.connected)
|
||||
} catch {
|
||||
setConnected(false)
|
||||
}
|
||||
await loadPhotos()
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// ── Photo Picker ──────────────────────────────────────────────────────────
|
||||
|
||||
const [pickerDateFilter, setPickerDateFilter] = useState(true)
|
||||
|
||||
const openPicker = async () => {
|
||||
setShowPicker(true)
|
||||
setPickerLoading(true)
|
||||
setSelectedIds(new Set())
|
||||
setPickerDateFilter(!!(startDate && endDate))
|
||||
await loadPickerPhotos(!!(startDate && endDate))
|
||||
}
|
||||
|
||||
const loadPickerPhotos = async (useDate: boolean) => {
|
||||
setPickerLoading(true)
|
||||
try {
|
||||
const res = await apiClient.post('/integrations/immich/search', {
|
||||
from: useDate && startDate ? startDate : undefined,
|
||||
to: useDate && endDate ? endDate : undefined,
|
||||
})
|
||||
setPickerPhotos(res.data.assets || [])
|
||||
} catch {
|
||||
setPickerPhotos([])
|
||||
} finally {
|
||||
setPickerLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePickerSelect = (id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const confirmSelection = () => {
|
||||
if (selectedIds.size === 0) return
|
||||
setShowConfirmShare(true)
|
||||
}
|
||||
|
||||
const executeAddPhotos = async () => {
|
||||
setShowConfirmShare(false)
|
||||
try {
|
||||
await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, {
|
||||
asset_ids: [...selectedIds],
|
||||
shared: true,
|
||||
})
|
||||
setShowPicker(false)
|
||||
loadInitial()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Remove photo ──────────────────────────────────────────────────────────
|
||||
|
||||
const removePhoto = async (assetId: string) => {
|
||||
try {
|
||||
await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
|
||||
setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Toggle sharing ────────────────────────────────────────────────────────
|
||||
|
||||
const toggleSharing = async (assetId: string, shared: boolean) => {
|
||||
try {
|
||||
await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared })
|
||||
setTripPhotos(prev => prev.map(p =>
|
||||
p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
|
||||
))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const token = useAuthStore(s => s.token)
|
||||
|
||||
const thumbnailUrl = (assetId: string, userId: number) =>
|
||||
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}&token=${token}`
|
||||
|
||||
const originalUrl = (assetId: string, userId: number) =>
|
||||
`/api/integrations/immich/assets/${assetId}/original?userId=${userId}&token=${token}`
|
||||
|
||||
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
|
||||
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
|
||||
const allVisibleRaw = [...ownPhotos, ...othersPhotos]
|
||||
|
||||
// Unique locations for filter
|
||||
const locations = [...new Set(allVisibleRaw.map(p => p.city).filter(Boolean) as string[])].sort()
|
||||
|
||||
// Apply filter + sort
|
||||
const allVisible = allVisibleRaw
|
||||
.filter(p => !locationFilter || p.city === locationFilter)
|
||||
.sort((a, b) => {
|
||||
const da = new Date(a.added_at || 0).getTime()
|
||||
const db = new Date(b.added_at || 0).getTime()
|
||||
return sortAsc ? da - db : db - da
|
||||
})
|
||||
|
||||
const font: React.CSSProperties = {
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
}
|
||||
|
||||
// ── Loading ───────────────────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', ...font }}>
|
||||
<div className="w-8 h-8 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Not connected ─────────────────────────────────────────────────────────
|
||||
|
||||
if (!connected && allVisible.length === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: 40, textAlign: 'center', ...font }}>
|
||||
<Camera size={40} style={{ color: 'var(--text-faint)', marginBottom: 12 }} />
|
||||
<h3 style={{ margin: '0 0 6px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{t('memories.notConnected')}
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)', maxWidth: 300 }}>
|
||||
{t('memories.notConnectedHint')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Photo Picker Modal ────────────────────────────────────────────────────
|
||||
|
||||
if (showPicker) {
|
||||
const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||
{/* Picker header */}
|
||||
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{t('memories.selectPhotos')}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => setShowPicker(false)}
|
||||
style={{ padding: '7px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={confirmSelection} disabled={selectedIds.size === 0}
|
||||
style={{
|
||||
padding: '7px 14px', borderRadius: 10, border: 'none', fontSize: 12, fontWeight: 600,
|
||||
cursor: selectedIds.size > 0 ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||
background: selectedIds.size > 0 ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: selectedIds.size > 0 ? 'var(--bg-primary)' : 'var(--text-faint)',
|
||||
}}>
|
||||
{selectedIds.size > 0 ? t('memories.addSelected', { count: selectedIds.size }) : t('memories.addPhotos')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Filter tabs */}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{startDate && endDate && (
|
||||
<button onClick={() => { if (!pickerDateFilter) { setPickerDateFilter(true); loadPickerPhotos(true) } }}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 99, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
border: '1px solid', transition: 'all 0.15s',
|
||||
background: pickerDateFilter ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
{t('memories.tripDates')} ({startDate ? new Date(startDate + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) : ''} — {endDate ? new Date(endDate + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : ''})
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => { if (pickerDateFilter || !startDate) { setPickerDateFilter(false); loadPickerPhotos(false) } }}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 99, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
border: '1px solid', transition: 'all 0.15s',
|
||||
background: !pickerDateFilter ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: !pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: !pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
{t('memories.allPhotos')}
|
||||
</button>
|
||||
</div>
|
||||
{selectedIds.size > 0 && (
|
||||
<p style={{ margin: '8px 0 0', fontSize: 12, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{selectedIds.size} {t('memories.selected')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Picker grid */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||
{pickerLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 60 }}>
|
||||
<div className="w-7 h-7 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
) : pickerPhotos.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
<Camera size={36} style={{ color: 'var(--text-faint)', margin: '0 auto 10px', display: 'block' }} />
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>{t('memories.noPhotos')}</p>
|
||||
</div>
|
||||
) : (() => {
|
||||
// Group photos by month
|
||||
const byMonth: Record<string, ImmichAsset[]> = {}
|
||||
for (const asset of pickerPhotos) {
|
||||
const d = asset.takenAt ? new Date(asset.takenAt) : null
|
||||
const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown'
|
||||
if (!byMonth[key]) byMonth[key] = []
|
||||
byMonth[key].push(asset)
|
||||
}
|
||||
const sortedMonths = Object.keys(byMonth).sort().reverse()
|
||||
|
||||
return sortedMonths.map(month => (
|
||||
<div key={month} style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-muted)', marginBottom: 6, paddingLeft: 2 }}>
|
||||
{month !== 'unknown'
|
||||
? new Date(month + '-15').toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||
: '—'}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gap: 4 }}>
|
||||
{byMonth[month].map(asset => {
|
||||
const isSelected = selectedIds.has(asset.id)
|
||||
const isAlready = alreadyAdded.has(asset.id)
|
||||
return (
|
||||
<div key={asset.id}
|
||||
onClick={() => !isAlready && togglePickerSelect(asset.id)}
|
||||
style={{
|
||||
position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden',
|
||||
cursor: isAlready ? 'default' : 'pointer',
|
||||
opacity: isAlready ? 0.3 : 1,
|
||||
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
|
||||
outlineOffset: -3,
|
||||
}}>
|
||||
<img src={thumbnailUrl(asset.id, currentUser!.id)} alt="" loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
{isSelected && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 4, right: 4, width: 22, height: 22, borderRadius: '50%',
|
||||
background: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Check size={13} color="var(--bg-primary)" />
|
||||
</div>
|
||||
)}
|
||||
{isAlready && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', fontSize: 10, color: 'white', fontWeight: 600,
|
||||
}}>
|
||||
{t('memories.alreadyAdded')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm share popup (inside picker) */}
|
||||
{showConfirmShare && (
|
||||
<div onClick={() => setShowConfirmShare(false)}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
||||
<div onClick={e => e.stopPropagation()}
|
||||
style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>
|
||||
<Share2 size={28} style={{ color: 'var(--text-primary)', marginBottom: 12 }} />
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{t('memories.confirmShareTitle')}
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||
{t('memories.confirmShareHint', { count: selectedIds.size })}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||
<button onClick={() => setShowConfirmShare(false)}
|
||||
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={executeAddPhotos}
|
||||
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||
{t('memories.confirmShareButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Gallery ──────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{t('memories.title')}
|
||||
</h2>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>
|
||||
{allVisible.length} {t('memories.photosFound')}
|
||||
{othersPhotos.length > 0 && ` · ${othersPhotos.length} ${t('memories.fromOthers')}`}
|
||||
</p>
|
||||
</div>
|
||||
{connected && (
|
||||
<button onClick={openPicker}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
|
||||
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={14} /> {t('memories.addPhotos')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter & Sort bar */}
|
||||
{allVisibleRaw.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, padding: '8px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
<button onClick={() => setSortAsc(v => !v)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-card)',
|
||||
fontSize: 11, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)',
|
||||
}}>
|
||||
<ArrowUpDown size={11} /> {sortAsc ? t('memories.oldest') : t('memories.newest')}
|
||||
</button>
|
||||
{locations.length > 1 && (
|
||||
<select value={locationFilter} onChange={e => setLocationFilter(e.target.value)}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-card)', fontSize: 11, fontFamily: 'inherit', color: 'var(--text-muted)',
|
||||
cursor: 'pointer', outline: 'none',
|
||||
}}>
|
||||
<option value="">{t('memories.allLocations')}</option>
|
||||
{locations.map(loc => <option key={loc} value={loc}>{loc}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||
{allVisible.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
<Camera size={40} style={{ color: 'var(--text-faint)', margin: '0 auto 12px', display: 'block' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>
|
||||
{t('memories.noPhotos')}
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '0 0 16px' }}>
|
||||
{t('memories.noPhotosHint')}
|
||||
</p>
|
||||
<button onClick={openPicker}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '9px 18px', borderRadius: 10,
|
||||
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={15} /> {t('memories.addPhotos')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: 6 }}>
|
||||
{allVisible.map(photo => {
|
||||
const isOwn = photo.user_id === currentUser?.id
|
||||
return (
|
||||
<div key={photo.immich_asset_id} className="group"
|
||||
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||
setLightboxInfoLoading(true)
|
||||
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
|
||||
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
||||
}}>
|
||||
|
||||
<img src={thumbnailUrl(photo.immich_asset_id, photo.user_id)} alt="" loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
|
||||
|
||||
{/* Other user's avatar */}
|
||||
{!isOwn && (
|
||||
<div className="memories-avatar" style={{ position: 'absolute', bottom: 6, left: 6, zIndex: 10 }}>
|
||||
<div style={{
|
||||
width: 22, height: 22, borderRadius: '50%',
|
||||
background: `hsl(${photo.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
|
||||
border: '2px solid white', boxShadow: '0 1px 4px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
{photo.username[0]}
|
||||
</div>
|
||||
<div className="memories-avatar-tooltip" style={{
|
||||
position: 'absolute', bottom: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||
marginBottom: 6, padding: '3px 8px', borderRadius: 6,
|
||||
background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||
fontSize: 10, fontWeight: 600, whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none', opacity: 0, transition: 'opacity 0.15s',
|
||||
}}>
|
||||
{photo.username}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Own photo actions (hover) */}
|
||||
{isOwn && (
|
||||
<div className="opacity-0 group-hover:opacity-100"
|
||||
style={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 3, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => { e.stopPropagation(); toggleSharing(photo.immich_asset_id, !photo.shared) }}
|
||||
title={photo.shared ? t('memories.stopSharing') : t('memories.sharePhotos')}
|
||||
style={{
|
||||
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{photo.shared ? <Eye size={12} color="white" /> : <EyeOff size={12} color="white" />}
|
||||
</button>
|
||||
<button onClick={e => { e.stopPropagation(); removePhoto(photo.immich_asset_id) }}
|
||||
style={{
|
||||
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<X size={12} color="white" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Not shared indicator */}
|
||||
{isOwn && !photo.shared && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 6, right: 6, padding: '2px 6px', borderRadius: 6,
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
fontSize: 9, color: 'rgba(255,255,255,0.7)', fontWeight: 500,
|
||||
}}>
|
||||
<EyeOff size={9} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
|
||||
{t('memories.private')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.memories-avatar:hover .memories-avatar-tooltip { opacity: 1 !important; }
|
||||
`}</style>
|
||||
|
||||
{/* Confirm share popup */}
|
||||
{showConfirmShare && (
|
||||
<div onClick={() => setShowConfirmShare(false)}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
||||
<div onClick={e => e.stopPropagation()}
|
||||
style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>
|
||||
<Share2 size={28} style={{ color: 'var(--text-primary)', marginBottom: 12 }} />
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{t('memories.confirmShareTitle')}
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||
{t('memories.confirmShareHint', { count: selectedIds.size })}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||
<button onClick={() => setShowConfirmShare(false)}
|
||||
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={executeAddPhotos}
|
||||
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||
{t('memories.confirmShareButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightboxId && lightboxUserId && (
|
||||
<div onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
|
||||
style={{
|
||||
position: 'absolute', inset: 0, zIndex: 100,
|
||||
background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<button onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
|
||||
style={{
|
||||
position: 'absolute', top: 16, right: 16, width: 40, height: 40, borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.1)', border: 'none', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<X size={20} color="white" />
|
||||
</button>
|
||||
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
|
||||
<img
|
||||
src={originalUrl(lightboxId, lightboxUserId)}
|
||||
alt=""
|
||||
style={{ maxWidth: lightboxInfo ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
|
||||
/>
|
||||
|
||||
{/* Info panel — liquid glass */}
|
||||
{lightboxInfo && (
|
||||
<div style={{
|
||||
width: 240, flexShrink: 0, borderRadius: 16, padding: 18,
|
||||
background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)', color: 'white',
|
||||
display: 'flex', flexDirection: 'column', gap: 14, maxHeight: '100%', overflowY: 'auto',
|
||||
}}>
|
||||
{/* Date */}
|
||||
{lightboxInfo.takenAt && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>Date</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{new Date(lightboxInfo.takenAt).toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })}</div>
|
||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)' }}>{new Date(lightboxInfo.takenAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{(lightboxInfo.city || lightboxInfo.country) && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>
|
||||
<MapPin size={9} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />Location
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
{[lightboxInfo.city, lightboxInfo.state, lightboxInfo.country].filter(Boolean).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Camera */}
|
||||
{lightboxInfo.camera && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>Camera</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 500 }}>{lightboxInfo.camera}</div>
|
||||
{lightboxInfo.lens && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', marginTop: 2 }}>{lightboxInfo.lens}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
{(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
{lightboxInfo.focalLength && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Focal</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.focalLength}</div>
|
||||
</div>
|
||||
)}
|
||||
{lightboxInfo.aperture && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Aperture</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.aperture}</div>
|
||||
</div>
|
||||
)}
|
||||
{lightboxInfo.shutter && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Shutter</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.shutter}</div>
|
||||
</div>
|
||||
)}
|
||||
{lightboxInfo.iso && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>ISO</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.iso}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resolution & File */}
|
||||
{(lightboxInfo.width || lightboxInfo.fileName) && (
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 10 }}>
|
||||
{lightboxInfo.width && lightboxInfo.height && (
|
||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>{lightboxInfo.width} × {lightboxInfo.height}</div>
|
||||
)}
|
||||
{lightboxInfo.fileSize && (
|
||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)' }}>{(lightboxInfo.fileSize / 1024 / 1024).toFixed(1)} MB</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lightboxInfoLoading && (
|
||||
<div style={{ width: 240, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div className="w-6 h-6 border-2 rounded-full animate-spin" style={{ borderColor: 'rgba(255,255,255,0.2)', borderTopColor: 'white' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,13 @@ function noteIconSvg(iconId) {
|
||||
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }))
|
||||
}
|
||||
|
||||
const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||
function transportIconSvg(type) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
const Icon = TRANSPORT_ICON_MAP[type] || Ticket
|
||||
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' }))
|
||||
}
|
||||
|
||||
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
|
||||
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
|
||||
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
|
||||
@@ -96,13 +103,14 @@ interface downloadTripPDFProps {
|
||||
assignments: AssignmentsMap
|
||||
categories: Category[]
|
||||
dayNotes: DayNotesMap
|
||||
reservations?: any[]
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
locale: string
|
||||
}
|
||||
|
||||
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }: downloadTripPDFProps) {
|
||||
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, reservations = [], t: _t, locale: _locale }: downloadTripPDFProps) {
|
||||
await ensureRenderer()
|
||||
const loc = _locale || 'de-DE'
|
||||
const loc = _locale || undefined
|
||||
const tr = _t || (k => k)
|
||||
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
|
||||
const range = longDateRange(sorted, loc)
|
||||
@@ -123,15 +131,46 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||
const cost = dayCost(assignments, day.id, loc)
|
||||
|
||||
// Transport bookings for this day
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
const dayTransport = (reservations || []).filter(r => {
|
||||
if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false
|
||||
return day.date && r.reservation_time.split('T')[0] === day.date
|
||||
})
|
||||
|
||||
const merged = []
|
||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||
dayTransport.forEach(r => {
|
||||
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||
merged.push({ type: 'transport', k: pos, data: r })
|
||||
})
|
||||
merged.sort((a, b) => a.k - b.k)
|
||||
|
||||
let pi = 0
|
||||
const itemsHtml = merged.length === 0
|
||||
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
|
||||
: merged.map(item => {
|
||||
if (item.type === 'transport') {
|
||||
const r = item.data
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
const icon = transportIconSvg(r.type)
|
||||
let subtitle = ''
|
||||
if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||
return `
|
||||
<div class="note-card" style="border-left: 3px solid #3b82f6;">
|
||||
<div class="note-line" style="background: #3b82f6;"></div>
|
||||
<span class="note-icon">${icon}</span>
|
||||
<div class="note-body">
|
||||
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
||||
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
if (item.type === 'note') {
|
||||
const note = item.data
|
||||
return `
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { packingApi, tripsApi, adminApi } from '../../api/client'
|
||||
import ReactDOM from 'react-dom'
|
||||
import {
|
||||
CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
|
||||
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus,
|
||||
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, Upload,
|
||||
} from 'lucide-react'
|
||||
import type { PackingItem } from '../../types'
|
||||
|
||||
@@ -651,9 +652,13 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
|
||||
const handleAddNewCategory = async () => {
|
||||
if (!newCatName.trim()) return
|
||||
// Create a first item in the new category to make it appear
|
||||
let catName = newCatName.trim()
|
||||
// Allow duplicate display names — append invisible zero-width spaces to make unique internally
|
||||
while (allCategories.includes(catName)) {
|
||||
catName += '\u200B'
|
||||
}
|
||||
try {
|
||||
await addPackingItem(tripId, { name: '...', category: newCatName.trim() })
|
||||
await addPackingItem(tripId, { name: '...', category: catName })
|
||||
setNewCatName('')
|
||||
setAddingCategory(false)
|
||||
} catch { toast.error(t('packing.toast.addError')) }
|
||||
@@ -723,6 +728,9 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([])
|
||||
const [showTemplateDropdown, setShowTemplateDropdown] = useState(false)
|
||||
const [applyingTemplate, setApplyingTemplate] = useState(false)
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
const [importText, setImportText] = useState('')
|
||||
const csvInputRef = useRef<HTMLInputElement>(null)
|
||||
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -753,6 +761,44 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
}
|
||||
}
|
||||
|
||||
const parseImportLines = (text: string) => {
|
||||
return text.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
|
||||
// Format: Category, Name, Weight (optional), Bag (optional), checked/unchecked (optional)
|
||||
const parts = line.split(/[,;\t]/).map(s => s.trim())
|
||||
if (parts.length >= 2) {
|
||||
const category = parts[0]
|
||||
const name = parts[1]
|
||||
const weight_grams = parts[2] || undefined
|
||||
const bag = parts[3] || undefined
|
||||
const checked = parts[4]?.toLowerCase() === 'checked' || parts[4] === '1'
|
||||
return { name, category, weight_grams, bag, checked }
|
||||
}
|
||||
// Single value = just a name
|
||||
return { name: parts[0], category: undefined, weight_grams: undefined, bag: undefined, checked: false }
|
||||
}).filter(i => i.name)
|
||||
}
|
||||
|
||||
const handleBulkImport = async () => {
|
||||
const parsed = parseImportLines(importText)
|
||||
if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return }
|
||||
try {
|
||||
const result = await packingApi.bulkImport(tripId, parsed)
|
||||
toast.success(t('packing.importSuccess', { count: result.count }))
|
||||
setImportText('')
|
||||
setShowImportModal(false)
|
||||
window.location.reload()
|
||||
} catch { toast.error(t('packing.importError')) }
|
||||
}
|
||||
|
||||
const handleCsvFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => { if (typeof reader.result === 'string') setImportText(reader.result) }
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||
|
||||
return (
|
||||
@@ -777,6 +823,13 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowImportModal(true)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
||||
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
|
||||
}}>
|
||||
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
||||
</button>
|
||||
{availableTemplates.length > 0 && (
|
||||
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
|
||||
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
|
||||
@@ -1098,6 +1151,60 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
.assignee-chip:hover + .assignee-tooltip { opacity: 1 !important; }
|
||||
.assignee-chip:hover { opacity: 0.7; }
|
||||
`}</style>
|
||||
|
||||
{/* Bulk Import Modal */}
|
||||
{showImportModal && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => setShowImportModal(false)}>
|
||||
<div style={{
|
||||
width: 420, maxHeight: '80vh', background: 'var(--bg-card)', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 14,
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('packing.importTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', lineHeight: 1.5 }}>{t('packing.importHint')}</div>
|
||||
<textarea
|
||||
value={importText}
|
||||
onChange={e => setImportText(e.target.value)}
|
||||
rows={10}
|
||||
placeholder={t('packing.importPlaceholder')}
|
||||
style={{
|
||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
padding: '10px 12px', fontSize: 13, fontFamily: 'monospace',
|
||||
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)',
|
||||
background: 'var(--bg-input)', resize: 'vertical', lineHeight: 1.5,
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<input ref={csvInputRef} type="file" accept=".csv,.txt" style={{ display: 'none' }} onChange={handleCsvFile} />
|
||||
<button onClick={() => csvInputRef.current?.click()} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Upload size={11} /> {t('packing.importCsv')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => setShowImportModal(false)} style={{
|
||||
fontSize: 12, background: 'none', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit',
|
||||
}}>{t('common.cancel')}</button>
|
||||
<button onClick={handleBulkImport} disabled={!importText.trim()} style={{
|
||||
fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600,
|
||||
fontFamily: 'inherit', opacity: importText.trim() ? 1 : 0.5,
|
||||
}}>{t('packing.importAction', { count: parseImportLines(importText).length })}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,12 +50,15 @@ interface DayDetailPanelProps {
|
||||
lng: number | null
|
||||
onClose: () => void
|
||||
onAccommodationChange: () => void
|
||||
leftWidth?: number
|
||||
rightWidth?: number
|
||||
}
|
||||
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }: DayDetailPanelProps) {
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0 }: DayDetailPanelProps) {
|
||||
const { t, language, locale } = useTranslation()
|
||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||
const fmtTime = (v) => formatTime12(v, is12h)
|
||||
const unit = isFahrenheit ? '°F' : '°C'
|
||||
const [weather, setWeather] = useState(null)
|
||||
@@ -146,7 +149,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', bottom: 20, left: '50%', transform: 'translateX(-50%)', width: 'min(800px, calc(100vw - 32px))', zIndex: 50, ...font }}>
|
||||
<div style={{ position: 'fixed', bottom: 20, left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, zIndex: 50, ...font }}>
|
||||
<div style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
backdropFilter: 'blur(40px) saturate(180%)',
|
||||
@@ -368,7 +371,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.title}</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text-faint)', display: 'flex', gap: 6, marginTop: 1 }}>
|
||||
<span>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
||||
{linked.confirmation_number && <span>#{linked.confirmation_number}</span>}
|
||||
{linked.confirmation_number && <span
|
||||
onMouseEnter={e => { if (blurCodes) e.currentTarget.style.filter = 'none' }}
|
||||
onMouseLeave={e => { if (blurCodes) e.currentTarget.style.filter = 'blur(4px)' }}
|
||||
onClick={e => { if (blurCodes) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(4px)' : 'none' } }}
|
||||
style={{ filter: blurCodes ? 'blur(4px)' : 'none', transition: 'filter 0.2s', cursor: blurCodes ? 'pointer' : 'default' }}
|
||||
>#{linked.confirmation_number}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
|
||||
|
||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
@@ -74,6 +75,7 @@ interface DayPlanSidebarProps {
|
||||
onDeletePlace: (placeId: number) => void
|
||||
reservations?: Reservation[]
|
||||
onAddReservation: () => void
|
||||
onNavigateToFiles?: () => void
|
||||
}
|
||||
|
||||
export default function DayPlanSidebar({
|
||||
@@ -85,6 +87,7 @@ export default function DayPlanSidebar({
|
||||
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
reservations = [],
|
||||
onAddReservation,
|
||||
onNavigateToFiles,
|
||||
}: DayPlanSidebarProps) {
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
@@ -108,11 +111,22 @@ export default function DayPlanSidebar({
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [lockedIds, setLockedIds] = useState(new Set())
|
||||
const [lockHoverId, setLockHoverId] = useState(null)
|
||||
const [dropTargetKey, setDropTargetKey] = useState(null)
|
||||
const [dropTargetKey, _setDropTargetKey] = useState(null)
|
||||
const dropTargetRef = useRef(null)
|
||||
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
|
||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [transportDetail, setTransportDetail] = useState(null)
|
||||
const [timeConfirm, setTimeConfirm] = useState<{
|
||||
dayId: number; fromId: number; time: string;
|
||||
// For drag & drop reorder
|
||||
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean;
|
||||
// For arrow reorder
|
||||
reorderIds?: number[];
|
||||
} | null>(null)
|
||||
const inputRef = useRef(null)
|
||||
const dragDataRef = useRef(null) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
|
||||
const dragDataRef = useRef(null)
|
||||
const initedTransportIds = useRef(new Set<number>()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
|
||||
|
||||
const currency = trip?.currency || 'EUR'
|
||||
|
||||
@@ -176,16 +190,137 @@ export default function DayPlanSidebar({
|
||||
})
|
||||
}
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
|
||||
const getTransportForDay = (dayId: number) => {
|
||||
const day = days.find(d => d.id === dayId)
|
||||
if (!day?.date) return []
|
||||
return reservations.filter(r => {
|
||||
if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false
|
||||
const resDate = r.reservation_time.split('T')[0]
|
||||
return resDate === day.date
|
||||
})
|
||||
}
|
||||
|
||||
const getDayAssignments = (dayId) =>
|
||||
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
|
||||
// Helper: parse time string ("HH:MM" or ISO) to minutes since midnight, or null
|
||||
const parseTimeToMinutes = (time?: string | null): number | null => {
|
||||
if (!time) return null
|
||||
// ISO-Format "2025-03-30T09:00:00"
|
||||
if (time.includes('T')) {
|
||||
const [h, m] = time.split('T')[1].split(':').map(Number)
|
||||
return h * 60 + m
|
||||
}
|
||||
// Einfaches "HH:MM" Format
|
||||
const parts = time.split(':').map(Number)
|
||||
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
|
||||
return null
|
||||
}
|
||||
|
||||
// Compute initial day_plan_position for a transport based on time
|
||||
const computeTransportPosition = (r, da) => {
|
||||
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
|
||||
// Find the last place with time <= transport time
|
||||
let afterIdx = -1
|
||||
for (const a of da) {
|
||||
const pm = parseTimeToMinutes(a.place?.place_time)
|
||||
if (pm !== null && pm <= minutes) afterIdx = a.order_index
|
||||
}
|
||||
// Position: midpoint between afterIdx and afterIdx+1 (leaves room for other items)
|
||||
return afterIdx >= 0 ? afterIdx + 0.5 : da.length + 0.5
|
||||
}
|
||||
|
||||
// Auto-initialize transport positions on first render if not set
|
||||
const initTransportPositions = (dayId) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
const transport = getTransportForDay(dayId)
|
||||
const needsInit = transport.filter(r => r.day_plan_position == null && !initedTransportIds.current.has(r.id))
|
||||
if (needsInit.length === 0) return
|
||||
|
||||
const sorted = [...needsInit].sort((a, b) =>
|
||||
(parseTimeToMinutes(a.reservation_time) ?? 0) - (parseTimeToMinutes(b.reservation_time) ?? 0)
|
||||
)
|
||||
const positions = sorted.map((r, idx) => ({
|
||||
id: r.id,
|
||||
day_plan_position: computeTransportPosition(r, da) + idx * 0.01,
|
||||
}))
|
||||
// Mark as initialized immediately to prevent re-entry
|
||||
for (const p of positions) {
|
||||
initedTransportIds.current.add(p.id)
|
||||
const res = reservations.find(x => x.id === p.id)
|
||||
if (res) res.day_plan_position = p.day_plan_position
|
||||
}
|
||||
// Persist to server (fire and forget)
|
||||
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
||||
}
|
||||
|
||||
const getMergedItems = (dayId) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
return [
|
||||
...da.map(a => ({ type: 'place', sortKey: a.order_index, data: a })),
|
||||
...dn.map(n => ({ type: 'note', sortKey: n.sort_order, data: n })),
|
||||
const transport = getTransportForDay(dayId)
|
||||
|
||||
// Initialize positions for transports that don't have one yet
|
||||
if (transport.some(r => r.day_plan_position == null)) {
|
||||
initTransportPositions(dayId)
|
||||
}
|
||||
|
||||
// Build base list: untimed places + notes sorted by order_index/sort_order
|
||||
const timedPlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) !== null)
|
||||
const freePlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) === null)
|
||||
|
||||
const baseItems = [
|
||||
...freePlaces.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
|
||||
].sort((a, b) => a.sortKey - b.sortKey)
|
||||
|
||||
// Timed places + transports: compute sortKeys based on time, inserted among base items
|
||||
const allTimed = [
|
||||
...timedPlaces.map(a => ({ type: 'place' as const, data: a, minutes: parseTimeToMinutes(a.place?.place_time)! })),
|
||||
...transport.map(r => ({ type: 'transport' as const, data: r, minutes: parseTimeToMinutes(r.reservation_time) ?? 0 })),
|
||||
].sort((a, b) => a.minutes - b.minutes)
|
||||
|
||||
if (allTimed.length === 0) return baseItems
|
||||
if (baseItems.length === 0) {
|
||||
return allTimed.map((item, i) => ({ ...item, sortKey: i }))
|
||||
}
|
||||
|
||||
// Insert timed items among base items using time-to-position mapping.
|
||||
// Each timed item finds the last base place whose order_index corresponds
|
||||
// to a reasonable position, then gets a fractional sortKey after it.
|
||||
const result = [...baseItems]
|
||||
for (let ti = 0; ti < allTimed.length; ti++) {
|
||||
const timed = allTimed[ti]
|
||||
const minutes = timed.minutes
|
||||
|
||||
// For transports, use persisted position if available
|
||||
if (timed.type === 'transport' && timed.data.day_plan_position != null) {
|
||||
result.push({ type: timed.type, sortKey: timed.data.day_plan_position, data: timed.data })
|
||||
continue
|
||||
}
|
||||
|
||||
// Find insertion position: after the last base item with time <= this item's time
|
||||
let insertAfterKey = -Infinity
|
||||
for (const item of result) {
|
||||
if (item.type === 'place') {
|
||||
const pm = parseTimeToMinutes(item.data?.place?.place_time)
|
||||
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
|
||||
} else if (item.type === 'transport') {
|
||||
const tm = parseTimeToMinutes(item.data?.reservation_time)
|
||||
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
|
||||
}
|
||||
}
|
||||
|
||||
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
|
||||
const sortKey = insertAfterKey === -Infinity
|
||||
? lastKey + 0.5 + ti * 0.01
|
||||
: insertAfterKey + 0.01 + ti * 0.001
|
||||
|
||||
result.push({ type: timed.type, sortKey, data: timed.data })
|
||||
}
|
||||
|
||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
||||
}
|
||||
|
||||
const openAddNote = (dayId, e) => {
|
||||
@@ -195,6 +330,41 @@ export default function DayPlanSidebar({
|
||||
})
|
||||
}
|
||||
|
||||
// Check if a proposed reorder of place IDs would break chronological order
|
||||
// of ALL timed items (places with time + transport bookings)
|
||||
const wouldBreakChronology = (dayId: number, newPlaceIds: number[]) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
const transport = getTransportForDay(dayId)
|
||||
|
||||
// Simulate the merged list with places in new order + transports at their positions
|
||||
// Places get sequential integer positions
|
||||
const simItems: { pos: number; minutes: number }[] = []
|
||||
newPlaceIds.forEach((id, idx) => {
|
||||
const a = da.find(x => x.id === id)
|
||||
const m = parseTimeToMinutes(a?.place?.place_time)
|
||||
if (m !== null) simItems.push({ pos: idx, minutes: m })
|
||||
})
|
||||
|
||||
// Transports: compute where they'd go with the new place order
|
||||
for (const r of transport) {
|
||||
const rMin = parseTimeToMinutes(r.reservation_time)
|
||||
if (rMin === null) continue
|
||||
// Find the last place (in new order) with time <= transport time
|
||||
let afterIdx = -1
|
||||
newPlaceIds.forEach((id, idx) => {
|
||||
const a = da.find(x => x.id === id)
|
||||
const pm = parseTimeToMinutes(a?.place?.place_time)
|
||||
if (pm !== null && pm <= rMin) afterIdx = idx
|
||||
})
|
||||
const pos = afterIdx >= 0 ? afterIdx + 0.5 : newPlaceIds.length + 0.5
|
||||
simItems.push({ pos, minutes: rMin })
|
||||
}
|
||||
|
||||
// Sort by position and check chronological order
|
||||
simItems.sort((a, b) => a.pos - b.pos)
|
||||
return !simItems.every((item, i) => i === 0 || item.minutes >= simItems[i - 1].minutes)
|
||||
}
|
||||
|
||||
const openEditNote = (dayId, note, e) => {
|
||||
e?.stopPropagation()
|
||||
_openEditNote(dayId, note)
|
||||
@@ -205,49 +375,180 @@ export default function DayPlanSidebar({
|
||||
await _deleteNote(dayId, noteId)
|
||||
}
|
||||
|
||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
||||
const m = getMergedItems(dayId)
|
||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
||||
// Unified reorder: assigns positions to ALL item types based on new visual order
|
||||
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
|
||||
// Places get sequential integer positions (0, 1, 2, ...)
|
||||
// Non-place items between place N-1 and place N get fractional positions
|
||||
const assignmentIds: number[] = []
|
||||
const noteUpdates: { id: number; sort_order: number }[] = []
|
||||
const transportUpdates: { id: number; day_plan_position: number }[] = []
|
||||
|
||||
// Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standard), oder NACH dem Ziel wenn insertAfter
|
||||
const newOrder = [...m]
|
||||
const [moved] = newOrder.splice(fromIdx, 1)
|
||||
let adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx
|
||||
if (insertAfter) adjustedTo += 1
|
||||
newOrder.splice(adjustedTo, 0, moved)
|
||||
|
||||
// Orte: neuer order_index über onReorder
|
||||
const assignmentIds = newOrder.filter(i => i.type === 'place').map(i => i.data.id)
|
||||
|
||||
// Notizen: sort_order muss ZWISCHEN den umgebenden order_indices der Orte liegen, niemals gleich sein.
|
||||
// Formel: Notiz zwischen placesBefore-1 und placesBefore ergibt (placesBefore - 1) + rank/(count+1)
|
||||
// z.B. einzelne Notiz nach 2 Orten → (2-1) + 0.5 = 1.5 (zwischen order_index 1 und 2)
|
||||
const groups = {}
|
||||
let pc = 0
|
||||
newOrder.forEach(item => {
|
||||
if (item.type === 'place') { pc++ }
|
||||
else { if (!groups[pc]) groups[pc] = []; groups[pc].push(item.data.id) }
|
||||
})
|
||||
const noteChanges = []
|
||||
Object.entries(groups).forEach(([pb, ids]) => {
|
||||
ids.forEach((id, i) => {
|
||||
noteChanges.push({ id, sort_order: (Number(pb) - 1) + (i + 1) / (ids.length + 1) })
|
||||
})
|
||||
})
|
||||
let placeCount = 0
|
||||
let i = 0
|
||||
while (i < newOrder.length) {
|
||||
if (newOrder[i].type === 'place') {
|
||||
assignmentIds.push(newOrder[i].data.id)
|
||||
placeCount++
|
||||
i++
|
||||
} else {
|
||||
// Collect consecutive non-place items
|
||||
const group: { type: string; data: any }[] = []
|
||||
while (i < newOrder.length && newOrder[i].type !== 'place') {
|
||||
group.push(newOrder[i])
|
||||
i++
|
||||
}
|
||||
// Fractional positions between (placeCount-1) and placeCount
|
||||
const base = placeCount > 0 ? placeCount - 1 : -1
|
||||
group.forEach((g, idx) => {
|
||||
const pos = base + (idx + 1) / (group.length + 1)
|
||||
if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos })
|
||||
else if (g.type === 'transport') transportUpdates.push({ id: g.data.id, day_plan_position: pos })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
||||
for (const n of noteChanges) {
|
||||
for (const n of noteUpdates) {
|
||||
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
||||
}
|
||||
if (transportUpdates.length) {
|
||||
for (const tu of transportUpdates) {
|
||||
const res = reservations.find(r => r.id === tu.id)
|
||||
if (res) res.day_plan_position = tu.day_plan_position
|
||||
}
|
||||
await reservationsApi.updatePositions(tripId, transportUpdates)
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
}
|
||||
|
||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
||||
// Transport bookings themselves cannot be dragged
|
||||
if (fromType === 'transport') {
|
||||
toast.error(t('dayplan.cannotReorderTransport'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const m = getMergedItems(dayId)
|
||||
|
||||
// Check if a timed place is being moved → would it break chronological order?
|
||||
if (fromType === 'place') {
|
||||
const fromItem = m.find(i => i.type === 'place' && i.data.id === fromId)
|
||||
const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time)
|
||||
if (fromItem && fromMinutes !== null) {
|
||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
if (fromIdx !== -1 && toIdx !== -1) {
|
||||
const simulated = [...m]
|
||||
const [moved] = simulated.splice(fromIdx, 1)
|
||||
let insertIdx = simulated.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
if (insertIdx === -1) insertIdx = simulated.length
|
||||
if (insertAfter) insertIdx += 1
|
||||
simulated.splice(insertIdx, 0, moved)
|
||||
|
||||
const timedInOrder = simulated
|
||||
.map(i => {
|
||||
if (i.type === 'transport') return parseTimeToMinutes(i.data?.reservation_time)
|
||||
if (i.type === 'place') return parseTimeToMinutes(i.data?.place?.place_time)
|
||||
return null
|
||||
})
|
||||
.filter(t => t !== null)
|
||||
const isChronological = timedInOrder.every((t, i) => i === 0 || t >= timedInOrder[i - 1])
|
||||
|
||||
if (!isChronological) {
|
||||
const placeTime = fromItem.data.place.place_time
|
||||
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
|
||||
setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, time: timeStr })
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build new order: remove the dragged item, insert at target position
|
||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) {
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const newOrder = [...m]
|
||||
const [moved] = newOrder.splice(fromIdx, 1)
|
||||
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
if (adjustedTo === -1) adjustedTo = newOrder.length
|
||||
if (insertAfter) adjustedTo += 1
|
||||
newOrder.splice(adjustedTo, 0, moved)
|
||||
|
||||
await applyMergedOrder(dayId, newOrder)
|
||||
setDraggingId(null)
|
||||
setDropTargetKey(null)
|
||||
dragDataRef.current = null
|
||||
}
|
||||
|
||||
const confirmTimeRemoval = async () => {
|
||||
if (!timeConfirm) return
|
||||
const saved = { ...timeConfirm }
|
||||
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter } = saved
|
||||
setTimeConfirm(null)
|
||||
|
||||
// Remove time from assignment
|
||||
try {
|
||||
await assignmentsApi.updateTime(tripId, fromId, { place_time: null, end_time: null })
|
||||
const key = String(dayId)
|
||||
const currentAssignments = { ...assignments }
|
||||
if (currentAssignments[key]) {
|
||||
currentAssignments[key] = currentAssignments[key].map(a =>
|
||||
a.id === fromId ? { ...a, place: { ...a.place, place_time: null, end_time: null } } : a
|
||||
)
|
||||
tripStore.setAssignments(currentAssignments)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Unknown error')
|
||||
return
|
||||
}
|
||||
|
||||
// Build new merged order from either arrow reorderIds or drag & drop params
|
||||
const m = getMergedItems(dayId)
|
||||
|
||||
if (reorderIds) {
|
||||
// Arrow reorder: rebuild merged list with places in the new order,
|
||||
// keeping transports and notes at their relative positions
|
||||
const newMerged: typeof m = []
|
||||
let rIdx = 0
|
||||
for (const item of m) {
|
||||
if (item.type === 'place') {
|
||||
// Replace with the place from reorderIds at this position
|
||||
const nextId = reorderIds[rIdx++]
|
||||
const replacement = m.find(i => i.type === 'place' && i.data.id === nextId)
|
||||
if (replacement) newMerged.push(replacement)
|
||||
} else {
|
||||
newMerged.push(item)
|
||||
}
|
||||
}
|
||||
await applyMergedOrder(dayId, newMerged)
|
||||
return
|
||||
}
|
||||
|
||||
// Drag & drop reorder
|
||||
if (fromType && toType) {
|
||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
||||
|
||||
const newOrder = [...m]
|
||||
const [moved] = newOrder.splice(fromIdx, 1)
|
||||
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
if (adjustedTo === -1) adjustedTo = newOrder.length
|
||||
if (insertAfter) adjustedTo += 1
|
||||
newOrder.splice(adjustedTo, 0, moved)
|
||||
|
||||
await applyMergedOrder(dayId, newOrder)
|
||||
}
|
||||
}
|
||||
|
||||
const moveNote = async (dayId, noteId, direction) => {
|
||||
await _moveNote(dayId, noteId, direction, getMergedItems)
|
||||
}
|
||||
@@ -396,7 +697,7 @@ export default function DayPlanSidebar({
|
||||
notes.map(n => ({ ...n, day_id: Number(dayId) }))
|
||||
)
|
||||
try {
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, t, locale })
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
|
||||
} catch (e) {
|
||||
console.error('PDF error:', e)
|
||||
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
|
||||
@@ -413,6 +714,34 @@ export default function DayPlanSidebar({
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
{t('dayplan.pdf')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` },
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${trip?.title || 'trip'}.ics`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error('ICS export failed') }
|
||||
}}
|
||||
title={t('dayplan.icsTooltip')}
|
||||
style={{
|
||||
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-muted)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
ICS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -492,6 +821,18 @@ export default function DayPlanSidebar({
|
||||
</button>
|
||||
{(() => {
|
||||
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
||||
// Sort: check-out first, then ongoing stays, then check-in last
|
||||
.sort((a, b) => {
|
||||
const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id
|
||||
const bIsOut = b.end_day_id === day.id && b.start_day_id !== day.id
|
||||
const aIsIn = a.start_day_id === day.id
|
||||
const bIsIn = b.start_day_id === day.id
|
||||
if (aIsOut && !bIsOut) return -1
|
||||
if (!aIsOut && bIsOut) return 1
|
||||
if (aIsIn && !bIsIn) return 1
|
||||
if (!aIsIn && bIsIn) return -1
|
||||
return 0
|
||||
})
|
||||
if (dayAccs.length === 0) return null
|
||||
return dayAccs.map(acc => {
|
||||
const isCheckIn = acc.start_day_id === day.id
|
||||
@@ -542,11 +883,34 @@ export default function DayPlanSidebar({
|
||||
{isExpanded && (
|
||||
<div
|
||||
style={{ background: 'var(--bg-hover)', paddingTop: 6 }}
|
||||
onDragOver={e => { e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }}
|
||||
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
const { assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
// Drop on transport card (detected via dropTargetRef for sync accuracy)
|
||||
if (dropTargetRef.current?.startsWith('transport-')) {
|
||||
const transportId = Number(dropTargetRef.current.replace('transport-', ''))
|
||||
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
} else if (assignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (assignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId)
|
||||
} else if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId)
|
||||
}
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!assignmentId && !noteId && !placeId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
setDropTargetKey(null); window.__dragData = null; return
|
||||
}
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
@@ -577,7 +941,7 @@ export default function DayPlanSidebar({
|
||||
</div>
|
||||
) : (
|
||||
merged.map((item, idx) => {
|
||||
const itemKey = item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`
|
||||
const itemKey = item.type === 'transport' ? `transport-${item.data.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
|
||||
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
|
||||
|
||||
if (item.type === 'place') {
|
||||
@@ -590,20 +954,39 @@ export default function DayPlanSidebar({
|
||||
const isHovered = hoveredId === assignment.id
|
||||
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||
|
||||
const moveUp = (e) => {
|
||||
e.stopPropagation()
|
||||
if (placeIdx === 0) return
|
||||
const ids = placeItems.map(i => i.data.id)
|
||||
;[ids[placeIdx - 1], ids[placeIdx]] = [ids[placeIdx], ids[placeIdx - 1]]
|
||||
onReorder(day.id, ids)
|
||||
}
|
||||
const moveDown = (e) => {
|
||||
e.stopPropagation()
|
||||
if (placeIdx === placeItems.length - 1) return
|
||||
const ids = placeItems.map(i => i.data.id)
|
||||
;[ids[placeIdx], ids[placeIdx + 1]] = [ids[placeIdx + 1], ids[placeIdx]]
|
||||
onReorder(day.id, ids)
|
||||
const arrowMove = (direction: 'up' | 'down') => {
|
||||
const m = getMergedItems(day.id)
|
||||
const myIdx = m.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
|
||||
if (myIdx === -1) return
|
||||
const targetIdx = direction === 'up' ? myIdx - 1 : myIdx + 1
|
||||
if (targetIdx < 0 || targetIdx >= m.length) return
|
||||
|
||||
// Build new order: swap this item with its neighbor in the merged list
|
||||
const newOrder = [...m]
|
||||
;[newOrder[myIdx], newOrder[targetIdx]] = [newOrder[targetIdx], newOrder[myIdx]]
|
||||
|
||||
// Check chronological order of all timed items in the new order
|
||||
const placeTime = place.place_time
|
||||
if (parseTimeToMinutes(placeTime) !== null) {
|
||||
const timedInNewOrder = newOrder
|
||||
.map(i => {
|
||||
if (i.type === 'transport') return parseTimeToMinutes(i.data?.reservation_time)
|
||||
if (i.type === 'place') return parseTimeToMinutes(i.data?.place?.place_time)
|
||||
return null
|
||||
})
|
||||
.filter(t => t !== null)
|
||||
const isChronological = timedInNewOrder.every((t, i) => i === 0 || t >= timedInNewOrder[i - 1])
|
||||
if (!isChronological) {
|
||||
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
|
||||
// Store the new merged order for confirm action
|
||||
setTimeConfirm({ dayId: day.id, fromId: assignment.id, time: timeStr, reorderIds: newOrder.filter(i => i.type === 'place').map(i => i.data.id) })
|
||||
return
|
||||
}
|
||||
}
|
||||
applyMergedOrder(day.id, newOrder)
|
||||
}
|
||||
const moveUp = (e) => { e.stopPropagation(); arrowMove('up') }
|
||||
const moveDown = (e) => { e.stopPropagation(); arrowMove('down') }
|
||||
|
||||
return (
|
||||
<React.Fragment key={`place-${assignment.id}`}>
|
||||
@@ -773,10 +1156,10 @@ export default function DayPlanSidebar({
|
||||
)}
|
||||
</div>
|
||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={moveUp} disabled={placeIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === 0 ? 'default' : 'pointer', color: placeIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronUp size={12} strokeWidth={2} />
|
||||
</button>
|
||||
<button onClick={moveDown} disabled={placeIdx === placeItems.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === placeItems.length - 1 ? 'default' : 'pointer', color: placeIdx === placeItems.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<button onClick={moveDown} disabled={idx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === merged.length - 1 ? 'default' : 'pointer', color: idx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronDown size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -785,6 +1168,90 @@ export default function DayPlanSidebar({
|
||||
)
|
||||
}
|
||||
|
||||
// Transport booking (flight, train, bus, car, cruise)
|
||||
if (item.type === 'transport') {
|
||||
const res = item.data
|
||||
const TransportIcon = RES_ICONS[res.type] || Ticket
|
||||
const color = '#3b82f6'
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
const isTransportHovered = hoveredId === `transport-${res.id}`
|
||||
|
||||
// Subtitle aus Metadaten zusammensetzen
|
||||
let subtitle = ''
|
||||
if (res.type === 'flight') {
|
||||
const parts = [meta.airline, meta.flight_number].filter(Boolean)
|
||||
if (meta.departure_airport || meta.arrival_airport)
|
||||
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
||||
subtitle = parts.join(' · ')
|
||||
} else if (res.type === 'train') {
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={`transport-${res.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
onClick={() => setTransportDetail(res)}
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`transport-${res.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id)
|
||||
} else if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id)
|
||||
}
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||
}}
|
||||
onMouseEnter={() => setHoveredId(`transport-${res.id}`)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px 7px 10px',
|
||||
margin: '1px 8px',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${color}33`,
|
||||
background: isTransportHovered ? `${color}12` : `${color}08`,
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: '50%', background: `${color}18`,
|
||||
}}>
|
||||
<TransportIcon size={14} strokeWidth={1.8} color={color} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{res.title}
|
||||
</span>
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
||||
<Clock size={9} strokeWidth={2} />
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
// Notizkarte
|
||||
const note = item.data
|
||||
const isNoteHovered = hoveredId === `note-${note.id}`
|
||||
@@ -991,6 +1458,199 @@ export default function DayPlanSidebar({
|
||||
document.body
|
||||
))}
|
||||
|
||||
{/* Confirm: remove time when reordering a timed place */}
|
||||
{timeConfirm && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => setTimeConfirm(null)}>
|
||||
<div style={{
|
||||
width: 340, background: 'var(--bg-card)', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 12,
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: '50%', background: 'rgba(239,68,68,0.12)',
|
||||
}}>
|
||||
<Clock size={18} strokeWidth={1.8} color="#ef4444" />
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{t('dayplan.confirmRemoveTimeTitle')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||
{t('dayplan.confirmRemoveTimeBody', { time: timeConfirm.time })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
<button onClick={() => setTimeConfirm(null)} style={{
|
||||
fontSize: 12, background: 'none', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit',
|
||||
}}>{t('common.cancel')}</button>
|
||||
<button onClick={confirmTimeRemoval} style={{
|
||||
fontSize: 12, background: '#ef4444', color: 'white',
|
||||
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>{t('common.confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Transport-Detail-Modal */}
|
||||
{transportDetail && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => setTransportDetail(null)}>
|
||||
<div style={{
|
||||
width: 380, maxHeight: '80vh', overflowY: 'auto',
|
||||
background: 'var(--bg-card)', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 14,
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
{(() => {
|
||||
const res = transportDetail
|
||||
const TransportIcon = RES_ICONS[res.type] || Ticket
|
||||
const TRANSPORT_COLORS = { flight: '#3b82f6', train: '#06b6d4', bus: '#f59e0b', car: '#6b7280', cruise: '#0ea5e9' }
|
||||
const color = TRANSPORT_COLORS[res.type] || 'var(--text-muted)'
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
|
||||
const detailFields = []
|
||||
if (res.type === 'flight') {
|
||||
if (meta.airline) detailFields.push({ label: t('reservations.meta.airline'), value: meta.airline })
|
||||
if (meta.flight_number) detailFields.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
|
||||
if (meta.departure_airport) detailFields.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
|
||||
if (meta.arrival_airport) detailFields.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
|
||||
if (meta.seat) detailFields.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||
} else if (res.type === 'train') {
|
||||
if (meta.train_number) detailFields.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||
if (meta.platform) detailFields.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) detailFields.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||
}
|
||||
if (res.confirmation_number) detailFields.push({ label: t('reservations.confirmationCode'), value: res.confirmation_number, sensitive: true })
|
||||
if (res.location) detailFields.push({ label: t('reservations.locationAddress'), value: res.location })
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: '50%', background: `${color}18`,
|
||||
}}>
|
||||
<TransportIcon size={18} strokeWidth={1.8} color={color} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2 }}>
|
||||
{res.reservation_time?.includes('T')
|
||||
? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
: res.reservation_time
|
||||
? new Date(res.reservation_time + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
: ''
|
||||
}
|
||||
{res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '3px 8px', borderRadius: 6, fontSize: 10, fontWeight: 600,
|
||||
background: res.status === 'confirmed' ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
|
||||
color: res.status === 'confirmed' ? '#16a34a' : '#d97706',
|
||||
}}>
|
||||
{(res.status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')).replace(/\s*·\s*$/, '')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail-Felder */}
|
||||
{detailFields.length > 0 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
{detailFields.map((f, i) => {
|
||||
const shouldBlur = f.sensitive && useSettingsStore.getState().settings.blur_booking_codes
|
||||
return (
|
||||
<div key={i} style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{f.label}</div>
|
||||
<div
|
||||
onMouseEnter={e => { if (shouldBlur) e.currentTarget.style.filter = 'none' }}
|
||||
onMouseLeave={e => { if (shouldBlur) e.currentTarget.style.filter = 'blur(5px)' }}
|
||||
onClick={e => { if (shouldBlur) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(5px)' : 'none' } }}
|
||||
style={{
|
||||
fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', wordBreak: 'break-word',
|
||||
filter: shouldBlur ? 'blur(5px)' : 'none', transition: 'filter 0.2s',
|
||||
cursor: shouldBlur ? 'pointer' : 'default',
|
||||
userSelect: shouldBlur ? 'none' : 'auto',
|
||||
}}
|
||||
>{f.value}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notizen */}
|
||||
{res.notes && (
|
||||
<div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{res.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dateien */}
|
||||
{(() => {
|
||||
const resFiles = (tripStore.files || []).filter(f =>
|
||||
!f.deleted_at && (
|
||||
f.reservation_id === res.id ||
|
||||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(res.id))
|
||||
)
|
||||
)
|
||||
if (resFiles.length === 0) return null
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 6 }}>{t('files.title')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{resFiles.map(f => (
|
||||
<div key={f.id}
|
||||
onClick={() => { setTransportDetail(null); onNavigateToFiles?.() }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px',
|
||||
background: 'var(--bg-tertiary)', borderRadius: 8, cursor: 'pointer',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
>
|
||||
<FileText size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{f.original_name}
|
||||
</span>
|
||||
<ExternalLink size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Schließen */}
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<button onClick={() => setTransportDetail(null)} style={{
|
||||
fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Budget-Fußzeile */}
|
||||
{totalCost > 0 && (
|
||||
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
|
||||
@@ -104,6 +104,24 @@ export default function PlaceFormModal({
|
||||
if (!mapsSearch.trim()) return
|
||||
setIsSearchingMaps(true)
|
||||
try {
|
||||
// Detect Google Maps URLs and resolve them directly
|
||||
const trimmed = mapsSearch.trim()
|
||||
if (trimmed.match(/^https?:\/\/(www\.)?(google\.[a-z.]+\/maps|maps\.google\.[a-z.]+|maps\.app\.goo\.gl|goo\.gl)/i)) {
|
||||
const resolved = await mapsApi.resolveUrl(trimmed)
|
||||
if (resolved.lat && resolved.lng) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
name: resolved.name || prev.name,
|
||||
address: resolved.address || prev.address,
|
||||
lat: String(resolved.lat),
|
||||
lng: String(resolved.lng),
|
||||
}))
|
||||
setMapsResults([])
|
||||
setMapsSearch('')
|
||||
toast.success(t('places.urlResolved'))
|
||||
return
|
||||
}
|
||||
}
|
||||
const result = await mapsApi.search(mapsSearch, language)
|
||||
setMapsResults(result.places || [])
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -120,12 +120,15 @@ interface PlaceInspectorProps {
|
||||
tripMembers?: TripMember[]
|
||||
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
||||
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
|
||||
leftWidth?: number
|
||||
rightWidth?: number
|
||||
}
|
||||
|
||||
export default function PlaceInspector({
|
||||
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
|
||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||
files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace,
|
||||
leftWidth = 0, rightWidth = 0,
|
||||
}: PlaceInspectorProps) {
|
||||
const { t, locale, language } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
@@ -169,7 +172,7 @@ export default function PlaceInspector({
|
||||
const selectedDay = days?.find(d => d.id === selectedDayId)
|
||||
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
|
||||
|
||||
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id))
|
||||
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id) || (f.linked_place_ids || []).includes(place.id))
|
||||
|
||||
const handleFileUpload = useCallback(async (e) => {
|
||||
const selectedFiles = Array.from((e.target as HTMLInputElement).files || [])
|
||||
@@ -196,9 +199,9 @@ export default function PlaceInspector({
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: '50%',
|
||||
left: `calc(${leftWidth}px + (100% - ${leftWidth}px - ${rightWidth}px) / 2)`,
|
||||
transform: 'translateX(-50%)',
|
||||
width: 'min(800px, calc(100vw - 32px))',
|
||||
width: `min(800px, calc(100% - ${leftWidth}px - ${rightWidth}px - 32px))`,
|
||||
zIndex: 50,
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
}}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useMemo, useCallback } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation } from 'lucide-react'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import { placesApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
|
||||
interface PlacesSidebarProps {
|
||||
tripId: number
|
||||
places: Place[]
|
||||
categories: Category[]
|
||||
assignments: AssignmentsMap
|
||||
@@ -26,33 +31,55 @@ interface PlacesSidebarProps {
|
||||
}
|
||||
|
||||
export default function PlacesSidebar({
|
||||
places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange,
|
||||
}: PlacesSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const ctxMenu = useContextMenu()
|
||||
const gpxInputRef = useRef<HTMLInputElement>(null)
|
||||
const tripStore = useTripStore()
|
||||
|
||||
const handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
try {
|
||||
const result = await placesApi.importGpx(tripId, file)
|
||||
await tripStore.loadTrip(tripId)
|
||||
toast.success(t('places.gpxImported', { count: result.count }))
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t('places.gpxError'))
|
||||
}
|
||||
}
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [categoryFilter, setCategoryFilterLocal] = useState('')
|
||||
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
|
||||
|
||||
const setCategoryFilter = (val: string) => {
|
||||
setCategoryFilterLocal(val)
|
||||
onCategoryFilterChange?.(val)
|
||||
const toggleCategoryFilter = (catId: string) => {
|
||||
setCategoryFiltersLocal(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(catId)) next.delete(catId); else next.add(catId)
|
||||
// Notify parent with first selected or empty
|
||||
onCategoryFilterChange?.(next.size === 1 ? [...next][0] : '')
|
||||
return next
|
||||
})
|
||||
}
|
||||
const [dayPickerPlace, setDayPickerPlace] = useState(null)
|
||||
const [catDropOpen, setCatDropOpen] = useState(false)
|
||||
|
||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||
const plannedIds = new Set(
|
||||
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
||||
)
|
||||
|
||||
const filtered = places.filter(p => {
|
||||
const filtered = useMemo(() => places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
if (categoryFilter && String(p.category_id) !== String(categoryFilter)) return false
|
||||
if (categoryFilters.size > 0 && !categoryFilters.has(String(p.category_id))) return false
|
||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
}), [places, filter, categoryFilters, search, plannedIds.size])
|
||||
|
||||
const isAssignedToSelectedDay = (placeId) =>
|
||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||
@@ -72,6 +99,19 @@ export default function PlacesSidebar({
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
||||
</button>
|
||||
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
|
||||
<button
|
||||
onClick={() => gpxInputRef.current?.click()}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
width: '100%', padding: '5px 12px', borderRadius: 8, marginBottom: 10,
|
||||
border: '1px dashed var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
|
||||
</button>
|
||||
|
||||
{/* Filter-Tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||
@@ -106,21 +146,69 @@ export default function PlacesSidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kategoriefilter */}
|
||||
{categories.length > 0 && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<CustomSelect
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
placeholder={t('places.allCategories')}
|
||||
size="sm"
|
||||
options={[
|
||||
{ value: '', label: t('places.allCategories') },
|
||||
...categories.map(c => ({ value: String(c.id), label: c.name }))
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Category multi-select dropdown */}
|
||||
{categories.length > 0 && (() => {
|
||||
const label = categoryFilters.size === 0
|
||||
? t('places.allCategories')
|
||||
: categoryFilters.size === 1
|
||||
? categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories')
|
||||
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
||||
return (
|
||||
<div style={{ marginTop: 6, position: 'relative' }}>
|
||||
<button onClick={() => setCatDropOpen(v => !v)} style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
<ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{catDropOpen && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, maxHeight: 200, overflowY: 'auto',
|
||||
}}>
|
||||
{categories.map(c => {
|
||||
const active = categoryFilters.has(String(c.id))
|
||||
const CatIcon = getCategoryIcon(c.icon)
|
||||
return (
|
||||
<button key={c.id} onClick={() => toggleCategoryFilter(String(c.id))} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||
fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
|
||||
textAlign: 'left',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: active ? 'none' : '1.5px solid var(--border-primary)',
|
||||
background: active ? (c.color || 'var(--accent)') : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{active && <Check size={10} strokeWidth={3} color="white" />}
|
||||
</div>
|
||||
<CatIcon size={12} strokeWidth={2} color={c.color || 'var(--text-muted)'} />
|
||||
<span style={{ flex: 1 }}>{c.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{categoryFilters.size > 0 && (
|
||||
<button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.('') }} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: 'transparent', fontFamily: 'inherit', fontSize: 11, color: 'var(--text-faint)',
|
||||
marginTop: 2, borderTop: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<X size={10} /> {t('places.clearFilter')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Anzahl */}
|
||||
@@ -178,6 +266,8 @@ export default function PlacesSidebar({
|
||||
background: isSelected ? 'var(--border-faint)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
transition: 'background 0.1s',
|
||||
contentVisibility: 'auto',
|
||||
containIntrinsicSize: '0 52px',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import apiClient from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||
@@ -62,6 +65,8 @@ interface ReservationModalProps {
|
||||
}
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) {
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
const loadFiles = useTripStore(s => s.loadFiles)
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const fileInputRef = useRef(null)
|
||||
@@ -78,6 +83,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
|
||||
|
||||
const assignmentOptions = useMemo(
|
||||
() => buildAssignmentOptions(days, assignments, t, locale),
|
||||
@@ -204,7 +212,13 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
}
|
||||
}
|
||||
|
||||
const attachedFiles = reservation?.id ? files.filter(f => f.reservation_id === reservation.id) : []
|
||||
const attachedFiles = reservation?.id
|
||||
? files.filter(f =>
|
||||
f.reservation_id === reservation.id ||
|
||||
linkedFileIds.includes(f.id) ||
|
||||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
|
||||
)
|
||||
: []
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
@@ -459,11 +473,23 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}><ExternalLink size={11} /></a>
|
||||
{onFileDelete && (
|
||||
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={async () => {
|
||||
// Always unlink, never delete the file
|
||||
// Clear primary reservation_id if it points to this reservation
|
||||
if (f.reservation_id === reservation?.id) {
|
||||
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
|
||||
}
|
||||
// Remove from file_links if linked there
|
||||
try {
|
||||
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
||||
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
|
||||
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
|
||||
} catch {}
|
||||
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
|
||||
if (tripId) loadFiles(tripId)
|
||||
}} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pendingFiles.map((f, i) => (
|
||||
@@ -477,14 +503,56 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
))}
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>
|
||||
{/* Link existing file picker */}
|
||||
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Link2 size={11} /> {t('reservations.linkExisting')}
|
||||
</button>
|
||||
{showFilePicker && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220, maxHeight: 200, overflowY: 'auto',
|
||||
}}>
|
||||
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
|
||||
<button key={f.id} type="button" onClick={async () => {
|
||||
try {
|
||||
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
|
||||
setLinkedFileIds(prev => [...prev, f.id])
|
||||
setShowFilePicker(false)
|
||||
if (tripId) loadFiles(tripId)
|
||||
} catch {}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
|
||||
background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit',
|
||||
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<FileText size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -505,5 +573,5 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
function formatDate(dateStr, locale) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString(locale || 'de-DE', { day: 'numeric', month: 'short' })
|
||||
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -62,18 +63,21 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||
const [codeRevealed, setCodeRevealed] = useState(false)
|
||||
const typeInfo = getType(r.type)
|
||||
const TypeIcon = typeInfo.Icon
|
||||
const confirmed = r.status === 'confirmed'
|
||||
const attachedFiles = files.filter(f => f.reservation_id === r.id)
|
||||
const attachedFiles = files.filter(f => f.reservation_id === r.id || (f.linked_reservation_ids || []).includes(r.id))
|
||||
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
const handleToggle = async () => {
|
||||
try { await toggleReservationStatus(tripId, r.id) }
|
||||
catch { toast.error(t('reservations.toast.updateError')) }
|
||||
}
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(t('reservations.confirm.delete', { name: r.title }))) return
|
||||
setShowDeleteConfirm(false)
|
||||
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
|
||||
}
|
||||
|
||||
@@ -104,7 +108,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
<button onClick={handleDelete} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={11} />
|
||||
@@ -134,7 +138,19 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
{r.confirmation_number && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{r.confirmation_number}</div>
|
||||
<div
|
||||
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
|
||||
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
|
||||
onClick={() => blurCodes && setCodeRevealed(v => !v)}
|
||||
style={{
|
||||
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1,
|
||||
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
|
||||
cursor: blurCodes ? 'pointer' : 'default',
|
||||
transition: 'filter 0.2s',
|
||||
}}
|
||||
>
|
||||
{r.confirmation_number}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -227,6 +243,46 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Delete confirmation popup */}
|
||||
{showDeleteConfirm && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
width: 340, background: 'var(--bg-card)', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 12,
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: '50%', background: 'rgba(239,68,68,0.12)',
|
||||
}}>
|
||||
<Trash2 size={18} strokeWidth={1.8} color="#ef4444" />
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{t('reservations.confirm.deleteTitle')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||
{t('reservations.confirm.deleteBody', { name: r.title })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
<button onClick={() => setShowDeleteConfirm(false)} style={{
|
||||
fontSize: 12, background: 'none', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit',
|
||||
}}>{t('common.cancel')}</button>
|
||||
<button onClick={handleDelete} style={{
|
||||
fontSize: 12, background: '#ef4444', color: 'white',
|
||||
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>{t('common.confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { tripsApi, authApi } from '../../api/client'
|
||||
import { tripsApi, authApi, shareApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { Crown, UserMinus, UserPlus, Users, LogOut } from 'lucide-react'
|
||||
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
@@ -32,6 +32,129 @@ function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function ShareLinkSection({ tripId, t }: { tripId: number; t: any }) {
|
||||
const [shareToken, setShareToken] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [perms, setPerms] = useState({ share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false })
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
shareApi.getLink(tripId).then(d => {
|
||||
setShareToken(d.token)
|
||||
if (d.token) setPerms({ share_map: d.share_map ?? true, share_bookings: d.share_bookings ?? true, share_packing: d.share_packing ?? false, share_budget: d.share_budget ?? false, share_collab: d.share_collab ?? false })
|
||||
setLoading(false)
|
||||
}).catch(() => setLoading(false))
|
||||
}, [tripId])
|
||||
|
||||
const shareUrl = shareToken ? `${window.location.origin}/shared/${shareToken}` : null
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const d = await shareApi.createLink(tripId, perms)
|
||||
setShareToken(d.token)
|
||||
} catch { toast.error(t('share.createError')) }
|
||||
}
|
||||
|
||||
const handleUpdatePerms = async (key: string, val: boolean) => {
|
||||
const newPerms = { ...perms, [key]: val }
|
||||
setPerms(newPerms)
|
||||
if (shareToken) {
|
||||
try { await shareApi.createLink(tripId, newPerms) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await shareApi.deleteLink(tripId)
|
||||
setShareToken(null)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
if (shareUrl) {
|
||||
navigator.clipboard.writeText(shareUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||
<Link2 size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('share.linkTitle')}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 10, lineHeight: 1.5 }}>{t('share.linkHint')}</p>
|
||||
|
||||
{/* Permission checkboxes */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||
{[
|
||||
{ key: 'share_map', label: t('share.permMap'), always: true },
|
||||
{ key: 'share_bookings', label: t('share.permBookings') },
|
||||
{ key: 'share_packing', label: t('share.permPacking') },
|
||||
{ key: 'share_budget', label: t('share.permBudget') },
|
||||
{ key: 'share_collab', label: t('share.permCollab') },
|
||||
].map(opt => (
|
||||
<button key={opt.key} onClick={() => !opt.always && handleUpdatePerms(opt.key, !perms[opt.key])}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 20,
|
||||
border: '1.5px solid', fontSize: 11, fontWeight: 500, cursor: opt.always ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: perms[opt.key] ? 'var(--text-primary)' : 'transparent',
|
||||
borderColor: perms[opt.key] ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: perms[opt.key] ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
opacity: opt.always ? 0.7 : 1,
|
||||
}}>
|
||||
{perms[opt.key] ? <Check size={10} /> : null}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{shareUrl ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px',
|
||||
background: 'var(--bg-tertiary)', borderRadius: 8, border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<input type="text" value={shareUrl} readOnly style={{
|
||||
flex: 1, border: 'none', background: 'none', fontSize: 11, color: 'var(--text-primary)',
|
||||
outline: 'none', fontFamily: 'monospace',
|
||||
}} />
|
||||
<button onClick={handleCopy} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 6,
|
||||
border: 'none', background: copied ? '#16a34a' : 'var(--accent)', color: copied ? 'white' : 'var(--accent-text)',
|
||||
fontSize: 10, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', transition: 'background 0.2s',
|
||||
}}>
|
||||
{copied ? <><Check size={10} /> {t('common.copied')}</> : <><Copy size={10} /> {t('common.copy')}</>}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={handleDelete} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', borderRadius: 8, border: '1px solid rgba(239,68,68,0.3)',
|
||||
background: 'rgba(239,68,68,0.06)', color: '#ef4444', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Trash2 size={11} /> {t('share.deleteLink')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleCreate} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '8px 0', borderRadius: 8, border: '1px dashed var(--border-primary)',
|
||||
background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Link2 size={12} /> {t('share.createLink')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TripMembersModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
@@ -123,8 +246,12 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
] : []
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="sm">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} className="share-modal-grid">
|
||||
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
||||
|
||||
{/* Left column: Members */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
|
||||
{/* Trip name */}
|
||||
<div style={{ padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-secondary)' }}>
|
||||
@@ -228,6 +355,13 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right column: Share Link */}
|
||||
<div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
|
||||
<ShareLinkSection tripId={tripId} t={t} />
|
||||
</div>
|
||||
|
||||
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function VacayCalendar() {
|
||||
}, [entries])
|
||||
|
||||
const blockWeekends = plan?.block_weekends !== false
|
||||
const weekendDays: number[] = plan?.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
|
||||
const companyHolidaysEnabled = plan?.company_holidays_enabled !== false
|
||||
|
||||
const handleCellClick = useCallback(async (dateStr) => {
|
||||
@@ -35,7 +36,7 @@ export default function VacayCalendar() {
|
||||
return
|
||||
}
|
||||
if (holidays[dateStr]) return
|
||||
if (blockWeekends && isWeekend(dateStr)) return
|
||||
if (blockWeekends && isWeekend(dateStr, weekendDays)) return
|
||||
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
|
||||
await toggleEntry(dateStr, selectedUserId || undefined)
|
||||
}, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
|
||||
@@ -57,6 +58,7 @@ export default function VacayCalendar() {
|
||||
onCellClick={handleCellClick}
|
||||
companyMode={companyMode}
|
||||
blockWeekends={blockWeekends}
|
||||
weekendDays={weekendDays}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { isWeekend } from './holidays'
|
||||
import type { HolidaysMap, VacayEntry } from '../../types'
|
||||
|
||||
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const WEEKDAYS_ES = ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sa', 'Do']
|
||||
const WEEKDAYS_AR = ['اث', 'ثل', 'أر', 'خم', 'جم', 'سب', 'أح']
|
||||
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
||||
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
||||
const MONTHS_ES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']
|
||||
const MONTHS_AR = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر']
|
||||
const WEEKDAY_KEYS = ['vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat', 'vacay.sun'] as const
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
@@ -29,16 +22,18 @@ interface VacayMonthCardProps {
|
||||
onCellClick: (date: string) => void
|
||||
companyMode: boolean
|
||||
blockWeekends: boolean
|
||||
weekendDays?: number[]
|
||||
}
|
||||
|
||||
export default function VacayMonthCard({
|
||||
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
|
||||
onCellClick, companyMode, blockWeekends
|
||||
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6]
|
||||
}: VacayMonthCardProps) {
|
||||
const { language } = useTranslation()
|
||||
const weekdays = language === 'de' ? WEEKDAYS_DE : language === 'es' ? WEEKDAYS_ES : language === 'ar' ? WEEKDAYS_AR : WEEKDAYS_EN
|
||||
const monthNames = language === 'de' ? MONTHS_DE : language === 'es' ? MONTHS_ES : language === 'ar' ? MONTHS_AR : MONTHS_EN
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
const weekdays = WEEKDAY_KEYS.map(k => t(k))
|
||||
const monthName = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(year, month, 1)), [locale, year, month])
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
@@ -58,7 +53,7 @@ export default function VacayMonthCard({
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-3 py-2 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--text-primary)' }}>{monthNames[month]}</span>
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--text-primary)', textTransform: 'capitalize' }}>{monthName}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
@@ -76,7 +71,8 @@ export default function VacayMonthCard({
|
||||
if (day === null) return <div key={di} style={{ height: 28 }} />
|
||||
|
||||
const dateStr = `${year}-${pad(month + 1)}-${pad(day)}`
|
||||
const weekend = di >= 5
|
||||
const dayOfWeek = new Date(year, month, day).getDay()
|
||||
const weekend = weekendDays.includes(dayOfWeek)
|
||||
const holiday = holidays[dateStr]
|
||||
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
|
||||
const dayEntries = entryMap[dateStr] || []
|
||||
|
||||
@@ -49,6 +49,42 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
onChange={() => toggle('block_weekends')}
|
||||
/>
|
||||
|
||||
{/* Weekend days selector */}
|
||||
{plan.block_weekends !== false && (
|
||||
<div style={{ paddingLeft: 36 }}>
|
||||
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>{t('vacay.weekendDays')}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[
|
||||
{ day: 1, label: t('vacay.mon') },
|
||||
{ day: 2, label: t('vacay.tue') },
|
||||
{ day: 3, label: t('vacay.wed') },
|
||||
{ day: 4, label: t('vacay.thu') },
|
||||
{ day: 5, label: t('vacay.fri') },
|
||||
{ day: 6, label: t('vacay.sat') },
|
||||
{ day: 0, label: t('vacay.sun') },
|
||||
].map(({ day, label }) => {
|
||||
const current: number[] = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
|
||||
const active = current.includes(day)
|
||||
return (
|
||||
<button key={day} onClick={() => {
|
||||
const next = active ? current.filter(d => d !== day) : [...current, day]
|
||||
updatePlan({ weekend_days: next.join(',') })
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||
fontFamily: 'inherit', border: '1px solid', transition: 'all 0.12s',
|
||||
background: active ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Carry-over */}
|
||||
<SettingToggle
|
||||
icon={ArrowRightLeft}
|
||||
|
||||
@@ -103,10 +103,9 @@ export function getHolidays(year: number, bundesland: string = 'NW'): Record<str
|
||||
return holidays
|
||||
}
|
||||
|
||||
export function isWeekend(dateStr: string): boolean {
|
||||
export function isWeekend(dateStr: string, weekendDays: number[] = [0, 6]): boolean {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
const day = d.getDay()
|
||||
return day === 0 || day === 6
|
||||
return weekendDays.includes(d.getDay())
|
||||
}
|
||||
|
||||
export function getWeekday(dateStr: string): string {
|
||||
@@ -123,9 +122,9 @@ export function daysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 0).getDate()
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string): string {
|
||||
export function formatDate(dateStr: string, locale?: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
export { BUNDESLAENDER }
|
||||
|
||||
@@ -107,9 +107,15 @@ export default function CustomSelect({
|
||||
{open && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed',
|
||||
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
|
||||
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
|
||||
width: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.width : 200 })(),
|
||||
...(() => {
|
||||
const r = ref.current?.getBoundingClientRect()
|
||||
if (!r) return { top: 0, left: 0, width: 200 }
|
||||
const spaceBelow = window.innerHeight - r.bottom
|
||||
const openUp = spaceBelow < 220 && r.top > spaceBelow
|
||||
return openUp
|
||||
? { bottom: window.innerHeight - r.top + 4, left: r.left, width: r.width }
|
||||
: { top: r.bottom + 4, left: r.left, width: r.width }
|
||||
})(),
|
||||
zIndex: 99999,
|
||||
background: 'var(--bg-card)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
|
||||
@@ -7,6 +7,7 @@ const sizeClasses: Record<string, string> = {
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-2xl',
|
||||
'2xl': 'max-w-4xl',
|
||||
'3xl': 'max-w-5xl',
|
||||
}
|
||||
|
||||
interface ModalProps {
|
||||
|
||||
@@ -16,8 +16,18 @@ interface PlaceAvatarProps {
|
||||
|
||||
const photoCache = new Map<string, string | null>()
|
||||
const photoInFlight = new Set<string>()
|
||||
// Event-based notification instead of polling intervals
|
||||
const photoListeners = new Map<string, Set<(url: string | null) => void>>()
|
||||
|
||||
export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||
function notifyListeners(key: string, url: string | null) {
|
||||
const listeners = photoListeners.get(key)
|
||||
if (listeners) {
|
||||
listeners.forEach(fn => fn(url))
|
||||
photoListeners.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,28 +43,27 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
|
||||
}
|
||||
|
||||
if (photoInFlight.has(cacheKey)) {
|
||||
// Another instance is already fetching, wait for it
|
||||
const check = setInterval(() => {
|
||||
if (photoCache.has(cacheKey)) {
|
||||
clearInterval(check)
|
||||
const cached = photoCache.get(cacheKey)
|
||||
if (cached) setPhotoSrc(cached)
|
||||
}
|
||||
}, 200)
|
||||
return () => clearInterval(check)
|
||||
// Subscribe to notification instead of polling
|
||||
if (!photoListeners.has(cacheKey)) photoListeners.set(cacheKey, new Set())
|
||||
const handler = (url: string | null) => { if (url) setPhotoSrc(url) }
|
||||
photoListeners.get(cacheKey)!.add(handler)
|
||||
return () => { photoListeners.get(cacheKey)?.delete(handler) }
|
||||
}
|
||||
|
||||
photoInFlight.add(cacheKey)
|
||||
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
.then((data: { photoUrl?: string }) => {
|
||||
if (data.photoUrl) {
|
||||
photoCache.set(cacheKey, data.photoUrl)
|
||||
setPhotoSrc(data.photoUrl)
|
||||
} else {
|
||||
photoCache.set(cacheKey, null)
|
||||
}
|
||||
const url = data.photoUrl || null
|
||||
photoCache.set(cacheKey, url)
|
||||
if (url) setPhotoSrc(url)
|
||||
notifyListeners(cacheKey, url)
|
||||
photoInFlight.delete(cacheKey)
|
||||
})
|
||||
.catch(() => {
|
||||
photoCache.set(cacheKey, null)
|
||||
notifyListeners(cacheKey, null)
|
||||
photoInFlight.delete(cacheKey)
|
||||
})
|
||||
.catch(() => { photoCache.set(cacheKey, null); photoInFlight.delete(cacheKey) })
|
||||
}, [place.id, place.image_url, place.google_place_id, place.osm_id])
|
||||
|
||||
const bgColor = category?.color || '#6366f1'
|
||||
@@ -76,6 +85,7 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
|
||||
<img
|
||||
src={photoSrc}
|
||||
alt={place.name}
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => setPhotoSrc(null)}
|
||||
/>
|
||||
@@ -88,4 +98,4 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
|
||||
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,10 +4,14 @@ import de from './translations/de'
|
||||
import en from './translations/en'
|
||||
import es from './translations/es'
|
||||
import fr from './translations/fr'
|
||||
import hu from './translations/hu'
|
||||
import it from './translations/it'
|
||||
import ru from './translations/ru'
|
||||
import zh from './translations/zh'
|
||||
import nl from './translations/nl'
|
||||
import ar from './translations/ar'
|
||||
import br from './translations/br'
|
||||
import cs from './translations/cs'
|
||||
|
||||
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
|
||||
|
||||
@@ -16,14 +20,18 @@ export const SUPPORTED_LANGUAGES = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'hu', label: 'Magyar' },
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'br', label: 'Português (Brasil)' },
|
||||
{ value: 'cs', label: 'Česky' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'zh', label: '中文' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'ar', label: 'العربية' },
|
||||
] as const
|
||||
|
||||
const translations: Record<string, TranslationStrings> = { de, en, es, fr, 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 translations: Record<string, TranslationStrings> = { de, en, es, fr, hu, it, ru, zh, nl, ar, br, cs }
|
||||
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', hu: 'hu-HU', it: 'it-IT', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR', cs: 'cs-CZ' }
|
||||
const RTL_LANGUAGES = new Set(['ar'])
|
||||
|
||||
export function getLocaleForLanguage(language: string): string {
|
||||
@@ -31,7 +39,8 @@ export function getLocaleForLanguage(language: string): string {
|
||||
}
|
||||
|
||||
export function getIntlLanguage(language: string): string {
|
||||
return ['de', 'es', 'fr', 'ru', 'zh', 'nl', 'ar'].includes(language) ? language : 'en'
|
||||
if (language === 'br') return 'pt-BR'
|
||||
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'nl', 'ar', 'cs'].includes(language) ? language : 'en'
|
||||
}
|
||||
|
||||
export function isRtlLanguage(language: string): boolean {
|
||||
|
||||
@@ -144,6 +144,50 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.temperature': 'وحدة الحرارة',
|
||||
'settings.timeFormat': 'تنسيق الوقت',
|
||||
'settings.routeCalculation': 'حساب المسار',
|
||||
'settings.blurBookingCodes': 'إخفاء رموز الحجز',
|
||||
'settings.notifications': 'الإشعارات',
|
||||
'settings.notifyTripInvite': 'دعوات الرحلات',
|
||||
'settings.notifyBookingChange': 'تغييرات الحجز',
|
||||
'settings.notifyTripReminder': 'تذكيرات الرحلات',
|
||||
'settings.notifyVacayInvite': 'دعوات دمج الإجازات',
|
||||
'settings.notifyPhotosShared': 'صور مشتركة (Immich)',
|
||||
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
|
||||
'settings.notifyPackingTagged': 'قائمة الأمتعة: التعيينات',
|
||||
'settings.notifyWebhook': 'إشعارات Webhook',
|
||||
'admin.smtp.title': 'البريد والإشعارات',
|
||||
'admin.smtp.hint': 'تكوين SMTP لإشعارات البريد الإلكتروني. اختياري: عنوان Webhook لـ Discord أو Slack وغيرها.',
|
||||
'admin.smtp.testButton': 'إرسال بريد تجريبي',
|
||||
'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح',
|
||||
'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي',
|
||||
'dayplan.icsTooltip': 'تصدير التقويم (ICS)',
|
||||
'share.linkTitle': 'رابط عام',
|
||||
'share.linkHint': 'أنشئ رابطًا يمكن لأي شخص استخدامه لعرض هذه الرحلة بدون تسجيل الدخول. للقراءة فقط — لا يمكن التعديل.',
|
||||
'share.createLink': 'إنشاء رابط',
|
||||
'share.deleteLink': 'حذف الرابط',
|
||||
'share.createError': 'تعذر إنشاء الرابط',
|
||||
'common.copy': 'نسخ',
|
||||
'common.copied': 'تم النسخ',
|
||||
'share.permMap': 'الخريطة والخطة',
|
||||
'share.permBookings': 'الحجوزات',
|
||||
'share.permPacking': 'الأمتعة',
|
||||
'shared.expired': 'الرابط منتهي أو غير صالح',
|
||||
'shared.expiredHint': 'رابط الرحلة المشترك لم يعد نشطًا.',
|
||||
'shared.readOnly': 'عرض للقراءة فقط',
|
||||
'shared.tabPlan': 'الخطة',
|
||||
'shared.tabBookings': 'الحجوزات',
|
||||
'shared.tabPacking': 'قائمة التعبئة',
|
||||
'shared.tabBudget': 'الميزانية',
|
||||
'shared.tabChat': 'الدردشة',
|
||||
'shared.days': 'أيام',
|
||||
'shared.places': 'أماكن',
|
||||
'shared.other': 'أخرى',
|
||||
'shared.totalBudget': 'إجمالي الميزانية',
|
||||
'shared.messages': 'رسائل',
|
||||
'shared.sharedVia': 'تمت المشاركة عبر',
|
||||
'shared.confirmed': 'مؤكد',
|
||||
'shared.pending': 'قيد الانتظار',
|
||||
'share.permBudget': 'الميزانية',
|
||||
'share.permCollab': 'الدردشة',
|
||||
'settings.on': 'تشغيل',
|
||||
'settings.off': 'إيقاف',
|
||||
'settings.account': 'الحساب',
|
||||
@@ -276,6 +320,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.users': 'المستخدمون',
|
||||
'admin.tabs.categories': 'الفئات',
|
||||
'admin.tabs.backup': 'النسخ الاحتياطي',
|
||||
'admin.tabs.audit': 'سجل التدقيق',
|
||||
'admin.tabs.settings': 'الإعدادات',
|
||||
'admin.tabs.config': 'الإعدادات',
|
||||
'admin.tabs.templates': 'قوالب التعبئة',
|
||||
@@ -380,7 +425,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Addons
|
||||
'admin.addons.title': 'الإضافات',
|
||||
'admin.addons.subtitle': 'فعّل أو عطّل الميزات لتخصيص تجربة TREK.',
|
||||
'admin.addons.catalog.memories.name': 'الذكريات',
|
||||
'admin.addons.catalog.memories.name': 'ذكريات',
|
||||
'admin.addons.catalog.memories.description': 'ألبومات صور مشتركة لكل رحلة',
|
||||
'admin.addons.catalog.packing.name': 'التعبئة',
|
||||
'admin.addons.catalog.packing.description': 'قوائم تحقق لإعداد أمتعتك لكل رحلة',
|
||||
@@ -419,6 +464,18 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.weather.locationHint': 'يعتمد الطقس على أول مكان بإحداثيات في كل يوم. إذا لم يكن هناك مكان مخصص ليوم ما، يُستخدم أي مكان من قائمة الأماكن كمرجع.',
|
||||
|
||||
// GitHub
|
||||
'admin.audit.subtitle': 'أحداث الأمان والإدارة (النسخ الاحتياطية، المستخدمون، المصادقة الثنائية، الإعدادات).',
|
||||
'admin.audit.empty': 'لا توجد سجلات تدقيق بعد.',
|
||||
'admin.audit.refresh': 'تحديث',
|
||||
'admin.audit.loadMore': 'تحميل المزيد',
|
||||
'admin.audit.showing': 'تم تحميل {count} · الإجمالي {total}',
|
||||
'admin.audit.col.time': 'الوقت',
|
||||
'admin.audit.col.user': 'المستخدم',
|
||||
'admin.audit.col.action': 'الإجراء',
|
||||
'admin.audit.col.resource': 'المورد',
|
||||
'admin.audit.col.ip': 'عنوان IP',
|
||||
'admin.audit.col.details': 'التفاصيل',
|
||||
|
||||
'admin.github.title': 'سجل الإصدارات',
|
||||
'admin.github.subtitle': 'آخر التحديثات من {repo}',
|
||||
'admin.github.latest': 'الأحدث',
|
||||
@@ -482,6 +539,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.carriedOver': 'من {year}',
|
||||
'vacay.blockWeekends': 'حظر عطلة نهاية الأسبوع',
|
||||
'vacay.blockWeekendsHint': 'منع إدخالات الإجازة يومي السبت والأحد',
|
||||
'vacay.weekendDays': 'أيام عطلة نهاية الأسبوع',
|
||||
'vacay.mon': 'الاثنين',
|
||||
'vacay.tue': 'الثلاثاء',
|
||||
'vacay.wed': 'الأربعاء',
|
||||
'vacay.thu': 'الخميس',
|
||||
'vacay.fri': 'الجمعة',
|
||||
'vacay.sat': 'السبت',
|
||||
'vacay.sun': 'الأحد',
|
||||
'vacay.publicHolidays': 'العطل الرسمية',
|
||||
'vacay.publicHolidaysHint': 'وضع علامة على العطل الرسمية في التقويم',
|
||||
'vacay.selectCountry': 'اختر الدولة',
|
||||
@@ -539,6 +604,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.markVisited': 'تعيين كمُزار',
|
||||
'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة',
|
||||
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
|
||||
'atlas.addPoi': 'إضافة مكان',
|
||||
'atlas.bucketNamePlaceholder': 'الاسم (بلد، مدينة، مكان…)',
|
||||
'atlas.month': 'الشهر',
|
||||
'atlas.year': 'السنة',
|
||||
'atlas.addToBucketHint': 'حفظ كمكان تريد زيارته',
|
||||
'atlas.bucketWhen': 'متى تخطط للزيارة؟',
|
||||
'atlas.statsTab': 'الإحصائيات',
|
||||
@@ -624,14 +693,26 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'تصدير خطة اليوم بصيغة PDF',
|
||||
'dayplan.pdfError': 'فشل تصدير PDF',
|
||||
'dayplan.cannotReorderTransport': 'لا يمكن إعادة ترتيب الحجوزات ذات الوقت الثابت',
|
||||
'dayplan.confirmRemoveTimeTitle': 'إزالة الوقت؟',
|
||||
'dayplan.confirmRemoveTimeBody': 'هذا المكان له وقت ثابت ({time}). نقله سيزيل الوقت ويسمح بالترتيب الحر.',
|
||||
'dayplan.confirmRemoveTimeAction': 'إزالة الوقت ونقل',
|
||||
'dayplan.cannotDropOnTimed': 'لا يمكن وضع العناصر بين الإدخالات المرتبطة بوقت',
|
||||
'dayplan.cannotBreakChronology': 'سيؤدي هذا إلى كسر الترتيب الزمني للعناصر والحجوزات المجدولة',
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'إضافة مكان/نشاط',
|
||||
'places.importGpx': 'استيراد GPX',
|
||||
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
|
||||
'places.gpxError': 'فشل استيراد GPX',
|
||||
'places.urlResolved': 'تم استيراد المكان من الرابط',
|
||||
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
|
||||
'places.all': 'الكل',
|
||||
'places.unplanned': 'غير مخطط',
|
||||
'places.search': 'ابحث عن أماكن...',
|
||||
'places.allCategories': 'كل الفئات',
|
||||
'places.categoriesSelected': 'فئات',
|
||||
'places.clearFilter': 'مسح الفلتر',
|
||||
'places.count': '{count} أماكن',
|
||||
'places.countSingular': 'مكان واحد',
|
||||
'places.allPlanned': 'تم تخطيط جميع الأماكن',
|
||||
@@ -731,6 +812,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.type.tour': 'جولة',
|
||||
'reservations.type.other': 'أخرى',
|
||||
'reservations.confirm.delete': 'هل تريد حذف الحجز "{name}"؟',
|
||||
'reservations.confirm.deleteTitle': 'حذف الحجز؟',
|
||||
'reservations.confirm.deleteBody': 'سيتم حذف "{name}" نهائيًا.',
|
||||
'reservations.toast.updated': 'تم تحديث الحجز',
|
||||
'reservations.toast.removed': 'تم حذف الحجز',
|
||||
'reservations.toast.fileUploaded': 'تم رفع الملف',
|
||||
@@ -750,6 +833,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.pendingSave': 'سيتم الحفظ…',
|
||||
'reservations.uploading': 'جارٍ الرفع...',
|
||||
'reservations.attachFile': 'إرفاق ملف',
|
||||
'reservations.linkExisting': 'ربط ملف موجود',
|
||||
'reservations.toast.saveError': 'فشل الحفظ',
|
||||
'reservations.toast.updateError': 'فشل التحديث',
|
||||
'reservations.toast.deleteError': 'فشل الحذف',
|
||||
@@ -787,6 +871,9 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'budget.paid': 'مدفوع',
|
||||
'budget.open': 'مفتوح',
|
||||
'budget.noMembers': 'لا أعضاء معينون',
|
||||
'budget.settlement': 'التسوية',
|
||||
'budget.settlementInfo': 'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
|
||||
'budget.netBalances': 'الأرصدة الصافية',
|
||||
|
||||
// Files
|
||||
'files.title': 'الملفات',
|
||||
@@ -840,6 +927,15 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Packing
|
||||
'packing.title': 'قائمة التجهيز',
|
||||
'packing.empty': 'قائمة التجهيز فارغة',
|
||||
'packing.import': 'استيراد',
|
||||
'packing.importTitle': 'استيراد قائمة التعبئة',
|
||||
'packing.importHint': 'عنصر واحد لكل سطر. يمكن إضافة الفئة والكمية مفصولة بفاصلة أو فاصلة منقوطة أو علامة تبويب: الاسم، الفئة، الكمية',
|
||||
'packing.importPlaceholder': 'فرشاة أسنان\nواقي شمس، نظافة\nقمصان، ملابس، 5\nجواز سفر، مستندات',
|
||||
'packing.importCsv': 'تحميل CSV/TXT',
|
||||
'packing.importAction': 'استيراد {count}',
|
||||
'packing.importSuccess': 'تم استيراد {count} عنصر',
|
||||
'packing.importError': 'فشل الاستيراد',
|
||||
'packing.importEmpty': 'لا توجد عناصر للاستيراد',
|
||||
'packing.progress': '{packed} من {total} جُهّز ({percent}%)',
|
||||
'packing.clearChecked': 'إزالة {count} محدد',
|
||||
'packing.clearCheckedShort': 'إزالة {count}',
|
||||
@@ -991,7 +1087,27 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'backup.auto.enable': 'تفعيل النسخ التلقائي',
|
||||
'backup.auto.enableHint': 'سيتم إنشاء نسخ احتياطية تلقائيًا وفق الجدول المختار',
|
||||
'backup.auto.interval': 'الفترة',
|
||||
'backup.auto.hour': 'التنفيذ في الساعة',
|
||||
'backup.auto.hourHint': 'التوقيت المحلي للخادم (تنسيق {format})',
|
||||
'backup.auto.dayOfWeek': 'يوم الأسبوع',
|
||||
'backup.auto.dayOfMonth': 'يوم الشهر',
|
||||
'backup.auto.dayOfMonthHint': 'محدود بين 1–28 للتوافق مع جميع الأشهر',
|
||||
'backup.auto.scheduleSummary': 'الجدول',
|
||||
'backup.auto.summaryDaily': 'كل يوم الساعة {hour}:00',
|
||||
'backup.auto.summaryWeekly': 'كل {day} الساعة {hour}:00',
|
||||
'backup.auto.summaryMonthly': 'اليوم {day} من كل شهر الساعة {hour}:00',
|
||||
'backup.auto.envLocked': 'Docker',
|
||||
'backup.auto.envLockedHint': 'النسخ الاحتياطي التلقائي مُعدّ عبر متغيرات بيئة Docker. لتعديل الإعدادات، حدّث docker-compose.yml وأعد تشغيل الحاوية.',
|
||||
'backup.auto.copyEnv': 'نسخ متغيرات بيئة Docker',
|
||||
'backup.auto.envCopied': 'تم نسخ متغيرات بيئة Docker إلى الحافظة',
|
||||
'backup.auto.keepLabel': 'حذف النسخ القديمة بعد',
|
||||
'backup.dow.sunday': 'أحد',
|
||||
'backup.dow.monday': 'إثن',
|
||||
'backup.dow.tuesday': 'ثلا',
|
||||
'backup.dow.wednesday': 'أرب',
|
||||
'backup.dow.thursday': 'خمي',
|
||||
'backup.dow.friday': 'جمع',
|
||||
'backup.dow.saturday': 'سبت',
|
||||
'backup.interval.hourly': 'كل ساعة',
|
||||
'backup.interval.daily': 'يوميًا',
|
||||
'backup.interval.weekly': 'أسبوعيًا',
|
||||
@@ -1118,6 +1234,45 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.editAccommodation': 'تعديل الإقامة',
|
||||
'day.reservations': 'الحجوزات',
|
||||
|
||||
// Memories / Immich
|
||||
'memories.title': 'صور',
|
||||
'memories.notConnected': 'Immich غير متصل',
|
||||
'memories.notConnectedHint': 'قم بتوصيل Immich في الإعدادات لعرض صور رحلتك هنا.',
|
||||
'memories.noDates': 'أضف تواريخ لرحلتك لتحميل الصور.',
|
||||
'memories.noPhotos': 'لم يتم العثور على صور',
|
||||
'memories.noPhotosHint': 'لم يتم العثور على صور في Immich لفترة هذه الرحلة.',
|
||||
'memories.photosFound': 'صور',
|
||||
'memories.fromOthers': 'من آخرين',
|
||||
'memories.sharePhotos': 'مشاركة الصور',
|
||||
'memories.sharing': 'مشترك',
|
||||
'memories.reviewTitle': 'مراجعة صورك',
|
||||
'memories.reviewHint': 'انقر على الصور لاستبعادها من المشاركة.',
|
||||
'memories.shareCount': 'مشاركة {count} صور',
|
||||
'memories.immichUrl': 'عنوان خادم Immich',
|
||||
'memories.immichApiKey': 'مفتاح API',
|
||||
'memories.testConnection': 'اختبار الاتصال',
|
||||
'memories.connected': 'متصل',
|
||||
'memories.disconnected': 'غير متصل',
|
||||
'memories.connectionSuccess': 'تم الاتصال بـ Immich',
|
||||
'memories.connectionError': 'تعذر الاتصال بـ Immich',
|
||||
'memories.saved': 'تم حفظ إعدادات Immich',
|
||||
'memories.oldest': 'الأقدم أولاً',
|
||||
'memories.newest': 'الأحدث أولاً',
|
||||
'memories.allLocations': 'جميع المواقع',
|
||||
'memories.addPhotos': 'إضافة صور',
|
||||
'memories.selectPhotos': 'اختيار صور من Immich',
|
||||
'memories.selectHint': 'انقر على الصور لتحديدها.',
|
||||
'memories.selected': 'محدد',
|
||||
'memories.addSelected': 'إضافة {count} صور',
|
||||
'memories.alreadyAdded': 'تمت الإضافة',
|
||||
'memories.private': 'خاص',
|
||||
'memories.stopSharing': 'إيقاف المشاركة',
|
||||
'memories.tripDates': 'تواريخ الرحلة',
|
||||
'memories.allPhotos': 'جميع الصور',
|
||||
'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
|
||||
'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
|
||||
'memories.confirmShareButton': 'مشاركة الصور',
|
||||
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'الدردشة',
|
||||
'collab.tabs.notes': 'الملاحظات',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -139,6 +139,50 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.temperature': 'Temperatureinheit',
|
||||
'settings.timeFormat': 'Zeitformat',
|
||||
'settings.routeCalculation': 'Routenberechnung',
|
||||
'settings.blurBookingCodes': 'Buchungscodes verbergen',
|
||||
'settings.notifications': 'Benachrichtigungen',
|
||||
'settings.notifyTripInvite': 'Trip-Einladungen',
|
||||
'settings.notifyBookingChange': 'Buchungsänderungen',
|
||||
'settings.notifyTripReminder': 'Trip-Erinnerungen',
|
||||
'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen',
|
||||
'settings.notifyPhotosShared': 'Geteilte Fotos (Immich)',
|
||||
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
|
||||
'settings.notifyPackingTagged': 'Packliste: Zuweisungen',
|
||||
'settings.notifyWebhook': 'Webhook-Benachrichtigungen',
|
||||
'admin.smtp.title': 'E-Mail & Benachrichtigungen',
|
||||
'admin.smtp.hint': 'SMTP-Konfiguration für E-Mail-Benachrichtigungen. Optional: Webhook-URL für Discord, Slack, etc.',
|
||||
'admin.smtp.testButton': 'Test-E-Mail senden',
|
||||
'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet',
|
||||
'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen',
|
||||
'dayplan.icsTooltip': 'Kalender exportieren (ICS)',
|
||||
'share.linkTitle': 'Öffentlicher Link',
|
||||
'share.linkHint': 'Erstelle einen Link den jeder ohne Login nutzen kann, um diese Reise anzuschauen. Nur lesen — keine Bearbeitung möglich.',
|
||||
'share.createLink': 'Link erstellen',
|
||||
'share.deleteLink': 'Link löschen',
|
||||
'share.createError': 'Link konnte nicht erstellt werden',
|
||||
'common.copy': 'Kopieren',
|
||||
'common.copied': 'Kopiert',
|
||||
'share.permMap': 'Karte & Plan',
|
||||
'share.permBookings': 'Buchungen',
|
||||
'share.permPacking': 'Packliste',
|
||||
'shared.expired': 'Link abgelaufen oder ungültig',
|
||||
'shared.expiredHint': 'Dieser geteilte Reise-Link ist nicht mehr aktiv.',
|
||||
'shared.readOnly': 'Nur-Lesen Ansicht',
|
||||
'shared.tabPlan': 'Plan',
|
||||
'shared.tabBookings': 'Buchungen',
|
||||
'shared.tabPacking': 'Packliste',
|
||||
'shared.tabBudget': 'Budget',
|
||||
'shared.tabChat': 'Chat',
|
||||
'shared.days': 'Tage',
|
||||
'shared.places': 'Orte',
|
||||
'shared.other': 'Sonstige',
|
||||
'shared.totalBudget': 'Gesamtbudget',
|
||||
'shared.messages': 'Nachrichten',
|
||||
'shared.sharedVia': 'Geteilt über',
|
||||
'shared.confirmed': 'Bestätigt',
|
||||
'shared.pending': 'Ausstehend',
|
||||
'share.permBudget': 'Budget',
|
||||
'share.permCollab': 'Chat',
|
||||
'settings.on': 'An',
|
||||
'settings.off': 'Aus',
|
||||
'settings.account': 'Konto',
|
||||
@@ -271,6 +315,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.users': 'Benutzer',
|
||||
'admin.tabs.categories': 'Kategorien',
|
||||
'admin.tabs.backup': 'Backup',
|
||||
'admin.tabs.audit': 'Audit-Protokoll',
|
||||
'admin.stats.users': 'Benutzer',
|
||||
'admin.stats.trips': 'Reisen',
|
||||
'admin.stats.places': 'Orte',
|
||||
@@ -374,8 +419,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.addons': 'Addons',
|
||||
'admin.addons.title': 'Addons',
|
||||
'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',
|
||||
@@ -388,6 +431,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.addons.catalog.atlas.description': 'Weltkarte mit besuchten Ländern und Reisestatistiken',
|
||||
'admin.addons.catalog.collab.name': 'Collab',
|
||||
'admin.addons.catalog.collab.description': 'Echtzeit-Notizen, Umfragen und Chat für die Reiseplanung',
|
||||
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
|
||||
'admin.addons.catalog.memories.description': 'Reisefotos über deine Immich-Instanz teilen',
|
||||
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
|
||||
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
|
||||
'admin.addons.enabled': 'Aktiviert',
|
||||
@@ -413,6 +458,19 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
|
||||
'admin.audit.subtitle': 'Sicherheitsrelevante und administrative Ereignisse (Backups, Benutzer, MFA, Einstellungen).',
|
||||
'admin.audit.empty': 'Noch keine Audit-Einträge.',
|
||||
'admin.audit.refresh': 'Aktualisieren',
|
||||
'admin.audit.loadMore': 'Mehr laden',
|
||||
'admin.audit.showing': '{count} geladen · {total} gesamt',
|
||||
'admin.audit.col.time': 'Zeit',
|
||||
'admin.audit.col.user': 'Benutzer',
|
||||
'admin.audit.col.action': 'Aktion',
|
||||
'admin.audit.col.resource': 'Ressource',
|
||||
'admin.audit.col.ip': 'IP',
|
||||
'admin.audit.col.details': 'Details',
|
||||
|
||||
'admin.github.title': 'Update-Verlauf',
|
||||
'admin.github.subtitle': 'Neueste Updates von {repo}',
|
||||
'admin.github.latest': 'Aktuell',
|
||||
@@ -475,7 +533,15 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.remaining': 'Rest',
|
||||
'vacay.carriedOver': 'aus {year}',
|
||||
'vacay.blockWeekends': 'Wochenenden sperren',
|
||||
'vacay.blockWeekendsHint': 'Verhindert Urlaubseinträge an Samstagen und Sonntagen',
|
||||
'vacay.blockWeekendsHint': 'Verhindert Urlaubseinträge an Wochenendtagen',
|
||||
'vacay.weekendDays': 'Wochenendtage',
|
||||
'vacay.mon': 'Mo',
|
||||
'vacay.tue': 'Di',
|
||||
'vacay.wed': 'Mi',
|
||||
'vacay.thu': 'Do',
|
||||
'vacay.fri': 'Fr',
|
||||
'vacay.sat': 'Sa',
|
||||
'vacay.sun': 'So',
|
||||
'vacay.publicHolidays': 'Feiertage',
|
||||
'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren',
|
||||
'vacay.selectCountry': 'Land wählen',
|
||||
@@ -534,6 +600,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.markVisited': 'Als besucht markieren',
|
||||
'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen',
|
||||
'atlas.addToBucket': 'Zur Bucket List',
|
||||
'atlas.addPoi': 'Ort hinzufügen',
|
||||
'atlas.bucketNamePlaceholder': 'Name (Land, Stadt, Ort...)',
|
||||
'atlas.month': 'Monat',
|
||||
'atlas.year': 'Jahr',
|
||||
'atlas.addToBucketHint': 'Als Wunschziel speichern',
|
||||
'atlas.bucketWhen': 'Wann möchtest du dorthin reisen?',
|
||||
'atlas.statsTab': 'Statistik',
|
||||
@@ -597,6 +667,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
|
||||
'dayplan.cannotReorderTransport': 'Buchungen mit fester Uhrzeit können nicht verschoben werden',
|
||||
'dayplan.confirmRemoveTimeTitle': 'Uhrzeit entfernen?',
|
||||
'dayplan.confirmRemoveTimeBody': 'Dieser Ort hat eine feste Uhrzeit ({time}). Durch das Verschieben wird die Uhrzeit entfernt und der Ort kann frei sortiert werden.',
|
||||
'dayplan.confirmRemoveTimeAction': 'Uhrzeit entfernen & verschieben',
|
||||
'dayplan.cannotDropOnTimed': 'Orte können nicht zwischen zeitgebundene Einträge geschoben werden',
|
||||
'dayplan.cannotBreakChronology': 'Die zeitliche Reihenfolge von Uhrzeiten und Buchungen darf nicht verletzt werden',
|
||||
'dayplan.addNote': 'Notiz hinzufügen',
|
||||
'dayplan.editNote': 'Notiz bearbeiten',
|
||||
'dayplan.noteAdd': 'Notiz hinzufügen',
|
||||
@@ -622,11 +698,17 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Ort/Aktivität hinzufügen',
|
||||
'places.importGpx': 'GPX importieren',
|
||||
'places.gpxImported': '{count} Orte aus GPX importiert',
|
||||
'places.urlResolved': 'Ort aus URL importiert',
|
||||
'places.gpxError': 'GPX-Import fehlgeschlagen',
|
||||
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ungeplant',
|
||||
'places.search': 'Orte suchen...',
|
||||
'places.allCategories': 'Alle Kategorien',
|
||||
'places.categoriesSelected': 'Kategorien',
|
||||
'places.clearFilter': 'Filter zurücksetzen',
|
||||
'places.count': '{count} Orte',
|
||||
'places.countSingular': '1 Ort',
|
||||
'places.allPlanned': 'Alle Orte sind eingeplant',
|
||||
@@ -725,6 +807,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.type.tour': 'Tour',
|
||||
'reservations.type.other': 'Sonstiges',
|
||||
'reservations.confirm.delete': 'Möchtest du die Reservierung "{name}" wirklich löschen?',
|
||||
'reservations.confirm.deleteTitle': 'Buchung löschen?',
|
||||
'reservations.confirm.deleteBody': '"{name}" wird unwiderruflich gelöscht.',
|
||||
'reservations.toast.updated': 'Reservierung aktualisiert',
|
||||
'reservations.toast.removed': 'Reservierung gelöscht',
|
||||
'reservations.toast.saveError': 'Fehler beim Speichern',
|
||||
@@ -748,6 +832,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.pendingSave': 'wird gespeichert…',
|
||||
'reservations.uploading': 'Wird hochgeladen...',
|
||||
'reservations.attachFile': 'Datei anhängen',
|
||||
'reservations.linkExisting': 'Vorhandene verknüpfen',
|
||||
'reservations.linkAssignment': 'Mit Tagesplanung verknüpfen',
|
||||
'reservations.pickAssignment': 'Zuordnung aus dem Plan wählen...',
|
||||
'reservations.noAssignment': 'Keine Verknüpfung',
|
||||
@@ -781,6 +866,9 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'budget.paid': 'Bezahlt',
|
||||
'budget.open': 'Offen',
|
||||
'budget.noMembers': 'Keine Teilnehmer zugewiesen',
|
||||
'budget.settlement': 'Ausgleich',
|
||||
'budget.settlementInfo': 'Klicke auf ein Mitglied-Bild bei einem Eintrag, um es grün zu markieren — das bedeutet, diese Person hat bezahlt. Der Ausgleich zeigt dann, wer wem wie viel schuldet.',
|
||||
'budget.netBalances': 'Netto-Salden',
|
||||
|
||||
// Files
|
||||
'files.title': 'Dateien',
|
||||
@@ -834,6 +922,15 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Packing
|
||||
'packing.title': 'Packliste',
|
||||
'packing.empty': 'Packliste ist leer',
|
||||
'packing.import': 'Importieren',
|
||||
'packing.importTitle': 'Packliste importieren',
|
||||
'packing.importHint': 'Ein Eintrag pro Zeile. Format: Kategorie, Name, Gewicht in g (optional), Tasche (optional), checked/unchecked (optional)',
|
||||
'packing.importPlaceholder': 'Hygiene, Zahnbürste\nKleidung, T-Shirts, 200\nDokumente, Reisepass, , Handgepäck\nElektronik, Ladekabel, 50, Koffer, checked',
|
||||
'packing.importCsv': 'CSV/TXT laden',
|
||||
'packing.importAction': '{count} importieren',
|
||||
'packing.importSuccess': '{count} Einträge importiert',
|
||||
'packing.importError': 'Import fehlgeschlagen',
|
||||
'packing.importEmpty': 'Keine Einträge zum Importieren',
|
||||
'packing.progress': '{packed} von {total} gepackt ({percent}%)',
|
||||
'packing.clearChecked': '{count} abgehakte entfernen',
|
||||
'packing.clearCheckedShort': '{count} entfernen',
|
||||
@@ -985,7 +1082,27 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'backup.auto.enable': 'Auto-Backup aktivieren',
|
||||
'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt',
|
||||
'backup.auto.interval': 'Intervall',
|
||||
'backup.auto.hour': 'Ausführung um',
|
||||
'backup.auto.hourHint': 'Lokale Serverzeit ({format}-Format)',
|
||||
'backup.auto.dayOfWeek': 'Wochentag',
|
||||
'backup.auto.dayOfMonth': 'Tag des Monats',
|
||||
'backup.auto.dayOfMonthHint': 'Auf 1–28 beschränkt, um mit allen Monaten kompatibel zu sein',
|
||||
'backup.auto.scheduleSummary': 'Zeitplan',
|
||||
'backup.auto.summaryDaily': 'Täglich um {hour}:00',
|
||||
'backup.auto.summaryWeekly': 'Jeden {day} um {hour}:00',
|
||||
'backup.auto.summaryMonthly': 'Am {day}. jedes Monats um {hour}:00',
|
||||
'backup.auto.envLocked': 'Docker',
|
||||
'backup.auto.envLockedHint': 'Auto-Backup wird über Docker-Umgebungsvariablen konfiguriert. Ändern Sie Ihre docker-compose.yml und starten Sie den Container neu.',
|
||||
'backup.auto.copyEnv': 'Docker-Umgebungsvariablen kopieren',
|
||||
'backup.auto.envCopied': 'Docker-Umgebungsvariablen in die Zwischenablage kopiert',
|
||||
'backup.auto.keepLabel': 'Alte Backups löschen nach',
|
||||
'backup.dow.sunday': 'So',
|
||||
'backup.dow.monday': 'Mo',
|
||||
'backup.dow.tuesday': 'Di',
|
||||
'backup.dow.wednesday': 'Mi',
|
||||
'backup.dow.thursday': 'Do',
|
||||
'backup.dow.friday': 'Fr',
|
||||
'backup.dow.saturday': 'Sa',
|
||||
'backup.interval.hourly': 'Stündlich',
|
||||
'backup.interval.daily': 'Täglich',
|
||||
'backup.interval.weekly': 'Wöchentlich',
|
||||
@@ -1112,6 +1229,45 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.editAccommodation': 'Unterkunft bearbeiten',
|
||||
'day.reservations': 'Reservierungen',
|
||||
|
||||
// Photos / Immich
|
||||
'memories.title': 'Fotos',
|
||||
'memories.notConnected': 'Immich nicht verbunden',
|
||||
'memories.notConnectedHint': 'Verbinde deine Immich-Instanz in den Einstellungen, um deine Reisefotos hier zu sehen.',
|
||||
'memories.noDates': 'Füge Daten zu deiner Reise hinzu, um Fotos zu laden.',
|
||||
'memories.noPhotos': 'Keine Fotos gefunden',
|
||||
'memories.noPhotosHint': 'Keine Fotos in Immich für den Zeitraum dieser Reise gefunden.',
|
||||
'memories.photosFound': 'Fotos',
|
||||
'memories.fromOthers': 'von anderen',
|
||||
'memories.sharePhotos': 'Fotos teilen',
|
||||
'memories.sharing': 'Wird geteilt',
|
||||
'memories.reviewTitle': 'Deine Fotos prüfen',
|
||||
'memories.reviewHint': 'Klicke auf Fotos, um sie vom Teilen auszuschließen.',
|
||||
'memories.shareCount': '{count} Fotos teilen',
|
||||
'memories.immichUrl': 'Immich Server URL',
|
||||
'memories.immichApiKey': 'API-Schlüssel',
|
||||
'memories.testConnection': 'Verbindung testen',
|
||||
'memories.connected': 'Verbunden',
|
||||
'memories.disconnected': 'Nicht verbunden',
|
||||
'memories.connectionSuccess': 'Verbindung zu Immich hergestellt',
|
||||
'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen',
|
||||
'memories.saved': 'Immich-Einstellungen gespeichert',
|
||||
'memories.addPhotos': 'Fotos hinzufügen',
|
||||
'memories.selectPhotos': 'Fotos aus Immich auswählen',
|
||||
'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.',
|
||||
'memories.selected': 'ausgewählt',
|
||||
'memories.addSelected': '{count} Fotos hinzufügen',
|
||||
'memories.alreadyAdded': 'Hinzugefügt',
|
||||
'memories.private': 'Privat',
|
||||
'memories.stopSharing': 'Nicht mehr teilen',
|
||||
'memories.oldest': 'Älteste zuerst',
|
||||
'memories.newest': 'Neueste zuerst',
|
||||
'memories.allLocations': 'Alle Orte',
|
||||
'memories.tripDates': 'Trip-Zeitraum',
|
||||
'memories.allPhotos': 'Alle Fotos',
|
||||
'memories.confirmShareTitle': 'Mit Reisebegleitern teilen?',
|
||||
'memories.confirmShareHint': '{count} Fotos werden für alle Mitglieder dieses Trips sichtbar. Du kannst einzelne Fotos nachträglich auf privat setzen.',
|
||||
'memories.confirmShareButton': 'Fotos teilen',
|
||||
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'Chat',
|
||||
'collab.tabs.notes': 'Notizen',
|
||||
|
||||
@@ -139,6 +139,50 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.temperature': 'Temperature Unit',
|
||||
'settings.timeFormat': 'Time Format',
|
||||
'settings.routeCalculation': 'Route Calculation',
|
||||
'settings.blurBookingCodes': 'Blur Booking Codes',
|
||||
'settings.notifications': 'Notifications',
|
||||
'settings.notifyTripInvite': 'Trip invitations',
|
||||
'settings.notifyBookingChange': 'Booking changes',
|
||||
'settings.notifyTripReminder': 'Trip reminders',
|
||||
'settings.notifyVacayInvite': 'Vacay fusion invitations',
|
||||
'settings.notifyPhotosShared': 'Shared photos (Immich)',
|
||||
'settings.notifyCollabMessage': 'Chat messages (Collab)',
|
||||
'settings.notifyPackingTagged': 'Packing list: assignments',
|
||||
'settings.notifyWebhook': 'Webhook notifications',
|
||||
'admin.smtp.title': 'Email & Notifications',
|
||||
'admin.smtp.hint': 'SMTP configuration for email notifications. Optional: Webhook URL for Discord, Slack, etc.',
|
||||
'admin.smtp.testButton': 'Send test email',
|
||||
'admin.smtp.testSuccess': 'Test email sent successfully',
|
||||
'admin.smtp.testFailed': 'Test email failed',
|
||||
'dayplan.icsTooltip': 'Export calendar (ICS)',
|
||||
'share.linkTitle': 'Public Link',
|
||||
'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
|
||||
'share.createLink': 'Create link',
|
||||
'share.deleteLink': 'Delete link',
|
||||
'share.createError': 'Could not create link',
|
||||
'common.copy': 'Copy',
|
||||
'common.copied': 'Copied',
|
||||
'share.permMap': 'Map & Plan',
|
||||
'share.permBookings': 'Bookings',
|
||||
'share.permPacking': 'Packing',
|
||||
'shared.expired': 'Link expired or invalid',
|
||||
'shared.expiredHint': 'This shared trip link is no longer active.',
|
||||
'shared.readOnly': 'Read-only shared view',
|
||||
'shared.tabPlan': 'Plan',
|
||||
'shared.tabBookings': 'Bookings',
|
||||
'shared.tabPacking': 'Packing',
|
||||
'shared.tabBudget': 'Budget',
|
||||
'shared.tabChat': 'Chat',
|
||||
'shared.days': 'days',
|
||||
'shared.places': 'places',
|
||||
'shared.other': 'Other',
|
||||
'shared.totalBudget': 'Total Budget',
|
||||
'shared.messages': 'messages',
|
||||
'shared.sharedVia': 'Shared via',
|
||||
'shared.confirmed': 'Confirmed',
|
||||
'shared.pending': 'Pending',
|
||||
'share.permBudget': 'Budget',
|
||||
'share.permCollab': 'Chat',
|
||||
'settings.on': 'On',
|
||||
'settings.off': 'Off',
|
||||
'settings.account': 'Account',
|
||||
@@ -271,6 +315,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.users': 'Users',
|
||||
'admin.tabs.categories': 'Categories',
|
||||
'admin.tabs.backup': 'Backup',
|
||||
'admin.tabs.audit': 'Audit log',
|
||||
'admin.stats.users': 'Users',
|
||||
'admin.stats.trips': 'Trips',
|
||||
'admin.stats.places': 'Places',
|
||||
@@ -374,8 +419,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.addons': 'Addons',
|
||||
'admin.addons.title': 'Addons',
|
||||
'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',
|
||||
@@ -388,6 +431,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.addons.catalog.atlas.description': 'World map with visited countries and travel stats',
|
||||
'admin.addons.catalog.collab.name': 'Collab',
|
||||
'admin.addons.catalog.collab.description': 'Real-time notes, polls, and chat for trip planning',
|
||||
'admin.addons.catalog.memories.name': 'Photos (Immich)',
|
||||
'admin.addons.catalog.memories.description': 'Share trip photos via your Immich instance',
|
||||
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
|
||||
'admin.addons.subtitleAfter': ' experience.',
|
||||
'admin.addons.enabled': 'Enabled',
|
||||
@@ -413,6 +458,18 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
|
||||
'admin.audit.subtitle': 'Security-sensitive and administration events (backups, users, MFA, settings).',
|
||||
'admin.audit.empty': 'No audit entries yet.',
|
||||
'admin.audit.refresh': 'Refresh',
|
||||
'admin.audit.loadMore': 'Load more',
|
||||
'admin.audit.showing': '{count} loaded · {total} total',
|
||||
'admin.audit.col.time': 'Time',
|
||||
'admin.audit.col.user': 'User',
|
||||
'admin.audit.col.action': 'Action',
|
||||
'admin.audit.col.resource': 'Resource',
|
||||
'admin.audit.col.ip': 'IP',
|
||||
'admin.audit.col.details': 'Details',
|
||||
'admin.github.title': 'Release History',
|
||||
'admin.github.subtitle': 'Latest updates from {repo}',
|
||||
'admin.github.latest': 'Latest',
|
||||
@@ -475,7 +532,15 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.remaining': 'Left',
|
||||
'vacay.carriedOver': 'from {year}',
|
||||
'vacay.blockWeekends': 'Block Weekends',
|
||||
'vacay.blockWeekendsHint': 'Prevent vacation entries on Saturdays and Sundays',
|
||||
'vacay.blockWeekendsHint': 'Prevent vacation entries on weekend days',
|
||||
'vacay.weekendDays': 'Weekend days',
|
||||
'vacay.mon': 'Mon',
|
||||
'vacay.tue': 'Tue',
|
||||
'vacay.wed': 'Wed',
|
||||
'vacay.thu': 'Thu',
|
||||
'vacay.fri': 'Fri',
|
||||
'vacay.sat': 'Sat',
|
||||
'vacay.sun': 'Sun',
|
||||
'vacay.publicHolidays': 'Public Holidays',
|
||||
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
|
||||
'vacay.selectCountry': 'Select country',
|
||||
@@ -534,6 +599,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.markVisited': 'Mark as visited',
|
||||
'atlas.markVisitedHint': 'Add this country to your visited list',
|
||||
'atlas.addToBucket': 'Add to bucket list',
|
||||
'atlas.addPoi': 'Add place',
|
||||
'atlas.bucketNamePlaceholder': 'Name (country, city, place...)',
|
||||
'atlas.month': 'Month',
|
||||
'atlas.year': 'Year',
|
||||
'atlas.addToBucketHint': 'Save as a place you want to visit',
|
||||
'atlas.bucketWhen': 'When do you plan to visit?',
|
||||
'atlas.statsTab': 'Stats',
|
||||
@@ -597,6 +666,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'No places planned for this day',
|
||||
'dayplan.cannotReorderTransport': 'Bookings with a fixed time cannot be reordered',
|
||||
'dayplan.confirmRemoveTimeTitle': 'Remove time?',
|
||||
'dayplan.confirmRemoveTimeBody': 'This place has a fixed time ({time}). Moving it will remove the time and allow free sorting.',
|
||||
'dayplan.confirmRemoveTimeAction': 'Remove time & move',
|
||||
'dayplan.cannotDropOnTimed': 'Items cannot be placed between time-bound entries',
|
||||
'dayplan.cannotBreakChronology': 'This would break the chronological order of timed items and bookings',
|
||||
'dayplan.addNote': 'Add Note',
|
||||
'dayplan.editNote': 'Edit Note',
|
||||
'dayplan.noteAdd': 'Add Note',
|
||||
@@ -622,11 +697,17 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Add Place/Activity',
|
||||
'places.importGpx': 'Import GPX',
|
||||
'places.gpxImported': '{count} places imported from GPX',
|
||||
'places.urlResolved': 'Place imported from URL',
|
||||
'places.gpxError': 'GPX import failed',
|
||||
'places.assignToDay': 'Add to which day?',
|
||||
'places.all': 'All',
|
||||
'places.unplanned': 'Unplanned',
|
||||
'places.search': 'Search places...',
|
||||
'places.allCategories': 'All Categories',
|
||||
'places.categoriesSelected': 'categories',
|
||||
'places.clearFilter': 'Clear filter',
|
||||
'places.count': '{count} places',
|
||||
'places.countSingular': '1 place',
|
||||
'places.allPlanned': 'All places are planned',
|
||||
@@ -725,6 +806,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.type.tour': 'Tour',
|
||||
'reservations.type.other': 'Other',
|
||||
'reservations.confirm.delete': 'Are you sure you want to delete the reservation "{name}"?',
|
||||
'reservations.confirm.deleteTitle': 'Delete booking?',
|
||||
'reservations.confirm.deleteBody': '"{name}" will be permanently deleted.',
|
||||
'reservations.toast.updated': 'Reservation updated',
|
||||
'reservations.toast.removed': 'Reservation deleted',
|
||||
'reservations.toast.fileUploaded': 'File uploaded',
|
||||
@@ -744,6 +827,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.pendingSave': 'will be saved…',
|
||||
'reservations.uploading': 'Uploading...',
|
||||
'reservations.attachFile': 'Attach file',
|
||||
'reservations.linkExisting': 'Link existing file',
|
||||
'reservations.toast.saveError': 'Failed to save',
|
||||
'reservations.toast.updateError': 'Failed to update',
|
||||
'reservations.toast.deleteError': 'Failed to delete',
|
||||
@@ -781,6 +865,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'budget.paid': 'Paid',
|
||||
'budget.open': 'Open',
|
||||
'budget.noMembers': 'No members assigned',
|
||||
'budget.settlement': 'Settlement',
|
||||
'budget.settlementInfo': 'Click a member avatar on a budget item to mark them green — this means they paid. The settlement then shows who owes whom and how much.',
|
||||
'budget.netBalances': 'Net Balances',
|
||||
|
||||
// Files
|
||||
'files.title': 'Files',
|
||||
@@ -834,6 +921,15 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Packing
|
||||
'packing.title': 'Packing List',
|
||||
'packing.empty': 'Packing list is empty',
|
||||
'packing.import': 'Import',
|
||||
'packing.importTitle': 'Import Packing List',
|
||||
'packing.importHint': 'One item per line. Format: Category, Name, Weight in g (optional), Bag (optional), checked/unchecked (optional)',
|
||||
'packing.importPlaceholder': 'Hygiene, Toothbrush\nClothing, T-Shirts, 200\nDocuments, Passport, , Carry-on\nElectronics, Charger, 50, Suitcase, checked',
|
||||
'packing.importCsv': 'Load CSV/TXT',
|
||||
'packing.importAction': 'Import {count}',
|
||||
'packing.importSuccess': '{count} items imported',
|
||||
'packing.importError': 'Import failed',
|
||||
'packing.importEmpty': 'No items to import',
|
||||
'packing.progress': '{packed} of {total} packed ({percent}%)',
|
||||
'packing.clearChecked': 'Remove {count} checked',
|
||||
'packing.clearCheckedShort': 'Remove {count}',
|
||||
@@ -985,7 +1081,27 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'backup.auto.enable': 'Enable auto-backup',
|
||||
'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule',
|
||||
'backup.auto.interval': 'Interval',
|
||||
'backup.auto.hour': 'Run at hour',
|
||||
'backup.auto.hourHint': 'Server local time ({format} format)',
|
||||
'backup.auto.dayOfWeek': 'Day of week',
|
||||
'backup.auto.dayOfMonth': 'Day of month',
|
||||
'backup.auto.dayOfMonthHint': 'Limited to 1–28 for compatibility with all months',
|
||||
'backup.auto.scheduleSummary': 'Schedule',
|
||||
'backup.auto.summaryDaily': 'Every day at {hour}:00',
|
||||
'backup.auto.summaryWeekly': 'Every {day} at {hour}:00',
|
||||
'backup.auto.summaryMonthly': 'Day {day} of every month at {hour}:00',
|
||||
'backup.auto.envLocked': 'Docker',
|
||||
'backup.auto.envLockedHint': 'Auto-backup is configured via Docker environment variables. To change these settings, update your docker-compose.yml and restart the container.',
|
||||
'backup.auto.copyEnv': 'Copy Docker env vars',
|
||||
'backup.auto.envCopied': 'Docker env vars copied to clipboard',
|
||||
'backup.auto.keepLabel': 'Delete old backups after',
|
||||
'backup.dow.sunday': 'Sun',
|
||||
'backup.dow.monday': 'Mon',
|
||||
'backup.dow.tuesday': 'Tue',
|
||||
'backup.dow.wednesday': 'Wed',
|
||||
'backup.dow.thursday': 'Thu',
|
||||
'backup.dow.friday': 'Fri',
|
||||
'backup.dow.saturday': 'Sat',
|
||||
'backup.interval.hourly': 'Hourly',
|
||||
'backup.interval.daily': 'Daily',
|
||||
'backup.interval.weekly': 'Weekly',
|
||||
@@ -1112,6 +1228,45 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.editAccommodation': 'Edit accommodation',
|
||||
'day.reservations': 'Reservations',
|
||||
|
||||
// Photos / Immich
|
||||
'memories.title': 'Photos',
|
||||
'memories.notConnected': 'Immich not connected',
|
||||
'memories.notConnectedHint': 'Connect your Immich instance in Settings to see your trip photos here.',
|
||||
'memories.noDates': 'Add dates to your trip to load photos.',
|
||||
'memories.noPhotos': 'No photos found',
|
||||
'memories.noPhotosHint': 'No photos found in Immich for this trip\'s date range.',
|
||||
'memories.photosFound': 'photos',
|
||||
'memories.fromOthers': 'from others',
|
||||
'memories.sharePhotos': 'Share photos',
|
||||
'memories.sharing': 'Sharing',
|
||||
'memories.reviewTitle': 'Review your photos',
|
||||
'memories.reviewHint': 'Click photos to exclude them from sharing.',
|
||||
'memories.shareCount': 'Share {count} photos',
|
||||
'memories.immichUrl': 'Immich Server URL',
|
||||
'memories.immichApiKey': 'API Key',
|
||||
'memories.testConnection': 'Test connection',
|
||||
'memories.connected': 'Connected',
|
||||
'memories.disconnected': 'Not connected',
|
||||
'memories.connectionSuccess': 'Connected to Immich',
|
||||
'memories.connectionError': 'Could not connect to Immich',
|
||||
'memories.saved': 'Immich settings saved',
|
||||
'memories.addPhotos': 'Add photos',
|
||||
'memories.selectPhotos': 'Select photos from Immich',
|
||||
'memories.selectHint': 'Tap photos to select them.',
|
||||
'memories.selected': 'selected',
|
||||
'memories.addSelected': 'Add {count} photos',
|
||||
'memories.alreadyAdded': 'Added',
|
||||
'memories.private': 'Private',
|
||||
'memories.stopSharing': 'Stop sharing',
|
||||
'memories.oldest': 'Oldest first',
|
||||
'memories.newest': 'Newest first',
|
||||
'memories.allLocations': 'All locations',
|
||||
'memories.tripDates': 'Trip dates',
|
||||
'memories.allPhotos': 'All photos',
|
||||
'memories.confirmShareTitle': 'Share with trip members?',
|
||||
'memories.confirmShareHint': '{count} photos will be visible to all members of this trip. You can make individual photos private later.',
|
||||
'memories.confirmShareButton': 'Share photos',
|
||||
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'Chat',
|
||||
'collab.tabs.notes': 'Notes',
|
||||
|
||||
@@ -140,6 +140,50 @@ const es: Record<string, string> = {
|
||||
'settings.temperature': 'Unidad de temperatura',
|
||||
'settings.timeFormat': 'Formato de hora',
|
||||
'settings.routeCalculation': 'Cálculo de ruta',
|
||||
'settings.blurBookingCodes': 'Difuminar códigos de reserva',
|
||||
'settings.notifications': 'Notificaciones',
|
||||
'settings.notifyTripInvite': 'Invitaciones de viaje',
|
||||
'settings.notifyBookingChange': 'Cambios en reservas',
|
||||
'settings.notifyTripReminder': 'Recordatorios de viaje',
|
||||
'settings.notifyVacayInvite': 'Invitaciones de fusión Vacay',
|
||||
'settings.notifyPhotosShared': 'Fotos compartidas (Immich)',
|
||||
'settings.notifyCollabMessage': 'Mensajes de chat (Collab)',
|
||||
'settings.notifyPackingTagged': 'Lista de equipaje: asignaciones',
|
||||
'settings.notifyWebhook': 'Notificaciones webhook',
|
||||
'admin.smtp.title': 'Correo y notificaciones',
|
||||
'admin.smtp.hint': 'Configuración SMTP para notificaciones por correo. Opcional: URL webhook para Discord, Slack, etc.',
|
||||
'admin.smtp.testButton': 'Enviar correo de prueba',
|
||||
'admin.smtp.testSuccess': 'Correo de prueba enviado correctamente',
|
||||
'admin.smtp.testFailed': 'Error al enviar correo de prueba',
|
||||
'dayplan.icsTooltip': 'Exportar calendario (ICS)',
|
||||
'share.linkTitle': 'Enlace público',
|
||||
'share.linkHint': 'Crea un enlace que cualquiera puede usar para ver este viaje sin iniciar sesión. Solo lectura — no se puede editar.',
|
||||
'share.createLink': 'Crear enlace',
|
||||
'share.deleteLink': 'Eliminar enlace',
|
||||
'share.createError': 'No se pudo crear el enlace',
|
||||
'common.copy': 'Copiar',
|
||||
'common.copied': 'Copiado',
|
||||
'share.permMap': 'Mapa y plan',
|
||||
'share.permBookings': 'Reservas',
|
||||
'share.permPacking': 'Equipaje',
|
||||
'shared.expired': 'Enlace expirado o inválido',
|
||||
'shared.expiredHint': 'Este enlace de viaje compartido ya no está activo.',
|
||||
'shared.readOnly': 'Vista de solo lectura',
|
||||
'shared.tabPlan': 'Plan',
|
||||
'shared.tabBookings': 'Reservas',
|
||||
'shared.tabPacking': 'Equipaje',
|
||||
'shared.tabBudget': 'Presupuesto',
|
||||
'shared.tabChat': 'Chat',
|
||||
'shared.days': 'días',
|
||||
'shared.places': 'lugares',
|
||||
'shared.other': 'Otro',
|
||||
'shared.totalBudget': 'Presupuesto total',
|
||||
'shared.messages': 'mensajes',
|
||||
'shared.sharedVia': 'Compartido vía',
|
||||
'shared.confirmed': 'Confirmado',
|
||||
'shared.pending': 'Pendiente',
|
||||
'share.permBudget': 'Presupuesto',
|
||||
'share.permCollab': 'Chat',
|
||||
'settings.on': 'Activado',
|
||||
'settings.off': 'Desactivado',
|
||||
'settings.account': 'Cuenta',
|
||||
@@ -269,6 +313,7 @@ const es: Record<string, string> = {
|
||||
'admin.tabs.users': 'Usuarios',
|
||||
'admin.tabs.categories': 'Categorías',
|
||||
'admin.tabs.backup': 'Copia de seguridad',
|
||||
'admin.tabs.audit': 'Registro de auditoría',
|
||||
'admin.stats.users': 'Usuarios',
|
||||
'admin.stats.trips': 'Viajes',
|
||||
'admin.stats.places': 'Lugares',
|
||||
@@ -393,6 +438,19 @@ const es: Record<string, string> = {
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
|
||||
'admin.audit.subtitle': 'Eventos sensibles de seguridad y administración (copias de seguridad, usuarios, MFA, ajustes).',
|
||||
'admin.audit.empty': 'Aún no hay entradas de auditoría.',
|
||||
'admin.audit.refresh': 'Actualizar',
|
||||
'admin.audit.loadMore': 'Cargar más',
|
||||
'admin.audit.showing': '{count} cargados · {total} en total',
|
||||
'admin.audit.col.time': 'Fecha y hora',
|
||||
'admin.audit.col.user': 'Usuario',
|
||||
'admin.audit.col.action': 'Acción',
|
||||
'admin.audit.col.resource': 'Recurso',
|
||||
'admin.audit.col.ip': 'IP',
|
||||
'admin.audit.col.details': 'Detalles',
|
||||
|
||||
'admin.github.title': 'Historial de versiones',
|
||||
'admin.github.subtitle': 'Últimas novedades de {repo}',
|
||||
'admin.github.latest': 'Última',
|
||||
@@ -455,6 +513,14 @@ const es: Record<string, string> = {
|
||||
'vacay.carriedOver': 'de {year}',
|
||||
'vacay.blockWeekends': 'Bloquear fines de semana',
|
||||
'vacay.blockWeekendsHint': 'Impide marcar vacaciones en sábados y domingos',
|
||||
'vacay.weekendDays': 'Días de fin de semana',
|
||||
'vacay.mon': 'Lun',
|
||||
'vacay.tue': 'Mar',
|
||||
'vacay.wed': 'Mié',
|
||||
'vacay.thu': 'Jue',
|
||||
'vacay.fri': 'Vie',
|
||||
'vacay.sat': 'Sáb',
|
||||
'vacay.sun': 'Dom',
|
||||
'vacay.publicHolidays': 'Festivos',
|
||||
'vacay.publicHolidaysHint': 'Marcar festivos en el calendario',
|
||||
'vacay.selectCountry': 'Seleccionar país',
|
||||
@@ -548,6 +614,10 @@ const es: Record<string, string> = {
|
||||
'atlas.markVisited': 'Marcar como visitado',
|
||||
'atlas.markVisitedHint': 'Añadir este país a tu lista de visitados',
|
||||
'atlas.addToBucket': 'Añadir a lista de deseos',
|
||||
'atlas.addPoi': 'Añadir lugar',
|
||||
'atlas.bucketNamePlaceholder': 'Nombre (país, ciudad, lugar…)',
|
||||
'atlas.month': 'Mes',
|
||||
'atlas.year': 'Año',
|
||||
'atlas.addToBucketHint': 'Guardar como lugar que quieres visitar',
|
||||
'atlas.bucketWhen': '¿Cuándo planeas visitarlo?',
|
||||
|
||||
@@ -597,14 +667,26 @@ const es: Record<string, string> = {
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Exportar plan diario como PDF',
|
||||
'dayplan.pdfError': 'No se pudo exportar el PDF',
|
||||
'dayplan.cannotReorderTransport': 'Las reservas con hora fija no se pueden reordenar',
|
||||
'dayplan.confirmRemoveTimeTitle': '¿Eliminar hora?',
|
||||
'dayplan.confirmRemoveTimeBody': 'Este lugar tiene una hora fija ({time}). Al moverlo se eliminará la hora y se permitirá el orden libre.',
|
||||
'dayplan.confirmRemoveTimeAction': 'Eliminar hora y mover',
|
||||
'dayplan.cannotDropOnTimed': 'No se pueden colocar elementos entre entradas con hora fija',
|
||||
'dayplan.cannotBreakChronology': 'Esto rompería el orden cronológico de los elementos y reservas programados',
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Añadir lugar/actividad',
|
||||
'places.importGpx': 'Importar GPX',
|
||||
'places.gpxImported': '{count} lugares importados desde GPX',
|
||||
'places.gpxError': 'Error al importar GPX',
|
||||
'places.urlResolved': 'Lugar importado desde URL',
|
||||
'places.assignToDay': '¿A qué día añadirlo?',
|
||||
'places.all': 'Todo',
|
||||
'places.unplanned': 'Sin planificar',
|
||||
'places.search': 'Buscar lugares...',
|
||||
'places.allCategories': 'Todas las categorías',
|
||||
'places.categoriesSelected': 'categorías',
|
||||
'places.clearFilter': 'Borrar filtro',
|
||||
'places.count': '{count} lugares',
|
||||
'places.countSingular': '1 lugar',
|
||||
'places.allPlanned': 'Todos los lugares están planificados',
|
||||
@@ -687,6 +769,8 @@ const es: Record<string, string> = {
|
||||
'reservations.type.tour': 'Tour',
|
||||
'reservations.type.other': 'Otro',
|
||||
'reservations.confirm.delete': '¿Seguro que quieres eliminar la reserva "{name}"?',
|
||||
'reservations.confirm.deleteTitle': '¿Eliminar reserva?',
|
||||
'reservations.confirm.deleteBody': '« {name} » se eliminará permanentemente.',
|
||||
'reservations.toast.updated': 'Reserva actualizada',
|
||||
'reservations.toast.removed': 'Reserva eliminada',
|
||||
'reservations.toast.fileUploaded': 'Archivo subido',
|
||||
@@ -706,6 +790,7 @@ const es: Record<string, string> = {
|
||||
'reservations.pendingSave': 'se guardará…',
|
||||
'reservations.uploading': 'Subiendo...',
|
||||
'reservations.attachFile': 'Adjuntar archivo',
|
||||
'reservations.linkExisting': 'Vincular archivo existente',
|
||||
'reservations.toast.saveError': 'No se pudo guardar',
|
||||
'reservations.toast.updateError': 'No se pudo actualizar',
|
||||
'reservations.toast.deleteError': 'No se pudo eliminar',
|
||||
@@ -743,6 +828,9 @@ const es: Record<string, string> = {
|
||||
'budget.paid': 'Pagado',
|
||||
'budget.open': 'Abrir',
|
||||
'budget.noMembers': 'No hay miembros asignados',
|
||||
'budget.settlement': 'Liquidación',
|
||||
'budget.settlementInfo': 'Haz clic en el avatar de un miembro en una partida del presupuesto para marcarlo en verde — esto significa que ha pagado. La liquidación muestra quién debe cuánto a quién.',
|
||||
'budget.netBalances': 'Saldos netos',
|
||||
|
||||
// Files
|
||||
'files.title': 'Archivos',
|
||||
@@ -774,6 +862,15 @@ const es: Record<string, string> = {
|
||||
// Packing
|
||||
'packing.title': 'Lista de equipaje',
|
||||
'packing.empty': 'La lista de equipaje está vacía',
|
||||
'packing.import': 'Importar',
|
||||
'packing.importTitle': 'Importar lista de equipaje',
|
||||
'packing.importHint': 'Un elemento por línea. Categoría y cantidad opcionales separadas por coma, punto y coma o tabulación: Nombre, Categoría, Cantidad',
|
||||
'packing.importPlaceholder': 'Cepillo de dientes\nProtector solar, Higiene\nCamisetas, Ropa, 5\nPasaporte, Documentos',
|
||||
'packing.importCsv': 'Cargar CSV/TXT',
|
||||
'packing.importAction': 'Importar {count}',
|
||||
'packing.importSuccess': '{count} elementos importados',
|
||||
'packing.importError': 'Error al importar',
|
||||
'packing.importEmpty': 'Sin elementos para importar',
|
||||
'packing.progress': '{packed} de {total} preparados ({percent}%)',
|
||||
'packing.clearChecked': 'Eliminar {count} marcados',
|
||||
'packing.clearCheckedShort': 'Eliminar {count}',
|
||||
@@ -925,7 +1022,27 @@ const es: Record<string, string> = {
|
||||
'backup.auto.enable': 'Activar copia automática',
|
||||
'backup.auto.enableHint': 'Se crearán copias automáticamente según la frecuencia elegida',
|
||||
'backup.auto.interval': 'Intervalo',
|
||||
'backup.auto.hour': 'Ejecutar a la hora',
|
||||
'backup.auto.hourHint': 'Hora local del servidor (formato {format})',
|
||||
'backup.auto.dayOfWeek': 'Día de la semana',
|
||||
'backup.auto.dayOfMonth': 'Día del mes',
|
||||
'backup.auto.dayOfMonthHint': 'Limitado a 1–28 para compatibilidad con todos los meses',
|
||||
'backup.auto.scheduleSummary': 'Programación',
|
||||
'backup.auto.summaryDaily': 'Todos los días a las {hour}:00',
|
||||
'backup.auto.summaryWeekly': 'Cada {day} a las {hour}:00',
|
||||
'backup.auto.summaryMonthly': 'El día {day} de cada mes a las {hour}:00',
|
||||
'backup.auto.envLocked': 'Docker',
|
||||
'backup.auto.envLockedHint': 'La copia automática está configurada mediante variables de entorno Docker. Para cambiar estos ajustes, actualiza tu docker-compose.yml y reinicia el contenedor.',
|
||||
'backup.auto.copyEnv': 'Copiar variables de entorno Docker',
|
||||
'backup.auto.envCopied': 'Variables de entorno Docker copiadas al portapapeles',
|
||||
'backup.auto.keepLabel': 'Eliminar copias antiguas después de',
|
||||
'backup.dow.sunday': 'Dom',
|
||||
'backup.dow.monday': 'Lun',
|
||||
'backup.dow.tuesday': 'Mar',
|
||||
'backup.dow.wednesday': 'Mié',
|
||||
'backup.dow.thursday': 'Jue',
|
||||
'backup.dow.friday': 'Vie',
|
||||
'backup.dow.saturday': 'Sáb',
|
||||
'backup.interval.hourly': 'Cada hora',
|
||||
'backup.interval.daily': 'Diaria',
|
||||
'backup.interval.weekly': 'Semanal',
|
||||
@@ -1065,6 +1182,45 @@ const es: Record<string, string> = {
|
||||
'day.editAccommodation': 'Editar alojamiento',
|
||||
'day.reservations': 'Reservas',
|
||||
|
||||
// Memories / Immich
|
||||
'memories.title': 'Fotos',
|
||||
'memories.notConnected': 'Immich no conectado',
|
||||
'memories.notConnectedHint': 'Conecta tu instancia de Immich en Ajustes para ver tus fotos de viaje aquí.',
|
||||
'memories.noDates': 'Añade fechas a tu viaje para cargar fotos.',
|
||||
'memories.noPhotos': 'No se encontraron fotos',
|
||||
'memories.noPhotosHint': 'No se encontraron fotos en Immich para el rango de fechas de este viaje.',
|
||||
'memories.photosFound': 'fotos',
|
||||
'memories.fromOthers': 'de otros',
|
||||
'memories.sharePhotos': 'Compartir fotos',
|
||||
'memories.sharing': 'Compartiendo',
|
||||
'memories.reviewTitle': 'Revisar tus fotos',
|
||||
'memories.reviewHint': 'Haz clic en las fotos para excluirlas de compartir.',
|
||||
'memories.shareCount': 'Compartir {count} fotos',
|
||||
'memories.immichUrl': 'URL del servidor Immich',
|
||||
'memories.immichApiKey': 'Clave API',
|
||||
'memories.testConnection': 'Probar conexión',
|
||||
'memories.connected': 'Conectado',
|
||||
'memories.disconnected': 'No conectado',
|
||||
'memories.connectionSuccess': 'Conectado a Immich',
|
||||
'memories.connectionError': 'No se pudo conectar a Immich',
|
||||
'memories.saved': 'Configuración de Immich guardada',
|
||||
'memories.oldest': 'Más antiguas',
|
||||
'memories.newest': 'Más recientes',
|
||||
'memories.allLocations': 'Todas las ubicaciones',
|
||||
'memories.addPhotos': 'Añadir fotos',
|
||||
'memories.selectPhotos': 'Seleccionar fotos de Immich',
|
||||
'memories.selectHint': 'Toca las fotos para seleccionarlas.',
|
||||
'memories.selected': 'seleccionado(s)',
|
||||
'memories.addSelected': 'Añadir {count} fotos',
|
||||
'memories.alreadyAdded': 'Añadido',
|
||||
'memories.private': 'Privado',
|
||||
'memories.stopSharing': 'Dejar de compartir',
|
||||
'memories.tripDates': 'Fechas del viaje',
|
||||
'memories.allPhotos': 'Todas las fotos',
|
||||
'memories.confirmShareTitle': '¿Compartir con los miembros del viaje?',
|
||||
'memories.confirmShareHint': '{count} fotos serán visibles para todos los miembros de este viaje. Puedes hacer fotos individuales privadas más tarde.',
|
||||
'memories.confirmShareButton': 'Compartir fotos',
|
||||
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'Mensajes',
|
||||
'collab.tabs.notes': 'Notas',
|
||||
|
||||
@@ -5,13 +5,13 @@ const fr: Record<string, string> = {
|
||||
'common.delete': 'Supprimer',
|
||||
'common.edit': 'Modifier',
|
||||
'common.add': 'Ajouter',
|
||||
'common.loading': 'Chargement...',
|
||||
'common.loading': 'Chargement…',
|
||||
'common.error': 'Erreur',
|
||||
'common.back': 'Retour',
|
||||
'common.all': 'Tout',
|
||||
'common.close': 'Fermer',
|
||||
'common.open': 'Ouvrir',
|
||||
'common.upload': 'Téléverser',
|
||||
'common.upload': 'Importer',
|
||||
'common.search': 'Rechercher',
|
||||
'common.confirm': 'Confirmer',
|
||||
'common.ok': 'OK',
|
||||
@@ -24,10 +24,10 @@ const fr: Record<string, string> = {
|
||||
'common.name': 'Nom',
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Mot de passe',
|
||||
'common.saving': 'Enregistrement...',
|
||||
'common.saving': 'Enregistrement…',
|
||||
'common.update': 'Mettre à jour',
|
||||
'common.change': 'Modifier',
|
||||
'common.uploading': 'Téléversement…',
|
||||
'common.uploading': 'Import en cours…',
|
||||
'common.backToPlanning': 'Retour à la planification',
|
||||
'common.reset': 'Réinitialiser',
|
||||
|
||||
@@ -44,7 +44,7 @@ const fr: Record<string, string> = {
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': 'Mes voyages',
|
||||
'dashboard.subtitle.loading': 'Chargement des voyages...',
|
||||
'dashboard.subtitle.loading': 'Chargement des voyages…',
|
||||
'dashboard.subtitle.trips': '{count} voyages ({archived} archivés)',
|
||||
'dashboard.subtitle.empty': 'Commencez votre premier voyage',
|
||||
'dashboard.subtitle.activeOne': '{count} voyage actif',
|
||||
@@ -54,8 +54,8 @@ const fr: Record<string, string> = {
|
||||
'dashboard.gridView': 'Vue en grille',
|
||||
'dashboard.listView': 'Vue en liste',
|
||||
'dashboard.currency': 'Devise',
|
||||
'dashboard.timezone': 'Fuseaux horaires',
|
||||
'dashboard.localTime': 'Local',
|
||||
'dashboard.timezone': 'Fuseau horaire',
|
||||
'dashboard.localTime': 'Heure locale',
|
||||
'dashboard.timezoneCustomTitle': 'Fuseau horaire personnalisé',
|
||||
'dashboard.timezoneCustomLabelPlaceholder': 'Libellé (facultatif)',
|
||||
'dashboard.timezoneCustomTzPlaceholder': 'ex. America/New_York',
|
||||
@@ -105,7 +105,7 @@ const fr: Record<string, string> = {
|
||||
'dashboard.addMembers': 'Compagnons de voyage',
|
||||
'dashboard.addMember': 'Ajouter un membre',
|
||||
'dashboard.coverSaved': 'Image de couverture enregistrée',
|
||||
'dashboard.coverUploadError': 'Échec du téléversement',
|
||||
'dashboard.coverUploadError': 'Échec de l\'import',
|
||||
'dashboard.coverRemoveError': 'Échec de la suppression',
|
||||
'dashboard.titleRequired': 'Le titre est obligatoire',
|
||||
'dashboard.endDateError': 'La date de fin doit être postérieure à la date de début',
|
||||
@@ -115,7 +115,7 @@ const fr: Record<string, string> = {
|
||||
'settings.subtitle': 'Configurez vos paramètres personnels',
|
||||
'settings.map': 'Carte',
|
||||
'settings.mapTemplate': 'Modèle de carte',
|
||||
'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle...',
|
||||
'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle…',
|
||||
'settings.mapDefaultHint': 'Laissez vide pour OpenStreetMap (par défaut)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'Modèle d\'URL pour les tuiles de carte',
|
||||
@@ -127,7 +127,7 @@ const fr: Record<string, string> = {
|
||||
'settings.mapsKeyHint': 'Pour la recherche de lieux. Nécessite l\'API Places (New). Obtenez-la sur console.cloud.google.com',
|
||||
'settings.weatherKey': 'Clé API OpenWeatherMap',
|
||||
'settings.weatherKeyHint': 'Pour les données météo. Gratuit sur openweathermap.org/api',
|
||||
'settings.keyPlaceholder': 'Saisir la clé...',
|
||||
'settings.keyPlaceholder': 'Saisir la clé…',
|
||||
'settings.configured': 'Configuré',
|
||||
'settings.saveKeys': 'Enregistrer les clés',
|
||||
'settings.display': 'Affichage',
|
||||
@@ -139,6 +139,50 @@ const fr: Record<string, string> = {
|
||||
'settings.temperature': 'Unité de température',
|
||||
'settings.timeFormat': 'Format de l\'heure',
|
||||
'settings.routeCalculation': 'Calcul d\'itinéraire',
|
||||
'settings.blurBookingCodes': 'Masquer les codes de réservation',
|
||||
'settings.notifications': 'Notifications',
|
||||
'settings.notifyTripInvite': 'Invitations de voyage',
|
||||
'settings.notifyBookingChange': 'Modifications de réservation',
|
||||
'settings.notifyTripReminder': 'Rappels de voyage',
|
||||
'settings.notifyVacayInvite': 'Invitations de fusion Vacay',
|
||||
'settings.notifyPhotosShared': 'Photos partagées (Immich)',
|
||||
'settings.notifyCollabMessage': 'Messages de chat (Collab)',
|
||||
'settings.notifyPackingTagged': 'Liste de bagages : attributions',
|
||||
'settings.notifyWebhook': 'Notifications webhook',
|
||||
'admin.smtp.title': 'E-mail et notifications',
|
||||
'admin.smtp.hint': 'Configuration SMTP pour les notifications par e-mail. Optionnel : URL webhook pour Discord, Slack, etc.',
|
||||
'admin.smtp.testButton': 'Envoyer un e-mail de test',
|
||||
'admin.smtp.testSuccess': 'E-mail de test envoyé avec succès',
|
||||
'admin.smtp.testFailed': 'Échec de l\'e-mail de test',
|
||||
'dayplan.icsTooltip': 'Exporter le calendrier (ICS)',
|
||||
'share.linkTitle': 'Lien public',
|
||||
'share.linkHint': 'Créez un lien que n\'importe qui peut utiliser pour consulter ce voyage sans se connecter. Lecture seule — aucune modification possible.',
|
||||
'share.createLink': 'Créer un lien',
|
||||
'share.deleteLink': 'Supprimer le lien',
|
||||
'share.createError': 'Impossible de créer le lien',
|
||||
'common.copy': 'Copier',
|
||||
'common.copied': 'Copié',
|
||||
'share.permMap': 'Carte et plan',
|
||||
'share.permBookings': 'Réservations',
|
||||
'share.permPacking': 'Bagages',
|
||||
'shared.expired': 'Lien expiré ou invalide',
|
||||
'shared.expiredHint': 'Ce lien de partage n\'est plus actif.',
|
||||
'shared.readOnly': 'Vue en lecture seule',
|
||||
'shared.tabPlan': 'Plan',
|
||||
'shared.tabBookings': 'Réservations',
|
||||
'shared.tabPacking': 'Bagages',
|
||||
'shared.tabBudget': 'Budget',
|
||||
'shared.tabChat': 'Chat',
|
||||
'shared.days': 'jours',
|
||||
'shared.places': 'lieux',
|
||||
'shared.other': 'Autre',
|
||||
'shared.totalBudget': 'Budget total',
|
||||
'shared.messages': 'messages',
|
||||
'shared.sharedVia': 'Partagé via',
|
||||
'shared.confirmed': 'Confirmé',
|
||||
'shared.pending': 'En attente',
|
||||
'share.permBudget': 'Budget',
|
||||
'share.permCollab': 'Chat',
|
||||
'settings.on': 'Activé',
|
||||
'settings.off': 'Désactivé',
|
||||
'settings.account': 'Compte',
|
||||
@@ -186,15 +230,15 @@ const fr: Record<string, string> = {
|
||||
'settings.toast.keysSaved': 'Clés API enregistrées',
|
||||
'settings.toast.displaySaved': 'Paramètres d\'affichage enregistrés',
|
||||
'settings.toast.profileSaved': 'Profil enregistré',
|
||||
'settings.uploadAvatar': 'Téléverser une photo de profil',
|
||||
'settings.uploadAvatar': 'Importer une photo de profil',
|
||||
'settings.removeAvatar': 'Supprimer la photo de profil',
|
||||
'settings.avatarUploaded': 'Photo de profil mise à jour',
|
||||
'settings.avatarRemoved': 'Photo de profil supprimée',
|
||||
'settings.avatarError': 'Échec du téléversement',
|
||||
'settings.avatarError': 'Échec de l\'import',
|
||||
|
||||
// Login
|
||||
'login.error': 'Échec de la connexion. Veuillez vérifier vos identifiants.',
|
||||
'login.tagline': 'Vos voyages.\nVotre plan.',
|
||||
'login.tagline': 'Vos voyages.\nVotre organisation.',
|
||||
'login.description': 'Planifiez vos voyages en collaboration avec des cartes interactives, des budgets et la synchronisation en temps réel.',
|
||||
'login.features.maps': 'Cartes interactives',
|
||||
'login.features.mapsDesc': 'Google Places, itinéraires et regroupement',
|
||||
@@ -209,7 +253,7 @@ const fr: Record<string, string> = {
|
||||
'login.features.bookings': 'Réservations',
|
||||
'login.features.bookingsDesc': 'Vols, hôtels, restaurants et plus',
|
||||
'login.features.files': 'Documents',
|
||||
'login.features.filesDesc': 'Téléversez et gérez vos documents',
|
||||
'login.features.filesDesc': 'Importez et gérez vos documents',
|
||||
'login.features.routes': 'Itinéraires intelligents',
|
||||
'login.features.routesDesc': 'Optimisation automatique et export Google Maps',
|
||||
'login.selfHosted': 'Auto-hébergé · Open Source · Vos données restent les vôtres',
|
||||
@@ -260,7 +304,7 @@ const fr: Record<string, string> = {
|
||||
'register.minChars': 'Min. 6 caractères',
|
||||
'register.confirmPassword': 'Confirmer le mot de passe',
|
||||
'register.repeatPassword': 'Répéter le mot de passe',
|
||||
'register.registering': 'Inscription en cours...',
|
||||
'register.registering': 'Inscription en cours…',
|
||||
'register.register': 'S\'inscrire',
|
||||
'register.hasAccount': 'Vous avez déjà un compte ?',
|
||||
'register.signIn': 'Se connecter',
|
||||
@@ -343,7 +387,7 @@ const fr: Record<string, string> = {
|
||||
|
||||
// File Types
|
||||
'admin.fileTypes': 'Types de fichiers autorisés',
|
||||
'admin.fileTypesHint': 'Configurez les types de fichiers que les utilisateurs peuvent téléverser.',
|
||||
'admin.fileTypesHint': 'Configurez les types de fichiers que les utilisateurs peuvent importer.',
|
||||
'admin.fileTypesFormat': 'Extensions séparées par des virgules (ex. jpg,png,pdf,doc). Utilisez * pour autoriser tous les types.',
|
||||
'admin.fileTypesSaved': 'Paramètres des types de fichiers enregistrés',
|
||||
|
||||
@@ -381,11 +425,11 @@ const fr: Record<string, string> = {
|
||||
'admin.addons.catalog.budget.description': 'Suivez les dépenses et planifiez votre budget de voyage',
|
||||
'admin.addons.catalog.documents.name': 'Documents',
|
||||
'admin.addons.catalog.documents.description': 'Stockez et gérez vos documents de voyage',
|
||||
'admin.addons.catalog.vacay.name': 'Vacay',
|
||||
'admin.addons.catalog.vacay.name': 'Vacances',
|
||||
'admin.addons.catalog.vacay.description': 'Planificateur de vacances personnel avec vue calendrier',
|
||||
'admin.addons.catalog.atlas.name': 'Atlas',
|
||||
'admin.addons.catalog.atlas.description': 'Carte du monde avec pays visités et statistiques de voyage',
|
||||
'admin.addons.catalog.collab.name': 'Collab',
|
||||
'admin.addons.catalog.collab.name': 'Collaboration',
|
||||
'admin.addons.catalog.collab.description': 'Notes en temps réel, sondages et chat pour la planification de voyage',
|
||||
'admin.addons.subtitleBefore': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience ',
|
||||
'admin.addons.subtitleAfter': '.',
|
||||
@@ -408,7 +452,21 @@ const fr: Record<string, string> = {
|
||||
'admin.weather.climateDesc': 'Moyennes des 85 dernières années pour les jours au-delà des prévisions de 16 jours',
|
||||
'admin.weather.requests': '10 000 requêtes / jour',
|
||||
'admin.weather.requestsDesc': 'Gratuit, aucune clé API requise',
|
||||
'admin.weather.locationHint': 'La météo est basée sur le premier lieu avec des coordonnées de chaque jour. Si aucun lieu n\'est assigné à un jour, un lieu de la liste est utilisé comme référence.',
|
||||
'admin.weather.locationHint': 'La météo est basée sur le premier lieu avec des coordonnées de chaque jour. Si aucun lieu n\'est attribué à un jour, un lieu de la liste est utilisé comme référence.',
|
||||
|
||||
'admin.tabs.audit': 'Journal d\'audit',
|
||||
|
||||
'admin.audit.subtitle': 'Événements sensibles de sécurité et d\'administration (sauvegardes, utilisateurs, 2FA, paramètres).',
|
||||
'admin.audit.empty': 'Aucune entrée d\'audit.',
|
||||
'admin.audit.refresh': 'Actualiser',
|
||||
'admin.audit.loadMore': 'Charger plus',
|
||||
'admin.audit.showing': '{count} chargées · {total} au total',
|
||||
'admin.audit.col.time': 'Heure',
|
||||
'admin.audit.col.user': 'Utilisateur',
|
||||
'admin.audit.col.action': 'Action',
|
||||
'admin.audit.col.resource': 'Ressource',
|
||||
'admin.audit.col.ip': 'IP',
|
||||
'admin.audit.col.details': 'Détails',
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
@@ -419,8 +477,8 @@ const fr: Record<string, string> = {
|
||||
'admin.github.showDetails': 'Afficher les détails',
|
||||
'admin.github.hideDetails': 'Masquer les détails',
|
||||
'admin.github.loadMore': 'Charger plus',
|
||||
'admin.github.loading': 'Chargement...',
|
||||
'admin.github.support': 'Aide à continuer le développement de TREK',
|
||||
'admin.github.loading': 'Chargement…',
|
||||
'admin.github.support': 'Aidez à poursuivre le développement de TREK',
|
||||
'admin.github.error': 'Impossible de charger les versions',
|
||||
'admin.github.by': 'par',
|
||||
|
||||
@@ -430,7 +488,7 @@ const fr: Record<string, string> = {
|
||||
'admin.update.install': 'Installer la mise à jour',
|
||||
'admin.update.confirmTitle': 'Installer la mise à jour ?',
|
||||
'admin.update.confirmText': 'TREK sera mis à jour de {current} vers {version}. Le serveur redémarrera automatiquement ensuite.',
|
||||
'admin.update.dataInfo': 'Toutes vos données (voyages, utilisateurs, clés API, téléversements, Vacay, Atlas, budgets) seront préservées.',
|
||||
'admin.update.dataInfo': 'Toutes vos données (voyages, utilisateurs, clés API, importations, Vacances, Atlas, budgets) seront préservées.',
|
||||
'admin.update.warning': 'L\'application sera brièvement indisponible pendant le redémarrage.',
|
||||
'admin.update.confirm': 'Mettre à jour maintenant',
|
||||
'admin.update.installing': 'Mise à jour…',
|
||||
@@ -443,7 +501,7 @@ const fr: Record<string, string> = {
|
||||
'admin.update.reloadHint': 'Veuillez recharger la page dans quelques secondes.',
|
||||
|
||||
// Vacay addon
|
||||
'vacay.subtitle': 'Planifiez et gérez vos jours de congé',
|
||||
'vacay.subtitle': 'Planifiez et gérez vos jours de congés',
|
||||
'vacay.settings': 'Paramètres',
|
||||
'vacay.year': 'Année',
|
||||
'vacay.addYear': 'Ajouter une année',
|
||||
@@ -473,6 +531,14 @@ const fr: Record<string, string> = {
|
||||
'vacay.used': 'Utilisés',
|
||||
'vacay.remaining': 'Restants',
|
||||
'vacay.carriedOver': 'de {year}',
|
||||
'vacay.weekendDays': 'Jours de week-end',
|
||||
'vacay.mon': 'Lun',
|
||||
'vacay.tue': 'Mar',
|
||||
'vacay.wed': 'Mer',
|
||||
'vacay.thu': 'Jeu',
|
||||
'vacay.fri': 'Ven',
|
||||
'vacay.sat': 'Sam',
|
||||
'vacay.sun': 'Dim',
|
||||
'vacay.blockWeekends': 'Bloquer les week-ends',
|
||||
'vacay.blockWeekendsHint': 'Empêcher les entrées de vacances les samedis et dimanches',
|
||||
'vacay.publicHolidays': 'Jours fériés',
|
||||
@@ -490,11 +556,11 @@ const fr: Record<string, string> = {
|
||||
'vacay.shareEmailPlaceholder': 'E-mail de l\'utilisateur TREK',
|
||||
'vacay.shareSuccess': 'Plan partagé avec succès',
|
||||
'vacay.shareError': 'Impossible de partager le plan',
|
||||
'vacay.dissolve': 'Dissoudre la fusion',
|
||||
'vacay.dissolve': 'Séparer les calendriers',
|
||||
'vacay.dissolveHint': 'Séparer à nouveau les calendriers. Vos entrées seront conservées.',
|
||||
'vacay.dissolveAction': 'Dissoudre',
|
||||
'vacay.dissolved': 'Calendrier séparé',
|
||||
'vacay.fusedWith': 'Fusionné avec',
|
||||
'vacay.fusedWith': 'Partagé avec',
|
||||
'vacay.you': 'vous',
|
||||
'vacay.noData': 'Aucune donnée',
|
||||
'vacay.changeColor': 'Changer la couleur',
|
||||
@@ -569,6 +635,10 @@ const fr: Record<string, string> = {
|
||||
'atlas.markVisited': 'Marquer comme visité',
|
||||
'atlas.markVisitedHint': 'Ajouter ce pays à votre liste de visités',
|
||||
'atlas.addToBucket': 'Ajouter à la bucket list',
|
||||
'atlas.addPoi': 'Ajouter un lieu',
|
||||
'atlas.bucketNamePlaceholder': 'Nom (pays, ville, lieu…)',
|
||||
'atlas.month': 'Mois',
|
||||
'atlas.year': 'Année',
|
||||
'atlas.addToBucketHint': 'Sauvegarder comme lieu à visiter',
|
||||
'atlas.bucketWhen': 'Quand prévoyez-vous d\'y aller ?',
|
||||
|
||||
@@ -580,14 +650,14 @@ const fr: Record<string, string> = {
|
||||
'trip.tabs.packingShort': 'Bagages',
|
||||
'trip.tabs.budget': 'Budget',
|
||||
'trip.tabs.files': 'Fichiers',
|
||||
'trip.loading': 'Chargement du voyage...',
|
||||
'trip.loading': 'Chargement du voyage…',
|
||||
'trip.mobilePlan': 'Plan',
|
||||
'trip.mobilePlaces': 'Lieux',
|
||||
'trip.toast.placeUpdated': 'Lieu mis à jour',
|
||||
'trip.toast.placeAdded': 'Lieu ajouté',
|
||||
'trip.toast.placeDeleted': 'Lieu supprimé',
|
||||
'trip.toast.selectDay': 'Veuillez d\'abord sélectionner un jour',
|
||||
'trip.toast.assignedToDay': 'Lieu assigné au jour',
|
||||
'trip.toast.assignedToDay': 'Lieu attribué au planning',
|
||||
'trip.toast.reorderError': 'Échec de la réorganisation',
|
||||
'trip.toast.reservationUpdated': 'Réservation mise à jour',
|
||||
'trip.toast.reservationAdded': 'Réservation ajoutée',
|
||||
@@ -605,7 +675,7 @@ const fr: Record<string, string> = {
|
||||
'dayplan.totalCost': 'Coût total',
|
||||
'dayplan.days': 'Jours',
|
||||
'dayplan.dayN': 'Jour {n}',
|
||||
'dayplan.calculating': 'Calcul en cours...',
|
||||
'dayplan.calculating': 'Calcul en cours…',
|
||||
'dayplan.route': 'Itinéraire',
|
||||
'dayplan.optimize': 'Optimiser',
|
||||
'dayplan.optimized': 'Itinéraire optimisé',
|
||||
@@ -618,14 +688,26 @@ const fr: Record<string, string> = {
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Exporter le plan du jour en PDF',
|
||||
'dayplan.pdfError': 'Échec de l\'export PDF',
|
||||
'dayplan.cannotReorderTransport': 'Les réservations avec une heure fixe ne peuvent pas être réorganisées',
|
||||
'dayplan.confirmRemoveTimeTitle': 'Supprimer l\'heure ?',
|
||||
'dayplan.confirmRemoveTimeBody': 'Ce lieu a une heure fixe ({time}). Le déplacer supprimera l\'heure et permettra un tri libre.',
|
||||
'dayplan.confirmRemoveTimeAction': 'Supprimer l\'heure et déplacer',
|
||||
'dayplan.cannotDropOnTimed': 'Les éléments ne peuvent pas être placés entre des entrées à heure fixe',
|
||||
'dayplan.cannotBreakChronology': 'Cela briserait l\'ordre chronologique des éléments et réservations planifiés',
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Ajouter un lieu/activité',
|
||||
'places.importGpx': 'Importer GPX',
|
||||
'places.gpxImported': '{count} lieux importés depuis GPX',
|
||||
'places.gpxError': 'L\'import GPX a échoué',
|
||||
'places.urlResolved': 'Lieu importé depuis l\'URL',
|
||||
'places.assignToDay': 'Ajouter à quel jour ?',
|
||||
'places.all': 'Tous',
|
||||
'places.unplanned': 'Non planifiés',
|
||||
'places.search': 'Rechercher des lieux...',
|
||||
'places.search': 'Rechercher des lieux…',
|
||||
'places.allCategories': 'Toutes les catégories',
|
||||
'places.categoriesSelected': 'catégories',
|
||||
'places.clearFilter': 'Effacer le filtre',
|
||||
'places.count': '{count} lieux',
|
||||
'places.countSingular': '1 lieu',
|
||||
'places.allPlanned': 'Tous les lieux sont planifiés',
|
||||
@@ -634,7 +716,7 @@ const fr: Record<string, string> = {
|
||||
'places.formName': 'Nom',
|
||||
'places.formNamePlaceholder': 'ex. Tour Eiffel',
|
||||
'places.formDescription': 'Description',
|
||||
'places.formDescriptionPlaceholder': 'Brève description...',
|
||||
'places.formDescriptionPlaceholder': 'Brève description…',
|
||||
'places.formAddress': 'Adresse',
|
||||
'places.formAddressPlaceholder': 'Rue, ville, pays',
|
||||
'places.formLat': 'Latitude (ex. 48.8566)',
|
||||
@@ -648,10 +730,10 @@ const fr: Record<string, string> = {
|
||||
'places.endTimeBeforeStart': 'L\'heure de fin est antérieure à l\'heure de début',
|
||||
'places.timeCollision': 'Chevauchement horaire avec :',
|
||||
'places.formWebsite': 'Site web',
|
||||
'places.formNotesPlaceholder': 'Notes personnelles...',
|
||||
'places.formNotesPlaceholder': 'Notes personnelles…',
|
||||
'places.formReservation': 'Réservation',
|
||||
'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation...',
|
||||
'places.mapsSearchPlaceholder': 'Rechercher des lieux...',
|
||||
'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation…',
|
||||
'places.mapsSearchPlaceholder': 'Rechercher des lieux…',
|
||||
'places.mapsSearchError': 'La recherche de lieu a échoué.',
|
||||
'places.osmHint': 'Recherche via OpenStreetMap (pas de photos, horaires ni notes). Ajoutez une clé API Google dans les paramètres pour plus de détails.',
|
||||
'places.osmActive': 'Recherche via OpenStreetMap (pas de photos, notes ni horaires). Ajoutez une clé API Google dans les paramètres pour des données enrichies.',
|
||||
@@ -696,7 +778,7 @@ const fr: Record<string, string> = {
|
||||
'reservations.time': 'Heure',
|
||||
'reservations.timeAlt': 'Heure (alternative, ex. 19h30)',
|
||||
'reservations.notes': 'Notes',
|
||||
'reservations.notesPlaceholder': 'Notes supplémentaires...',
|
||||
'reservations.notesPlaceholder': 'Notes supplémentaires…',
|
||||
'reservations.meta.airline': 'Compagnie aérienne',
|
||||
'reservations.meta.flightNumber': 'N° de vol',
|
||||
'reservations.meta.from': 'De',
|
||||
@@ -724,16 +806,18 @@ const fr: Record<string, string> = {
|
||||
'reservations.type.tour': 'Visite',
|
||||
'reservations.type.other': 'Autre',
|
||||
'reservations.confirm.delete': 'Voulez-vous vraiment supprimer la réservation « {name} » ?',
|
||||
'reservations.confirm.deleteTitle': 'Supprimer la réservation ?',
|
||||
'reservations.confirm.deleteBody': '« {name} » sera définitivement supprimé.',
|
||||
'reservations.toast.updated': 'Réservation mise à jour',
|
||||
'reservations.toast.removed': 'Réservation supprimée',
|
||||
'reservations.toast.fileUploaded': 'Fichier téléversé',
|
||||
'reservations.toast.uploadError': 'Échec du téléversement',
|
||||
'reservations.toast.fileUploaded': 'Fichier importé',
|
||||
'reservations.toast.uploadError': 'Échec de l\'import',
|
||||
'reservations.newTitle': 'Nouvelle réservation',
|
||||
'reservations.bookingType': 'Type de réservation',
|
||||
'reservations.titleLabel': 'Titre',
|
||||
'reservations.titlePlaceholder': 'ex. Lufthansa LH123, Hôtel Adlon, ...',
|
||||
'reservations.titlePlaceholder': 'ex. Lufthansa LH123, Hôtel Adlon, …',
|
||||
'reservations.locationAddress': 'Lieu / Adresse',
|
||||
'reservations.locationPlaceholder': 'Adresse, aéroport, hôtel...',
|
||||
'reservations.locationPlaceholder': 'Adresse, aéroport, hôtel…',
|
||||
'reservations.confirmationCode': 'Code de réservation',
|
||||
'reservations.confirmationPlaceholder': 'ex. ABC12345',
|
||||
'reservations.day': 'Jour',
|
||||
@@ -741,21 +825,22 @@ const fr: Record<string, string> = {
|
||||
'reservations.place': 'Lieu',
|
||||
'reservations.noPlace': 'Aucun lieu',
|
||||
'reservations.pendingSave': 'sera enregistré…',
|
||||
'reservations.uploading': 'Téléversement...',
|
||||
'reservations.uploading': 'Importation…',
|
||||
'reservations.attachFile': 'Joindre un fichier',
|
||||
'reservations.linkExisting': 'Lier un fichier existant',
|
||||
'reservations.toast.saveError': 'Échec de l\'enregistrement',
|
||||
'reservations.toast.updateError': 'Échec de la mise à jour',
|
||||
'reservations.toast.deleteError': 'Échec de la suppression',
|
||||
'reservations.confirm.remove': 'Supprimer la réservation pour « {name} » ?',
|
||||
'reservations.linkAssignment': 'Lier à l\'assignation du jour',
|
||||
'reservations.pickAssignment': 'Sélectionnez une assignation de votre plan...',
|
||||
'reservations.linkAssignment': 'Lier à l\'affectation du jour',
|
||||
'reservations.pickAssignment': 'Sélectionnez une affectation de votre plan…',
|
||||
'reservations.noAssignment': 'Aucun lien (autonome)',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
'budget.emptyTitle': 'Aucun budget créé',
|
||||
'budget.emptyText': 'Créez des catégories et des entrées pour planifier votre budget de voyage',
|
||||
'budget.emptyPlaceholder': 'Nom de la catégorie...',
|
||||
'budget.emptyPlaceholder': 'Nom de la catégorie…',
|
||||
'budget.createCategory': 'Créer une catégorie',
|
||||
'budget.category': 'Catégorie',
|
||||
'budget.categoryName': 'Nom de la catégorie',
|
||||
@@ -773,24 +858,27 @@ const fr: Record<string, string> = {
|
||||
'budget.total': 'Total',
|
||||
'budget.totalBudget': 'Budget total',
|
||||
'budget.byCategory': 'Par catégorie',
|
||||
'budget.editTooltip': 'Cliquer pour modifier',
|
||||
'budget.editTooltip': 'Cliquez pour modifier',
|
||||
'budget.confirm.deleteCategory': 'Voulez-vous vraiment supprimer la catégorie « {name} » avec {count} entrées ?',
|
||||
'budget.deleteCategory': 'Supprimer la catégorie',
|
||||
'budget.perPerson': 'Par personne',
|
||||
'budget.paid': 'Payé',
|
||||
'budget.open': 'Ouvert',
|
||||
'budget.noMembers': 'Aucun membre assigné',
|
||||
'budget.settlement': 'Règlement',
|
||||
'budget.settlementInfo': 'Cliquez sur l\'avatar d\'un membre sur un poste budgétaire pour le marquer en vert — cela signifie qu\'il a payé. Le règlement indique ensuite qui doit combien à qui.',
|
||||
'budget.netBalances': 'Soldes nets',
|
||||
|
||||
// Files
|
||||
'files.title': 'Fichiers',
|
||||
'files.count': '{count} fichiers',
|
||||
'files.countSingular': '1 fichier',
|
||||
'files.uploaded': '{count} téléversés',
|
||||
'files.uploadError': 'Échec du téléversement',
|
||||
'files.uploaded': '{count} importés',
|
||||
'files.uploadError': 'Échec de l\'import',
|
||||
'files.dropzone': 'Déposez les fichiers ici',
|
||||
'files.dropzoneHint': 'ou cliquez pour parcourir',
|
||||
'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 Mo',
|
||||
'files.uploading': 'Téléversement...',
|
||||
'files.uploading': 'Importation…',
|
||||
'files.filterAll': 'Tous',
|
||||
'files.filterPdf': 'PDF',
|
||||
'files.filterImages': 'Images',
|
||||
@@ -798,7 +886,7 @@ const fr: Record<string, string> = {
|
||||
'files.filterCollab': 'Notes Collab',
|
||||
'files.sourceCollab': 'Depuis les notes Collab',
|
||||
'files.empty': 'Aucun fichier',
|
||||
'files.emptyHint': 'Téléversez des fichiers pour les joindre à votre voyage',
|
||||
'files.emptyHint': 'Importez des fichiers pour les joindre à votre voyage',
|
||||
'files.openTab': 'Ouvrir dans un nouvel onglet',
|
||||
'files.confirm.delete': 'Voulez-vous vraiment supprimer ce fichier ?',
|
||||
'files.toast.deleted': 'Fichier supprimé',
|
||||
@@ -817,22 +905,31 @@ const fr: Record<string, string> = {
|
||||
'files.assignTitle': 'Assigner le fichier',
|
||||
'files.assignPlace': 'Lieu',
|
||||
'files.assignBooking': 'Réservation',
|
||||
'files.unassigned': 'Non assigné',
|
||||
'files.unassigned': 'Non attribué',
|
||||
'files.unlink': 'Supprimer le lien',
|
||||
'files.toast.trashed': 'Déplacé dans la corbeille',
|
||||
'files.toast.restored': 'Fichier restauré',
|
||||
'files.toast.trashEmptied': 'Corbeille vidée',
|
||||
'files.toast.assigned': 'Fichier assigné',
|
||||
'files.toast.assigned': 'Fichier attribué',
|
||||
'files.toast.assignError': 'Échec de l\'assignation',
|
||||
'files.toast.restoreError': 'Échec de la restauration',
|
||||
'files.confirm.permanentDelete': 'Supprimer définitivement ce fichier ? Cette action est irréversible.',
|
||||
'files.confirm.emptyTrash': 'Supprimer définitivement tous les fichiers de la corbeille ? Cette action est irréversible.',
|
||||
'files.noteLabel': 'Note',
|
||||
'files.notePlaceholder': 'Ajouter une note...',
|
||||
'files.notePlaceholder': 'Ajouter une note…',
|
||||
|
||||
// Packing
|
||||
'packing.title': 'Liste de bagages',
|
||||
'packing.empty': 'La liste de bagages est vide',
|
||||
'packing.import': 'Importer',
|
||||
'packing.importTitle': 'Importer la liste',
|
||||
'packing.importHint': 'Un élément par ligne. Catégorie et quantité optionnelles séparées par virgule, point-virgule ou tabulation : Nom, Catégorie, Quantité',
|
||||
'packing.importPlaceholder': 'Brosse à dents\nCrème solaire, Hygiène\nT-Shirts, Vêtements, 5\nPasseport, Documents',
|
||||
'packing.importCsv': 'Charger CSV/TXT',
|
||||
'packing.importAction': 'Importer {count}',
|
||||
'packing.importSuccess': '{count} éléments importés',
|
||||
'packing.importError': 'Échec de l\'import',
|
||||
'packing.importEmpty': 'Aucun élément à importer',
|
||||
'packing.progress': '{packed} sur {total} emballés ({percent} %)',
|
||||
'packing.clearChecked': 'Supprimer {count} cochés',
|
||||
'packing.clearCheckedShort': 'Supprimer {count}',
|
||||
@@ -840,8 +937,8 @@ const fr: Record<string, string> = {
|
||||
'packing.suggestionsTitle': 'Ajouter des suggestions',
|
||||
'packing.allSuggested': 'Toutes les suggestions ajoutées',
|
||||
'packing.allPacked': 'Tout est emballé !',
|
||||
'packing.addPlaceholder': 'Ajouter un nouvel article...',
|
||||
'packing.categoryPlaceholder': 'Catégorie...',
|
||||
'packing.addPlaceholder': 'Ajouter un nouvel article…',
|
||||
'packing.categoryPlaceholder': 'Catégorie…',
|
||||
'packing.filterAll': 'Tous',
|
||||
'packing.filterOpen': 'À faire',
|
||||
'packing.filterDone': 'Fait',
|
||||
@@ -955,10 +1052,10 @@ const fr: Record<string, string> = {
|
||||
|
||||
// Backup (Admin)
|
||||
'backup.title': 'Sauvegarde des données',
|
||||
'backup.subtitle': 'Base de données et tous les fichiers téléversés',
|
||||
'backup.subtitle': 'Base de données et tous les fichiers importés',
|
||||
'backup.refresh': 'Actualiser',
|
||||
'backup.upload': 'Téléverser une sauvegarde',
|
||||
'backup.uploading': 'Téléversement…',
|
||||
'backup.upload': 'Importer une sauvegarde',
|
||||
'backup.uploading': 'Importation…',
|
||||
'backup.create': 'Créer une sauvegarde',
|
||||
'backup.creating': 'Création…',
|
||||
'backup.empty': 'Aucune sauvegarde',
|
||||
@@ -966,14 +1063,14 @@ const fr: Record<string, string> = {
|
||||
'backup.download': 'Télécharger',
|
||||
'backup.restore': 'Restaurer',
|
||||
'backup.confirm.restore': 'Restaurer la sauvegarde « {name} » ?\n\nToutes les données actuelles seront remplacées par la sauvegarde.',
|
||||
'backup.confirm.uploadRestore': 'Téléverser et restaurer le fichier de sauvegarde « {name} » ?\n\nToutes les données actuelles seront écrasées.',
|
||||
'backup.confirm.uploadRestore': 'Importer et restaurer le fichier de sauvegarde « {name} » ?\n\nToutes les données actuelles seront écrasées.',
|
||||
'backup.confirm.delete': 'Supprimer la sauvegarde « {name} » ?',
|
||||
'backup.toast.loadError': 'Impossible de charger les sauvegardes',
|
||||
'backup.toast.created': 'Sauvegarde créée avec succès',
|
||||
'backup.toast.createError': 'Impossible de créer la sauvegarde',
|
||||
'backup.toast.restored': 'Sauvegarde restaurée. La page va se recharger…',
|
||||
'backup.toast.restoreError': 'Échec de la restauration',
|
||||
'backup.toast.uploadError': 'Échec du téléversement',
|
||||
'backup.toast.uploadError': 'Échec de l\'import',
|
||||
'backup.toast.deleted': 'Sauvegarde supprimée',
|
||||
'backup.toast.deleteError': 'Échec de la suppression',
|
||||
'backup.toast.downloadError': 'Échec du téléchargement',
|
||||
@@ -984,7 +1081,27 @@ const fr: Record<string, string> = {
|
||||
'backup.auto.enable': 'Activer la sauvegarde automatique',
|
||||
'backup.auto.enableHint': 'Les sauvegardes seront créées automatiquement selon le calendrier choisi',
|
||||
'backup.auto.interval': 'Intervalle',
|
||||
'backup.auto.hour': 'Exécuter à l\'heure',
|
||||
'backup.auto.hourHint': 'Heure locale du serveur (format {format})',
|
||||
'backup.auto.dayOfWeek': 'Jour de la semaine',
|
||||
'backup.auto.dayOfMonth': 'Jour du mois',
|
||||
'backup.auto.dayOfMonthHint': 'Limité à 1–28 pour la compatibilité avec tous les mois',
|
||||
'backup.auto.scheduleSummary': 'Planification',
|
||||
'backup.auto.summaryDaily': 'Tous les jours à {hour}h00',
|
||||
'backup.auto.summaryWeekly': 'Chaque {day} à {hour}h00',
|
||||
'backup.auto.summaryMonthly': 'Le {day} de chaque mois à {hour}h00',
|
||||
'backup.auto.envLocked': 'Docker',
|
||||
'backup.auto.envLockedHint': 'La sauvegarde automatique est configurée via les variables d\'environnement Docker. Pour modifier ces paramètres, mettez à jour votre docker-compose.yml et redémarrez le conteneur.',
|
||||
'backup.auto.copyEnv': 'Copier les variables d\'env Docker',
|
||||
'backup.auto.envCopied': 'Variables d\'env Docker copiées dans le presse-papiers',
|
||||
'backup.auto.keepLabel': 'Supprimer les anciennes sauvegardes après',
|
||||
'backup.dow.sunday': 'Dim',
|
||||
'backup.dow.monday': 'Lun',
|
||||
'backup.dow.tuesday': 'Mar',
|
||||
'backup.dow.wednesday': 'Mer',
|
||||
'backup.dow.thursday': 'Jeu',
|
||||
'backup.dow.friday': 'Ven',
|
||||
'backup.dow.saturday': 'Sam',
|
||||
'backup.interval.hourly': 'Toutes les heures',
|
||||
'backup.interval.daily': 'Quotidien',
|
||||
'backup.interval.weekly': 'Hebdomadaire',
|
||||
@@ -999,15 +1116,15 @@ const fr: Record<string, string> = {
|
||||
// Photos
|
||||
'photos.allDays': 'Tous les jours',
|
||||
'photos.noPhotos': 'Aucune photo',
|
||||
'photos.uploadHint': 'Téléversez vos photos de voyage',
|
||||
'photos.uploadHint': 'Importez vos photos de voyage',
|
||||
'photos.clickToSelect': 'ou cliquez pour sélectionner',
|
||||
'photos.linkPlace': 'Lier au lieu',
|
||||
'photos.noPlace': 'Aucun lieu',
|
||||
'photos.uploadN': '{n} photo(s) téléversées',
|
||||
'photos.uploadN': '{n} photo(s) importée(s)',
|
||||
|
||||
// Backup restore modal
|
||||
'backup.restoreConfirmTitle': 'Restaurer la sauvegarde ?',
|
||||
'backup.restoreWarning': 'Toutes les données actuelles (voyages, lieux, utilisateurs, téléversements) seront définitivement remplacées par la sauvegarde. Cette action est irréversible.',
|
||||
'backup.restoreWarning': 'Toutes les données actuelles (voyages, lieux, utilisateurs, importations) seront définitivement remplacées par la sauvegarde. Cette action est irréversible.',
|
||||
'backup.restoreTip': 'Conseil : créez une sauvegarde de l\'état actuel avant de restaurer.',
|
||||
'backup.restoreConfirm': 'Oui, restaurer',
|
||||
|
||||
@@ -1044,8 +1161,8 @@ const fr: Record<string, string> = {
|
||||
'planner.placeN': '{n} lieux',
|
||||
'planner.addNote': 'Ajouter une note',
|
||||
'planner.noEntries': 'Aucune entrée pour ce jour',
|
||||
'planner.addPlace': 'Ajouter un lieu/activité',
|
||||
'planner.addPlaceShort': '+ Ajouter un lieu/activité',
|
||||
'planner.addPlace': 'Ajouter un lieu ou une activité',
|
||||
'planner.addPlaceShort': '+ Ajouter un lieu ou une activité',
|
||||
'planner.resPending': 'Réservation en attente · ',
|
||||
'planner.resConfirmed': 'Réservation confirmée · ',
|
||||
'planner.notePlaceholder': 'Note…',
|
||||
@@ -1075,7 +1192,7 @@ const fr: Record<string, string> = {
|
||||
'planner.noDays': 'Aucun jour',
|
||||
'planner.editTripToAddDays': 'Modifiez le voyage pour ajouter des jours',
|
||||
'planner.dayCount': '{n} jours',
|
||||
'planner.clickToUnlock': 'Cliquer pour déverrouiller',
|
||||
'planner.clickToUnlock': 'Cliquez pour déverrouiller',
|
||||
'planner.keepPosition': 'Maintenir la position lors de l\'optimisation de l\'itinéraire',
|
||||
'planner.dayDetails': 'Détails du jour',
|
||||
'planner.dayN': 'Jour {n}',
|
||||
@@ -1111,8 +1228,47 @@ const fr: Record<string, string> = {
|
||||
'day.editAccommodation': 'Modifier l\'hébergement',
|
||||
'day.reservations': 'Réservations',
|
||||
|
||||
// Memories / Immich
|
||||
'memories.title': 'Photos',
|
||||
'memories.notConnected': 'Immich non connecté',
|
||||
'memories.notConnectedHint': 'Connectez votre instance Immich dans les paramètres pour voir vos photos de voyage ici.',
|
||||
'memories.noDates': 'Ajoutez des dates à votre voyage pour charger les photos.',
|
||||
'memories.noPhotos': 'Aucune photo trouvée',
|
||||
'memories.noPhotosHint': 'Aucune photo trouvée dans Immich pour la période de ce voyage.',
|
||||
'memories.photosFound': 'photos',
|
||||
'memories.fromOthers': 'd\'autres',
|
||||
'memories.sharePhotos': 'Partager les photos',
|
||||
'memories.sharing': 'Partagé',
|
||||
'memories.reviewTitle': 'Vérifier vos photos',
|
||||
'memories.reviewHint': 'Cliquez sur les photos pour les exclure du partage.',
|
||||
'memories.shareCount': 'Partager {count} photos',
|
||||
'memories.immichUrl': 'URL du serveur Immich',
|
||||
'memories.immichApiKey': 'Clé API',
|
||||
'memories.testConnection': 'Tester la connexion',
|
||||
'memories.connected': 'Connecté',
|
||||
'memories.disconnected': 'Non connecté',
|
||||
'memories.connectionSuccess': 'Connecté à Immich',
|
||||
'memories.connectionError': 'Impossible de se connecter à Immich',
|
||||
'memories.saved': 'Paramètres Immich enregistrés',
|
||||
'memories.oldest': 'Plus anciennes',
|
||||
'memories.newest': 'Plus récentes',
|
||||
'memories.allLocations': 'Tous les lieux',
|
||||
'memories.addPhotos': 'Ajouter des photos',
|
||||
'memories.selectPhotos': 'Sélectionner des photos depuis Immich',
|
||||
'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.',
|
||||
'memories.selected': 'sélectionné(s)',
|
||||
'memories.addSelected': 'Ajouter {count} photos',
|
||||
'memories.alreadyAdded': 'Ajouté',
|
||||
'memories.private': 'Privé',
|
||||
'memories.stopSharing': 'Arrêter le partage',
|
||||
'memories.tripDates': 'Dates du voyage',
|
||||
'memories.allPhotos': 'Toutes les photos',
|
||||
'memories.confirmShareTitle': 'Partager avec les membres du voyage ?',
|
||||
'memories.confirmShareHint': '{count} photos seront visibles par tous les membres de ce voyage. Vous pourrez rendre des photos individuelles privées plus tard.',
|
||||
'memories.confirmShareButton': 'Partager les photos',
|
||||
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'Chat',
|
||||
'collab.tabs.chat': 'Discussion',
|
||||
'collab.tabs.notes': 'Notes',
|
||||
'collab.tabs.polls': 'Sondages',
|
||||
'collab.whatsNext.title': 'À venir',
|
||||
@@ -1122,7 +1278,7 @@ const fr: Record<string, string> = {
|
||||
'collab.whatsNext.until': 'à',
|
||||
'collab.whatsNext.emptyHint': 'Les activités avec des horaires apparaîtront ici',
|
||||
'collab.chat.send': 'Envoyer',
|
||||
'collab.chat.placeholder': 'Écrire un message...',
|
||||
'collab.chat.placeholder': 'Écrire un message…',
|
||||
'collab.chat.empty': 'Commencez la conversation',
|
||||
'collab.chat.emptyHint': 'Les messages sont partagés avec tous les membres du voyage',
|
||||
'collab.chat.emptyDesc': 'Partagez des idées, des plans et des mises à jour avec votre groupe de voyage',
|
||||
@@ -1139,9 +1295,9 @@ const fr: Record<string, string> = {
|
||||
'collab.notes.emptyHint': 'Commencez à capturer vos idées et plans',
|
||||
'collab.notes.all': 'Toutes',
|
||||
'collab.notes.titlePlaceholder': 'Titre de la note',
|
||||
'collab.notes.contentPlaceholder': 'Écrivez quelque chose...',
|
||||
'collab.notes.contentPlaceholder': 'Écrivez quelque chose…',
|
||||
'collab.notes.categoryPlaceholder': 'Catégorie',
|
||||
'collab.notes.newCategory': 'Nouvelle catégorie...',
|
||||
'collab.notes.newCategory': 'Nouvelle catégorie…',
|
||||
'collab.notes.category': 'Catégorie',
|
||||
'collab.notes.noCategory': 'Sans catégorie',
|
||||
'collab.notes.color': 'Couleur',
|
||||
@@ -1155,7 +1311,7 @@ const fr: Record<string, string> = {
|
||||
'collab.notes.categorySettings': 'Gérer les catégories',
|
||||
'collab.notes.create': 'Créer',
|
||||
'collab.notes.website': 'Site web',
|
||||
'collab.notes.websitePlaceholder': 'https://...',
|
||||
'collab.notes.websitePlaceholder': 'https://…',
|
||||
'collab.notes.attachFiles': 'Joindre des fichiers',
|
||||
'collab.notes.noCategoriesYet': 'Aucune catégorie',
|
||||
'collab.notes.emptyDesc': 'Créez une note pour commencer',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -139,6 +139,50 @@ const nl: Record<string, string> = {
|
||||
'settings.temperature': 'Temperatuureenheid',
|
||||
'settings.timeFormat': 'Tijdnotatie',
|
||||
'settings.routeCalculation': 'Routeberekening',
|
||||
'settings.blurBookingCodes': 'Boekingscodes vervagen',
|
||||
'settings.notifications': 'Meldingen',
|
||||
'settings.notifyTripInvite': 'Reisuitnodigingen',
|
||||
'settings.notifyBookingChange': 'Boekingswijzigingen',
|
||||
'settings.notifyTripReminder': 'Reisherinneringen',
|
||||
'settings.notifyVacayInvite': 'Vacay-fusieuitnodigingen',
|
||||
'settings.notifyPhotosShared': 'Gedeelde foto\'s (Immich)',
|
||||
'settings.notifyCollabMessage': 'Chatberichten (Collab)',
|
||||
'settings.notifyPackingTagged': 'Paklijst: toewijzingen',
|
||||
'settings.notifyWebhook': 'Webhook-meldingen',
|
||||
'admin.smtp.title': 'E-mail en meldingen',
|
||||
'admin.smtp.hint': 'SMTP-configuratie voor e-mailmeldingen. Optioneel: Webhook-URL voor Discord, Slack, etc.',
|
||||
'admin.smtp.testButton': 'Test-e-mail verzenden',
|
||||
'admin.smtp.testSuccess': 'Test-e-mail succesvol verzonden',
|
||||
'admin.smtp.testFailed': 'Test-e-mail mislukt',
|
||||
'dayplan.icsTooltip': 'Kalender exporteren (ICS)',
|
||||
'share.linkTitle': 'Openbare link',
|
||||
'share.linkHint': 'Maak een link die iedereen kan gebruiken om deze reis te bekijken zonder in te loggen. Alleen-lezen — bewerken niet mogelijk.',
|
||||
'share.createLink': 'Link aanmaken',
|
||||
'share.deleteLink': 'Link verwijderen',
|
||||
'share.createError': 'Kon link niet aanmaken',
|
||||
'common.copy': 'Kopiëren',
|
||||
'common.copied': 'Gekopieerd',
|
||||
'share.permMap': 'Kaart en plan',
|
||||
'share.permBookings': 'Boekingen',
|
||||
'share.permPacking': 'Paklijst',
|
||||
'shared.expired': 'Link verlopen of ongeldig',
|
||||
'shared.expiredHint': 'Deze gedeelde reislink is niet meer actief.',
|
||||
'shared.readOnly': 'Alleen-lezen weergave',
|
||||
'shared.tabPlan': 'Plan',
|
||||
'shared.tabBookings': 'Boekingen',
|
||||
'shared.tabPacking': 'Paklijst',
|
||||
'shared.tabBudget': 'Budget',
|
||||
'shared.tabChat': 'Chat',
|
||||
'shared.days': 'dagen',
|
||||
'shared.places': 'plaatsen',
|
||||
'shared.other': 'Overig',
|
||||
'shared.totalBudget': 'Totaal budget',
|
||||
'shared.messages': 'berichten',
|
||||
'shared.sharedVia': 'Gedeeld via',
|
||||
'shared.confirmed': 'Bevestigd',
|
||||
'shared.pending': 'In afwachting',
|
||||
'share.permBudget': 'Budget',
|
||||
'share.permCollab': 'Chat',
|
||||
'settings.on': 'Aan',
|
||||
'settings.off': 'Uit',
|
||||
'settings.account': 'Account',
|
||||
@@ -271,6 +315,7 @@ const nl: Record<string, string> = {
|
||||
'admin.tabs.users': 'Gebruikers',
|
||||
'admin.tabs.categories': 'Categorieën',
|
||||
'admin.tabs.backup': 'Back-up',
|
||||
'admin.tabs.audit': 'Auditlog',
|
||||
'admin.stats.users': 'Gebruikers',
|
||||
'admin.stats.trips': 'Reizen',
|
||||
'admin.stats.places': 'Plaatsen',
|
||||
@@ -412,6 +457,19 @@ const nl: Record<string, string> = {
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
|
||||
'admin.audit.subtitle': 'Beveiligingsgevoelige en beheerdersgebeurtenissen (back-ups, gebruikers, MFA, instellingen).',
|
||||
'admin.audit.empty': 'Nog geen auditregistraties.',
|
||||
'admin.audit.refresh': 'Vernieuwen',
|
||||
'admin.audit.loadMore': 'Meer laden',
|
||||
'admin.audit.showing': '{count} geladen · {total} totaal',
|
||||
'admin.audit.col.time': 'Tijd',
|
||||
'admin.audit.col.user': 'Gebruiker',
|
||||
'admin.audit.col.action': 'Actie',
|
||||
'admin.audit.col.resource': 'Bron',
|
||||
'admin.audit.col.ip': 'IP',
|
||||
'admin.audit.col.details': 'Details',
|
||||
|
||||
'admin.github.title': 'Release-geschiedenis',
|
||||
'admin.github.subtitle': 'Laatste updates van {repo}',
|
||||
'admin.github.latest': 'Nieuwste',
|
||||
@@ -475,6 +533,14 @@ const nl: Record<string, string> = {
|
||||
'vacay.carriedOver': 'van {year}',
|
||||
'vacay.blockWeekends': 'Weekenden blokkeren',
|
||||
'vacay.blockWeekendsHint': 'Voorkom vakantie-invoeren op zaterdag en zondag',
|
||||
'vacay.weekendDays': 'Weekenddagen',
|
||||
'vacay.mon': 'Ma',
|
||||
'vacay.tue': 'Di',
|
||||
'vacay.wed': 'Wo',
|
||||
'vacay.thu': 'Do',
|
||||
'vacay.fri': 'Vr',
|
||||
'vacay.sat': 'Za',
|
||||
'vacay.sun': 'Zo',
|
||||
'vacay.publicHolidays': 'Feestdagen',
|
||||
'vacay.publicHolidaysHint': 'Markeer feestdagen in de kalender',
|
||||
'vacay.selectCountry': 'Selecteer land',
|
||||
@@ -569,6 +635,10 @@ const nl: Record<string, string> = {
|
||||
'atlas.markVisited': 'Markeren als bezocht',
|
||||
'atlas.markVisitedHint': 'Dit land toevoegen aan je bezochte lijst',
|
||||
'atlas.addToBucket': 'Aan bucket list toevoegen',
|
||||
'atlas.addPoi': 'Plaats toevoegen',
|
||||
'atlas.bucketNamePlaceholder': 'Naam (land, stad, plek…)',
|
||||
'atlas.month': 'Maand',
|
||||
'atlas.year': 'Jaar',
|
||||
'atlas.addToBucketHint': 'Opslaan als plek die je wilt bezoeken',
|
||||
'atlas.bucketWhen': 'Wanneer ben je van plan te gaan?',
|
||||
|
||||
@@ -618,14 +688,26 @@ const nl: Record<string, string> = {
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Dagplan exporteren als PDF',
|
||||
'dayplan.pdfError': 'PDF-export mislukt',
|
||||
'dayplan.cannotReorderTransport': 'Boekingen met een vast tijdstip kunnen niet worden verplaatst',
|
||||
'dayplan.confirmRemoveTimeTitle': 'Tijd verwijderen?',
|
||||
'dayplan.confirmRemoveTimeBody': 'Deze plek heeft een vast tijdstip ({time}). Verplaatsen verwijdert het tijdstip en maakt vrije sortering mogelijk.',
|
||||
'dayplan.confirmRemoveTimeAction': 'Tijd verwijderen en verplaatsen',
|
||||
'dayplan.cannotDropOnTimed': 'Items kunnen niet tussen tijdgebonden items worden geplaatst',
|
||||
'dayplan.cannotBreakChronology': 'Dit zou de chronologische volgorde van geplande items en boekingen doorbreken',
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Plaats/activiteit toevoegen',
|
||||
'places.importGpx': 'GPX importeren',
|
||||
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
|
||||
'places.gpxError': 'GPX-import mislukt',
|
||||
'places.urlResolved': 'Plaats geïmporteerd van URL',
|
||||
'places.assignToDay': 'Aan welke dag toevoegen?',
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ongepland',
|
||||
'places.search': 'Plaatsen zoeken...',
|
||||
'places.allCategories': 'Alle categorieën',
|
||||
'places.categoriesSelected': 'categorieën',
|
||||
'places.clearFilter': 'Filter wissen',
|
||||
'places.count': '{count} plaatsen',
|
||||
'places.countSingular': '1 plaats',
|
||||
'places.allPlanned': 'Alle plaatsen zijn gepland',
|
||||
@@ -724,6 +806,8 @@ const nl: Record<string, string> = {
|
||||
'reservations.type.tour': 'Rondleiding',
|
||||
'reservations.type.other': 'Overig',
|
||||
'reservations.confirm.delete': 'Weet je zeker dat je de reservering "{name}" wilt verwijderen?',
|
||||
'reservations.confirm.deleteTitle': 'Boeking verwijderen?',
|
||||
'reservations.confirm.deleteBody': '"{name}" wordt permanent verwijderd.',
|
||||
'reservations.toast.updated': 'Reservering bijgewerkt',
|
||||
'reservations.toast.removed': 'Reservering verwijderd',
|
||||
'reservations.toast.fileUploaded': 'Bestand geüpload',
|
||||
@@ -743,6 +827,7 @@ const nl: Record<string, string> = {
|
||||
'reservations.pendingSave': 'wordt opgeslagen…',
|
||||
'reservations.uploading': 'Uploaden...',
|
||||
'reservations.attachFile': 'Bestand bijvoegen',
|
||||
'reservations.linkExisting': 'Bestaand bestand koppelen',
|
||||
'reservations.toast.saveError': 'Opslaan mislukt',
|
||||
'reservations.toast.updateError': 'Bijwerken mislukt',
|
||||
'reservations.toast.deleteError': 'Verwijderen mislukt',
|
||||
@@ -780,6 +865,9 @@ const nl: Record<string, string> = {
|
||||
'budget.paid': 'Betaald',
|
||||
'budget.open': 'Open',
|
||||
'budget.noMembers': 'Geen leden toegewezen',
|
||||
'budget.settlement': 'Afrekening',
|
||||
'budget.settlementInfo': 'Klik op de avatar van een lid bij een budgetpost om deze groen te markeren — dit betekent dat diegene heeft betaald. De afrekening toont vervolgens wie wie hoeveel verschuldigd is.',
|
||||
'budget.netBalances': 'Nettosaldi',
|
||||
|
||||
// Files
|
||||
'files.title': 'Bestanden',
|
||||
@@ -833,6 +921,15 @@ const nl: Record<string, string> = {
|
||||
// Packing
|
||||
'packing.title': 'Paklijst',
|
||||
'packing.empty': 'Paklijst is leeg',
|
||||
'packing.import': 'Importeren',
|
||||
'packing.importTitle': 'Paklijst importeren',
|
||||
'packing.importHint': 'Eén item per regel. Optioneel categorie en aantal gescheiden door komma, puntkomma of tab: Naam, Categorie, Aantal',
|
||||
'packing.importPlaceholder': 'Tandenborstel\nZonnebrand, Hygiëne\nT-Shirts, Kleding, 5\nPaspoort, Documenten',
|
||||
'packing.importCsv': 'CSV/TXT laden',
|
||||
'packing.importAction': '{count} importeren',
|
||||
'packing.importSuccess': '{count} items geïmporteerd',
|
||||
'packing.importError': 'Import mislukt',
|
||||
'packing.importEmpty': 'Geen items om te importeren',
|
||||
'packing.progress': '{packed} van {total} ingepakt ({percent}%)',
|
||||
'packing.clearChecked': '{count} aangevinkte verwijderen',
|
||||
'packing.clearCheckedShort': '{count} verwijderen',
|
||||
@@ -984,7 +1081,27 @@ const nl: Record<string, string> = {
|
||||
'backup.auto.enable': 'Auto-back-up inschakelen',
|
||||
'backup.auto.enableHint': 'Back-ups worden automatisch aangemaakt volgens het gekozen schema',
|
||||
'backup.auto.interval': 'Interval',
|
||||
'backup.auto.hour': 'Uitvoeren om',
|
||||
'backup.auto.hourHint': 'Lokale servertijd ({format}-notatie)',
|
||||
'backup.auto.dayOfWeek': 'Dag van de week',
|
||||
'backup.auto.dayOfMonth': 'Dag van de maand',
|
||||
'backup.auto.dayOfMonthHint': 'Beperkt tot 1–28 voor compatibiliteit met alle maanden',
|
||||
'backup.auto.scheduleSummary': 'Planning',
|
||||
'backup.auto.summaryDaily': 'Elke dag om {hour}:00',
|
||||
'backup.auto.summaryWeekly': 'Elke {day} om {hour}:00',
|
||||
'backup.auto.summaryMonthly': 'Dag {day} van elke maand om {hour}:00',
|
||||
'backup.auto.envLocked': 'Docker',
|
||||
'backup.auto.envLockedHint': 'Auto-back-up is geconfigureerd via Docker-omgevingsvariabelen. Pas je docker-compose.yml aan en herstart de container om deze instellingen te wijzigen.',
|
||||
'backup.auto.copyEnv': 'Docker-omgevingsvariabelen kopiëren',
|
||||
'backup.auto.envCopied': 'Docker-omgevingsvariabelen gekopieerd naar klembord',
|
||||
'backup.auto.keepLabel': 'Oude back-ups verwijderen na',
|
||||
'backup.dow.sunday': 'Zo',
|
||||
'backup.dow.monday': 'Ma',
|
||||
'backup.dow.tuesday': 'Di',
|
||||
'backup.dow.wednesday': 'Wo',
|
||||
'backup.dow.thursday': 'Do',
|
||||
'backup.dow.friday': 'Vr',
|
||||
'backup.dow.saturday': 'Za',
|
||||
'backup.interval.hourly': 'Elk uur',
|
||||
'backup.interval.daily': 'Dagelijks',
|
||||
'backup.interval.weekly': 'Wekelijks',
|
||||
@@ -1111,6 +1228,45 @@ const nl: Record<string, string> = {
|
||||
'day.editAccommodation': 'Accommodatie bewerken',
|
||||
'day.reservations': 'Reserveringen',
|
||||
|
||||
// Memories / Immich
|
||||
'memories.title': 'Foto\'s',
|
||||
'memories.notConnected': 'Immich niet verbonden',
|
||||
'memories.notConnectedHint': 'Verbind je Immich-instantie in Instellingen om je reisfoto\'s hier te zien.',
|
||||
'memories.noDates': 'Voeg data toe aan je reis om foto\'s te laden.',
|
||||
'memories.noPhotos': 'Geen foto\'s gevonden',
|
||||
'memories.noPhotosHint': 'Geen foto\'s gevonden in Immich voor de datumreeks van deze reis.',
|
||||
'memories.photosFound': 'foto\'s',
|
||||
'memories.fromOthers': 'van anderen',
|
||||
'memories.sharePhotos': 'Foto\'s delen',
|
||||
'memories.sharing': 'Wordt gedeeld',
|
||||
'memories.reviewTitle': 'Je foto\'s bekijken',
|
||||
'memories.reviewHint': 'Klik op foto\'s om ze uit te sluiten van delen.',
|
||||
'memories.shareCount': '{count} foto\'s delen',
|
||||
'memories.immichUrl': 'Immich Server URL',
|
||||
'memories.immichApiKey': 'API-sleutel',
|
||||
'memories.testConnection': 'Verbinding testen',
|
||||
'memories.connected': 'Verbonden',
|
||||
'memories.disconnected': 'Niet verbonden',
|
||||
'memories.connectionSuccess': 'Verbonden met Immich',
|
||||
'memories.connectionError': 'Kon niet verbinden met Immich',
|
||||
'memories.saved': 'Immich-instellingen opgeslagen',
|
||||
'memories.oldest': 'Oudste eerst',
|
||||
'memories.newest': 'Nieuwste eerst',
|
||||
'memories.allLocations': 'Alle locaties',
|
||||
'memories.addPhotos': 'Foto\'s toevoegen',
|
||||
'memories.selectPhotos': 'Selecteer foto\'s uit Immich',
|
||||
'memories.selectHint': 'Tik op foto\'s om ze te selecteren.',
|
||||
'memories.selected': 'geselecteerd',
|
||||
'memories.addSelected': '{count} foto\'s toevoegen',
|
||||
'memories.alreadyAdded': 'Toegevoegd',
|
||||
'memories.private': 'Privé',
|
||||
'memories.stopSharing': 'Delen stoppen',
|
||||
'memories.tripDates': 'Reisdata',
|
||||
'memories.allPhotos': 'Alle foto\'s',
|
||||
'memories.confirmShareTitle': 'Delen met reisgenoten?',
|
||||
'memories.confirmShareHint': '{count} foto\'s worden zichtbaar voor alle leden van deze reis. Je kunt individuele foto\'s later privé maken.',
|
||||
'memories.confirmShareButton': 'Foto\'s delen',
|
||||
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'Chat',
|
||||
'collab.tabs.notes': 'Notities',
|
||||
|
||||
@@ -139,6 +139,50 @@ const ru: Record<string, string> = {
|
||||
'settings.temperature': 'Единица температуры',
|
||||
'settings.timeFormat': 'Формат времени',
|
||||
'settings.routeCalculation': 'Расчёт маршрута',
|
||||
'settings.blurBookingCodes': 'Скрыть коды бронирования',
|
||||
'settings.notifications': 'Уведомления',
|
||||
'settings.notifyTripInvite': 'Приглашения в поездку',
|
||||
'settings.notifyBookingChange': 'Изменения бронирований',
|
||||
'settings.notifyTripReminder': 'Напоминания о поездке',
|
||||
'settings.notifyVacayInvite': 'Приглашения слияния Vacay',
|
||||
'settings.notifyPhotosShared': 'Общие фото (Immich)',
|
||||
'settings.notifyCollabMessage': 'Сообщения чата (Collab)',
|
||||
'settings.notifyPackingTagged': 'Список вещей: назначения',
|
||||
'settings.notifyWebhook': 'Webhook-уведомления',
|
||||
'admin.smtp.title': 'Почта и уведомления',
|
||||
'admin.smtp.hint': 'Настройка SMTP для уведомлений по почте. Необязательно: Webhook URL для Discord, Slack и т.д.',
|
||||
'admin.smtp.testButton': 'Отправить тестовое письмо',
|
||||
'admin.smtp.testSuccess': 'Тестовое письмо успешно отправлено',
|
||||
'admin.smtp.testFailed': 'Ошибка отправки тестового письма',
|
||||
'dayplan.icsTooltip': 'Экспорт календаря (ICS)',
|
||||
'share.linkTitle': 'Публичная ссылка',
|
||||
'share.linkHint': 'Создайте ссылку, по которой любой сможет просмотреть эту поездку без входа в систему. Только чтение — редактирование невозможно.',
|
||||
'share.createLink': 'Создать ссылку',
|
||||
'share.deleteLink': 'Удалить ссылку',
|
||||
'share.createError': 'Не удалось создать ссылку',
|
||||
'common.copy': 'Копировать',
|
||||
'common.copied': 'Скопировано',
|
||||
'share.permMap': 'Карта и план',
|
||||
'share.permBookings': 'Бронирования',
|
||||
'share.permPacking': 'Вещи',
|
||||
'shared.expired': 'Ссылка устарела или недействительна',
|
||||
'shared.expiredHint': 'Эта ссылка на поездку больше не активна.',
|
||||
'shared.readOnly': 'Режим только для чтения',
|
||||
'shared.tabPlan': 'План',
|
||||
'shared.tabBookings': 'Бронирования',
|
||||
'shared.tabPacking': 'Багаж',
|
||||
'shared.tabBudget': 'Бюджет',
|
||||
'shared.tabChat': 'Чат',
|
||||
'shared.days': 'дней',
|
||||
'shared.places': 'мест',
|
||||
'shared.other': 'Прочее',
|
||||
'shared.totalBudget': 'Общий бюджет',
|
||||
'shared.messages': 'сообщений',
|
||||
'shared.sharedVia': 'Поделено через',
|
||||
'shared.confirmed': 'Подтверждено',
|
||||
'shared.pending': 'Ожидает',
|
||||
'share.permBudget': 'Бюджет',
|
||||
'share.permCollab': 'Чат',
|
||||
'settings.on': 'Вкл.',
|
||||
'settings.off': 'Выкл.',
|
||||
'settings.account': 'Аккаунт',
|
||||
@@ -271,6 +315,7 @@ const ru: Record<string, string> = {
|
||||
'admin.tabs.users': 'Пользователи',
|
||||
'admin.tabs.categories': 'Категории',
|
||||
'admin.tabs.backup': 'Резервная копия',
|
||||
'admin.tabs.audit': 'Журнал аудита',
|
||||
'admin.stats.users': 'Пользователи',
|
||||
'admin.stats.trips': 'Поездки',
|
||||
'admin.stats.places': 'Места',
|
||||
@@ -412,6 +457,19 @@ const ru: Record<string, string> = {
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
|
||||
'admin.audit.subtitle': 'События, связанные с безопасностью и администрированием (резервные копии, пользователи, MFA, настройки).',
|
||||
'admin.audit.empty': 'Записей аудита пока нет.',
|
||||
'admin.audit.refresh': 'Обновить',
|
||||
'admin.audit.loadMore': 'Загрузить ещё',
|
||||
'admin.audit.showing': 'Загружено: {count} · всего {total}',
|
||||
'admin.audit.col.time': 'Время',
|
||||
'admin.audit.col.user': 'Пользователь',
|
||||
'admin.audit.col.action': 'Действие',
|
||||
'admin.audit.col.resource': 'Объект',
|
||||
'admin.audit.col.ip': 'IP',
|
||||
'admin.audit.col.details': 'Подробности',
|
||||
|
||||
'admin.github.title': 'История релизов',
|
||||
'admin.github.subtitle': 'Последние обновления из {repo}',
|
||||
'admin.github.latest': 'Последний',
|
||||
@@ -475,6 +533,14 @@ const ru: Record<string, string> = {
|
||||
'vacay.carriedOver': 'из {year}',
|
||||
'vacay.blockWeekends': 'Блокировать выходные',
|
||||
'vacay.blockWeekendsHint': 'Запретить записи об отпуске в субботу и воскресенье',
|
||||
'vacay.weekendDays': 'Выходные дни',
|
||||
'vacay.mon': 'Пн',
|
||||
'vacay.tue': 'Вт',
|
||||
'vacay.wed': 'Ср',
|
||||
'vacay.thu': 'Чт',
|
||||
'vacay.fri': 'Пт',
|
||||
'vacay.sat': 'Сб',
|
||||
'vacay.sun': 'Вс',
|
||||
'vacay.publicHolidays': 'Государственные праздники',
|
||||
'vacay.publicHolidaysHint': 'Отмечать государственные праздники в календаре',
|
||||
'vacay.selectCountry': 'Выберите страну',
|
||||
@@ -569,6 +635,10 @@ const ru: Record<string, string> = {
|
||||
'atlas.markVisited': 'Отметить как посещённую',
|
||||
'atlas.markVisitedHint': 'Добавить эту страну в список посещённых',
|
||||
'atlas.addToBucket': 'В список желаний',
|
||||
'atlas.addPoi': 'Добавить место',
|
||||
'atlas.bucketNamePlaceholder': 'Название (страна, город, место…)',
|
||||
'atlas.month': 'Месяц',
|
||||
'atlas.year': 'Год',
|
||||
'atlas.addToBucketHint': 'Сохранить как место для посещения',
|
||||
'atlas.bucketWhen': 'Когда вы планируете поехать?',
|
||||
|
||||
@@ -618,14 +688,26 @@ const ru: Record<string, string> = {
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Экспортировать план дня в PDF',
|
||||
'dayplan.pdfError': 'Ошибка экспорта PDF',
|
||||
'dayplan.cannotReorderTransport': 'Бронирования с фиксированным временем нельзя перемещать',
|
||||
'dayplan.confirmRemoveTimeTitle': 'Удалить время?',
|
||||
'dayplan.confirmRemoveTimeBody': 'У этого места фиксированное время ({time}). При перемещении время будет удалено, и станет доступна свободная сортировка.',
|
||||
'dayplan.confirmRemoveTimeAction': 'Удалить время и переместить',
|
||||
'dayplan.cannotDropOnTimed': 'Элементы нельзя размещать между записями с фиксированным временем',
|
||||
'dayplan.cannotBreakChronology': 'Это нарушит хронологический порядок запланированных элементов и бронирований',
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Добавить место/активность',
|
||||
'places.importGpx': 'Импорт GPX',
|
||||
'places.gpxImported': '{count} мест импортировано из GPX',
|
||||
'places.gpxError': 'Ошибка импорта GPX',
|
||||
'places.urlResolved': 'Место импортировано из URL',
|
||||
'places.assignToDay': 'Добавить в какой день?',
|
||||
'places.all': 'Все',
|
||||
'places.unplanned': 'Незапланированные',
|
||||
'places.search': 'Поиск мест...',
|
||||
'places.allCategories': 'Все категории',
|
||||
'places.categoriesSelected': 'категорий',
|
||||
'places.clearFilter': 'Сбросить фильтр',
|
||||
'places.count': '{count} мест',
|
||||
'places.countSingular': '1 место',
|
||||
'places.allPlanned': 'Все места запланированы',
|
||||
@@ -724,6 +806,8 @@ const ru: Record<string, string> = {
|
||||
'reservations.type.tour': 'Экскурсия',
|
||||
'reservations.type.other': 'Другое',
|
||||
'reservations.confirm.delete': 'Вы уверены, что хотите удалить бронирование «{name}»?',
|
||||
'reservations.confirm.deleteTitle': 'Удалить бронирование?',
|
||||
'reservations.confirm.deleteBody': '«{name}» будет удалено навсегда.',
|
||||
'reservations.toast.updated': 'Бронирование обновлено',
|
||||
'reservations.toast.removed': 'Бронирование удалено',
|
||||
'reservations.toast.fileUploaded': 'Файл загружен',
|
||||
@@ -743,6 +827,7 @@ const ru: Record<string, string> = {
|
||||
'reservations.pendingSave': 'будет сохранено…',
|
||||
'reservations.uploading': 'Загрузка...',
|
||||
'reservations.attachFile': 'Прикрепить файл',
|
||||
'reservations.linkExisting': 'Привязать существующий файл',
|
||||
'reservations.toast.saveError': 'Ошибка сохранения',
|
||||
'reservations.toast.updateError': 'Ошибка обновления',
|
||||
'reservations.toast.deleteError': 'Ошибка удаления',
|
||||
@@ -780,6 +865,9 @@ const ru: Record<string, string> = {
|
||||
'budget.paid': 'Оплачено',
|
||||
'budget.open': 'Не оплачено',
|
||||
'budget.noMembers': 'Участники не назначены',
|
||||
'budget.settlement': 'Взаиморасчёт',
|
||||
'budget.settlementInfo': 'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.',
|
||||
'budget.netBalances': 'Чистые балансы',
|
||||
|
||||
// Files
|
||||
'files.title': 'Файлы',
|
||||
@@ -833,6 +921,15 @@ const ru: Record<string, string> = {
|
||||
// Packing
|
||||
'packing.title': 'Список вещей',
|
||||
'packing.empty': 'Список вещей пуст',
|
||||
'packing.import': 'Импорт',
|
||||
'packing.importTitle': 'Импорт списка вещей',
|
||||
'packing.importHint': 'Один предмет на строку. Категория и количество — через запятую, точку с запятой или табуляцию: Название, Категория, Количество',
|
||||
'packing.importPlaceholder': 'Зубная щётка\nСолнцезащитный крем, Гигиена\nФутболки, Одежда, 5\nПаспорт, Документы',
|
||||
'packing.importCsv': 'Загрузить CSV/TXT',
|
||||
'packing.importAction': 'Импортировать {count}',
|
||||
'packing.importSuccess': '{count} предметов импортировано',
|
||||
'packing.importError': 'Ошибка импорта',
|
||||
'packing.importEmpty': 'Нет предметов для импорта',
|
||||
'packing.progress': '{packed} из {total} собрано ({percent}%)',
|
||||
'packing.clearChecked': 'Удалить {count} отмеченных',
|
||||
'packing.clearCheckedShort': 'Удалить {count}',
|
||||
@@ -984,7 +1081,27 @@ const ru: Record<string, string> = {
|
||||
'backup.auto.enable': 'Включить автокопирование',
|
||||
'backup.auto.enableHint': 'Резервные копии будут создаваться автоматически по выбранному расписанию',
|
||||
'backup.auto.interval': 'Интервал',
|
||||
'backup.auto.hour': 'Запуск в час',
|
||||
'backup.auto.hourHint': 'Местное время сервера (формат {format})',
|
||||
'backup.auto.dayOfWeek': 'День недели',
|
||||
'backup.auto.dayOfMonth': 'День месяца',
|
||||
'backup.auto.dayOfMonthHint': 'Ограничено 1–28 для совместимости со всеми месяцами',
|
||||
'backup.auto.scheduleSummary': 'Расписание',
|
||||
'backup.auto.summaryDaily': 'Каждый день в {hour}:00',
|
||||
'backup.auto.summaryWeekly': 'Каждый {day} в {hour}:00',
|
||||
'backup.auto.summaryMonthly': '{day}-го числа каждого месяца в {hour}:00',
|
||||
'backup.auto.envLocked': 'Docker',
|
||||
'backup.auto.envLockedHint': 'Автокопирование настроено через переменные окружения Docker. Чтобы изменить параметры, обновите docker-compose.yml и перезапустите контейнер.',
|
||||
'backup.auto.copyEnv': 'Скопировать переменные окружения Docker',
|
||||
'backup.auto.envCopied': 'Переменные окружения Docker скопированы в буфер обмена',
|
||||
'backup.auto.keepLabel': 'Удалять старые копии через',
|
||||
'backup.dow.sunday': 'Вс',
|
||||
'backup.dow.monday': 'Пн',
|
||||
'backup.dow.tuesday': 'Вт',
|
||||
'backup.dow.wednesday': 'Ср',
|
||||
'backup.dow.thursday': 'Чт',
|
||||
'backup.dow.friday': 'Пт',
|
||||
'backup.dow.saturday': 'Сб',
|
||||
'backup.interval.hourly': 'Каждый час',
|
||||
'backup.interval.daily': 'Ежедневно',
|
||||
'backup.interval.weekly': 'Еженедельно',
|
||||
@@ -1111,6 +1228,45 @@ const ru: Record<string, string> = {
|
||||
'day.editAccommodation': 'Редактировать жильё',
|
||||
'day.reservations': 'Бронирования',
|
||||
|
||||
// Memories / Immich
|
||||
'memories.title': 'Фото',
|
||||
'memories.notConnected': 'Immich не подключён',
|
||||
'memories.notConnectedHint': 'Подключите Immich в настройках, чтобы видеть фотографии из поездок.',
|
||||
'memories.noDates': 'Добавьте даты поездки для загрузки фотографий.',
|
||||
'memories.noPhotos': 'Фотографии не найдены',
|
||||
'memories.noPhotosHint': 'В Immich нет фотографий за период этой поездки.',
|
||||
'memories.photosFound': 'фото',
|
||||
'memories.fromOthers': 'от других',
|
||||
'memories.sharePhotos': 'Поделиться фото',
|
||||
'memories.sharing': 'Общий доступ',
|
||||
'memories.reviewTitle': 'Проверьте ваши фото',
|
||||
'memories.reviewHint': 'Нажмите на фото, чтобы исключить его из общего доступа.',
|
||||
'memories.shareCount': 'Поделиться ({count} фото)',
|
||||
'memories.immichUrl': 'URL сервера Immich',
|
||||
'memories.immichApiKey': 'API-ключ',
|
||||
'memories.testConnection': 'Проверить подключение',
|
||||
'memories.connected': 'Подключено',
|
||||
'memories.disconnected': 'Не подключено',
|
||||
'memories.connectionSuccess': 'Подключение к Immich установлено',
|
||||
'memories.connectionError': 'Не удалось подключиться к Immich',
|
||||
'memories.saved': 'Настройки Immich сохранены',
|
||||
'memories.oldest': 'Сначала старые',
|
||||
'memories.newest': 'Сначала новые',
|
||||
'memories.allLocations': 'Все места',
|
||||
'memories.addPhotos': 'Добавить фото',
|
||||
'memories.selectPhotos': 'Выбрать фото из Immich',
|
||||
'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.',
|
||||
'memories.selected': 'выбрано',
|
||||
'memories.addSelected': 'Добавить {count} фото',
|
||||
'memories.alreadyAdded': 'Добавлено',
|
||||
'memories.private': 'Приватное',
|
||||
'memories.stopSharing': 'Прекратить доступ',
|
||||
'memories.tripDates': 'Даты поездки',
|
||||
'memories.allPhotos': 'Все фото',
|
||||
'memories.confirmShareTitle': 'Поделиться с участниками поездки?',
|
||||
'memories.confirmShareHint': '{count} фото станут видны всем участникам этой поездки. Вы сможете сделать отдельные фото приватными позже.',
|
||||
'memories.confirmShareButton': 'Поделиться фото',
|
||||
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'Чат',
|
||||
'collab.tabs.notes': 'Заметки',
|
||||
|
||||
@@ -139,6 +139,50 @@ const zh: Record<string, string> = {
|
||||
'settings.temperature': '温度单位',
|
||||
'settings.timeFormat': '时间格式',
|
||||
'settings.routeCalculation': '路线计算',
|
||||
'settings.blurBookingCodes': '模糊预订代码',
|
||||
'settings.notifications': '通知',
|
||||
'settings.notifyTripInvite': '旅行邀请',
|
||||
'settings.notifyBookingChange': '预订变更',
|
||||
'settings.notifyTripReminder': '旅行提醒',
|
||||
'settings.notifyVacayInvite': 'Vacay 融合邀请',
|
||||
'settings.notifyPhotosShared': '共享照片 (Immich)',
|
||||
'settings.notifyCollabMessage': '聊天消息 (Collab)',
|
||||
'settings.notifyPackingTagged': '行李清单:分配',
|
||||
'settings.notifyWebhook': 'Webhook 通知',
|
||||
'admin.smtp.title': '邮件与通知',
|
||||
'admin.smtp.hint': '用于邮件通知的 SMTP 配置。可选:Discord、Slack 等的 Webhook URL。',
|
||||
'admin.smtp.testButton': '发送测试邮件',
|
||||
'admin.smtp.testSuccess': '测试邮件发送成功',
|
||||
'admin.smtp.testFailed': '测试邮件发送失败',
|
||||
'dayplan.icsTooltip': '导出日历 (ICS)',
|
||||
'share.linkTitle': '公开链接',
|
||||
'share.linkHint': '创建一个链接,任何人无需登录即可查看此旅行。仅可查看,无法编辑。',
|
||||
'share.createLink': '创建链接',
|
||||
'share.deleteLink': '删除链接',
|
||||
'share.createError': '无法创建链接',
|
||||
'common.copy': '复制',
|
||||
'common.copied': '已复制',
|
||||
'share.permMap': '地图与计划',
|
||||
'share.permBookings': '预订',
|
||||
'share.permPacking': '行李',
|
||||
'shared.expired': '链接已过期或无效',
|
||||
'shared.expiredHint': '此共享旅行链接已失效。',
|
||||
'shared.readOnly': '只读共享视图',
|
||||
'shared.tabPlan': '计划',
|
||||
'shared.tabBookings': '预订',
|
||||
'shared.tabPacking': '行李',
|
||||
'shared.tabBudget': '预算',
|
||||
'shared.tabChat': '聊天',
|
||||
'shared.days': '天',
|
||||
'shared.places': '个地点',
|
||||
'shared.other': '其他',
|
||||
'shared.totalBudget': '总预算',
|
||||
'shared.messages': '条消息',
|
||||
'shared.sharedVia': '通过以下分享',
|
||||
'shared.confirmed': '已确认',
|
||||
'shared.pending': '待确认',
|
||||
'share.permBudget': '预算',
|
||||
'share.permCollab': '聊天',
|
||||
'settings.on': '开',
|
||||
'settings.off': '关',
|
||||
'settings.account': '账户',
|
||||
@@ -271,6 +315,7 @@ const zh: Record<string, string> = {
|
||||
'admin.tabs.users': '用户',
|
||||
'admin.tabs.categories': '分类',
|
||||
'admin.tabs.backup': '备份',
|
||||
'admin.tabs.audit': '审计日志',
|
||||
'admin.stats.users': '用户',
|
||||
'admin.stats.trips': '旅行',
|
||||
'admin.stats.places': '地点',
|
||||
@@ -412,6 +457,19 @@ const zh: Record<string, string> = {
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
|
||||
'admin.audit.subtitle': '安全与管理员操作记录(备份、用户、MFA、设置)。',
|
||||
'admin.audit.empty': '暂无审计记录。',
|
||||
'admin.audit.refresh': '刷新',
|
||||
'admin.audit.loadMore': '加载更多',
|
||||
'admin.audit.showing': '已加载 {count} 条 · 共 {total} 条',
|
||||
'admin.audit.col.time': '时间',
|
||||
'admin.audit.col.user': '用户',
|
||||
'admin.audit.col.action': '操作',
|
||||
'admin.audit.col.resource': '资源',
|
||||
'admin.audit.col.ip': 'IP',
|
||||
'admin.audit.col.details': '详情',
|
||||
|
||||
'admin.github.title': '版本历史',
|
||||
'admin.github.subtitle': '{repo} 的最新更新',
|
||||
'admin.github.latest': '最新',
|
||||
@@ -475,6 +533,14 @@ const zh: Record<string, string> = {
|
||||
'vacay.carriedOver': '从 {year} 结转',
|
||||
'vacay.blockWeekends': '锁定周末',
|
||||
'vacay.blockWeekendsHint': '禁止在周六和周日安排假期',
|
||||
'vacay.weekendDays': '周末',
|
||||
'vacay.mon': '周一',
|
||||
'vacay.tue': '周二',
|
||||
'vacay.wed': '周三',
|
||||
'vacay.thu': '周四',
|
||||
'vacay.fri': '周五',
|
||||
'vacay.sat': '周六',
|
||||
'vacay.sun': '周日',
|
||||
'vacay.publicHolidays': '公共假日',
|
||||
'vacay.publicHolidaysHint': '在日历中标记公共假日',
|
||||
'vacay.selectCountry': '选择国家',
|
||||
@@ -569,6 +635,10 @@ const zh: Record<string, string> = {
|
||||
'atlas.markVisited': '标记为已访问',
|
||||
'atlas.markVisitedHint': '将此国家添加到已访问列表',
|
||||
'atlas.addToBucket': '添加到心愿单',
|
||||
'atlas.addPoi': '添加地点',
|
||||
'atlas.bucketNamePlaceholder': '名称(国家、城市、地点…)',
|
||||
'atlas.month': '月份',
|
||||
'atlas.year': '年份',
|
||||
'atlas.addToBucketHint': '保存为想去的地方',
|
||||
'atlas.bucketWhen': '你计划什么时候去?',
|
||||
|
||||
@@ -618,14 +688,26 @@ const zh: Record<string, string> = {
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': '导出当天计划为 PDF',
|
||||
'dayplan.pdfError': 'PDF 导出失败',
|
||||
'dayplan.cannotReorderTransport': '有固定时间的预订无法重新排序',
|
||||
'dayplan.confirmRemoveTimeTitle': '移除时间?',
|
||||
'dayplan.confirmRemoveTimeBody': '此地点有固定时间({time})。移动后将移除时间并允许自由排序。',
|
||||
'dayplan.confirmRemoveTimeAction': '移除时间并移动',
|
||||
'dayplan.cannotDropOnTimed': '无法将项目放置在有固定时间的条目之间',
|
||||
'dayplan.cannotBreakChronology': '这将打乱已计划项目和预订的时间顺序',
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': '添加地点/活动',
|
||||
'places.importGpx': '导入 GPX',
|
||||
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
|
||||
'places.gpxError': 'GPX 导入失败',
|
||||
'places.urlResolved': '已从 URL 导入地点',
|
||||
'places.assignToDay': '添加到哪一天?',
|
||||
'places.all': '全部',
|
||||
'places.unplanned': '未规划',
|
||||
'places.search': '搜索地点...',
|
||||
'places.allCategories': '所有分类',
|
||||
'places.categoriesSelected': '个分类',
|
||||
'places.clearFilter': '清除筛选',
|
||||
'places.count': '{count} 个地点',
|
||||
'places.countSingular': '1 个地点',
|
||||
'places.allPlanned': '所有地点已规划',
|
||||
@@ -724,6 +806,8 @@ const zh: Record<string, string> = {
|
||||
'reservations.type.tour': '旅游团',
|
||||
'reservations.type.other': '其他',
|
||||
'reservations.confirm.delete': '确定要删除预订「{name}」吗?',
|
||||
'reservations.confirm.deleteTitle': '删除预订?',
|
||||
'reservations.confirm.deleteBody': '"{name}" 将被永久删除。',
|
||||
'reservations.toast.updated': '预订已更新',
|
||||
'reservations.toast.removed': '预订已删除',
|
||||
'reservations.toast.fileUploaded': '文件已上传',
|
||||
@@ -743,6 +827,7 @@ const zh: Record<string, string> = {
|
||||
'reservations.pendingSave': '将被保存…',
|
||||
'reservations.uploading': '上传中...',
|
||||
'reservations.attachFile': '附加文件',
|
||||
'reservations.linkExisting': '关联已有文件',
|
||||
'reservations.toast.saveError': '保存失败',
|
||||
'reservations.toast.updateError': '更新失败',
|
||||
'reservations.toast.deleteError': '删除失败',
|
||||
@@ -780,6 +865,9 @@ const zh: Record<string, string> = {
|
||||
'budget.paid': '已支付',
|
||||
'budget.open': '未支付',
|
||||
'budget.noMembers': '未分配成员',
|
||||
'budget.settlement': '结算',
|
||||
'budget.settlementInfo': '点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。',
|
||||
'budget.netBalances': '净余额',
|
||||
|
||||
// Files
|
||||
'files.title': '文件',
|
||||
@@ -833,6 +921,15 @@ const zh: Record<string, string> = {
|
||||
// Packing
|
||||
'packing.title': '行李清单',
|
||||
'packing.empty': '行李清单为空',
|
||||
'packing.import': '导入',
|
||||
'packing.importTitle': '导入装箱清单',
|
||||
'packing.importHint': '每行一个物品。可选用逗号、分号或制表符分隔类别和数量:名称, 类别, 数量',
|
||||
'packing.importPlaceholder': '牙刷\n防晒霜, 卫生\nT恤, 衣物, 5\n护照, 证件',
|
||||
'packing.importCsv': '加载 CSV/TXT',
|
||||
'packing.importAction': '导入 {count}',
|
||||
'packing.importSuccess': '已导入 {count} 项',
|
||||
'packing.importError': '导入失败',
|
||||
'packing.importEmpty': '没有可导入的项目',
|
||||
'packing.progress': '已打包 {packed}/{total}({percent}%)',
|
||||
'packing.clearChecked': '移除 {count} 个已勾选',
|
||||
'packing.clearCheckedShort': '移除 {count} 个',
|
||||
@@ -984,7 +1081,27 @@ const zh: Record<string, string> = {
|
||||
'backup.auto.enable': '启用自动备份',
|
||||
'backup.auto.enableHint': '将按所选计划自动创建备份',
|
||||
'backup.auto.interval': '间隔',
|
||||
'backup.auto.hour': '执行时间',
|
||||
'backup.auto.hourHint': '服务器本地时间({format} 格式)',
|
||||
'backup.auto.dayOfWeek': '星期几',
|
||||
'backup.auto.dayOfMonth': '每月几号',
|
||||
'backup.auto.dayOfMonthHint': '限 1–28 以兼容所有月份',
|
||||
'backup.auto.scheduleSummary': '计划',
|
||||
'backup.auto.summaryDaily': '每天 {hour}:00',
|
||||
'backup.auto.summaryWeekly': '每{day} {hour}:00',
|
||||
'backup.auto.summaryMonthly': '每月 {day} 号 {hour}:00',
|
||||
'backup.auto.envLocked': 'Docker',
|
||||
'backup.auto.envLockedHint': '自动备份通过 Docker 环境变量配置。要更改设置,请更新 docker-compose.yml 并重启容器。',
|
||||
'backup.auto.copyEnv': '复制 Docker 环境变量',
|
||||
'backup.auto.envCopied': 'Docker 环境变量已复制到剪贴板',
|
||||
'backup.auto.keepLabel': '自动删除旧备份',
|
||||
'backup.dow.sunday': '周日',
|
||||
'backup.dow.monday': '周一',
|
||||
'backup.dow.tuesday': '周二',
|
||||
'backup.dow.wednesday': '周三',
|
||||
'backup.dow.thursday': '周四',
|
||||
'backup.dow.friday': '周五',
|
||||
'backup.dow.saturday': '周六',
|
||||
'backup.interval.hourly': '每小时',
|
||||
'backup.interval.daily': '每天',
|
||||
'backup.interval.weekly': '每周',
|
||||
@@ -1111,6 +1228,45 @@ const zh: Record<string, string> = {
|
||||
'day.editAccommodation': '编辑住宿',
|
||||
'day.reservations': '预订',
|
||||
|
||||
// Memories / Immich
|
||||
'memories.title': '照片',
|
||||
'memories.notConnected': 'Immich 未连接',
|
||||
'memories.notConnectedHint': '在设置中连接您的 Immich 实例以在此查看旅行照片。',
|
||||
'memories.noDates': '为旅行添加日期以加载照片。',
|
||||
'memories.noPhotos': '未找到照片',
|
||||
'memories.noPhotosHint': 'Immich 中未找到此旅行日期范围内的照片。',
|
||||
'memories.photosFound': '张照片',
|
||||
'memories.fromOthers': '来自他人',
|
||||
'memories.sharePhotos': '分享照片',
|
||||
'memories.sharing': '分享中',
|
||||
'memories.reviewTitle': '审查您的照片',
|
||||
'memories.reviewHint': '点击照片以将其从分享中排除。',
|
||||
'memories.shareCount': '分享 {count} 张照片',
|
||||
'memories.immichUrl': 'Immich 服务器地址',
|
||||
'memories.immichApiKey': 'API 密钥',
|
||||
'memories.testConnection': '测试连接',
|
||||
'memories.connected': '已连接',
|
||||
'memories.disconnected': '未连接',
|
||||
'memories.connectionSuccess': '已连接到 Immich',
|
||||
'memories.connectionError': '无法连接到 Immich',
|
||||
'memories.saved': 'Immich 设置已保存',
|
||||
'memories.oldest': '最早优先',
|
||||
'memories.newest': '最新优先',
|
||||
'memories.allLocations': '所有地点',
|
||||
'memories.addPhotos': '添加照片',
|
||||
'memories.selectPhotos': '从 Immich 选择照片',
|
||||
'memories.selectHint': '点击照片以选择。',
|
||||
'memories.selected': '已选择',
|
||||
'memories.addSelected': '添加 {count} 张照片',
|
||||
'memories.alreadyAdded': '已添加',
|
||||
'memories.private': '私密',
|
||||
'memories.stopSharing': '停止分享',
|
||||
'memories.tripDates': '旅行日期',
|
||||
'memories.allPhotos': '所有照片',
|
||||
'memories.confirmShareTitle': '与旅行成员分享?',
|
||||
'memories.confirmShareHint': '{count} 张照片将对本次旅行的所有成员可见。你可以稍后将单张照片设为私密。',
|
||||
'memories.confirmShareButton': '分享照片',
|
||||
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': '聊天',
|
||||
'collab.tabs.notes': '笔记',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { adminApi, authApi } from '../api/client'
|
||||
import apiClient, { adminApi, authApi, notificationsApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
@@ -13,6 +13,7 @@ import BackupPanel from '../components/Admin/BackupPanel'
|
||||
import GitHubPanel from '../components/Admin/GitHubPanel'
|
||||
import AddonManager from '../components/Admin/AddonManager'
|
||||
import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
|
||||
import AuditLogPanel from '../components/Admin/AuditLogPanel'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
|
||||
@@ -52,7 +53,7 @@ interface UpdateInfo {
|
||||
}
|
||||
|
||||
export default function AdminPage(): React.ReactElement {
|
||||
const { demoMode } = useAuthStore()
|
||||
const { demoMode, serverTimezone } = useAuthStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const TABS = [
|
||||
@@ -61,6 +62,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
{ id: 'addons', label: t('admin.tabs.addons') },
|
||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||
{ id: 'backup', label: t('admin.tabs.backup') },
|
||||
{ id: 'audit', label: t('admin.tabs.audit') },
|
||||
{ id: 'github', label: t('admin.tabs.github') },
|
||||
]
|
||||
|
||||
@@ -93,6 +95,16 @@ export default function AdminPage(): React.ReactElement {
|
||||
const [allowedFileTypes, setAllowedFileTypes] = useState<string>('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
|
||||
const [savingFileTypes, setSavingFileTypes] = useState<boolean>(false)
|
||||
|
||||
// SMTP settings
|
||||
const [smtpValues, setSmtpValues] = useState<Record<string, string>>({})
|
||||
const [smtpLoaded, setSmtpLoaded] = useState(false)
|
||||
useEffect(() => {
|
||||
apiClient.get('/auth/app-settings').then(r => {
|
||||
setSmtpValues(r.data || {})
|
||||
setSmtpLoaded(true)
|
||||
}).catch(() => setSmtpLoaded(true))
|
||||
}, [])
|
||||
|
||||
// API Keys
|
||||
const [mapsKey, setMapsKey] = useState<string>('')
|
||||
const [weatherKey, setWeatherKey] = useState<string>('')
|
||||
@@ -333,7 +345,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
<Shield className="w-5 h-5 text-slate-700" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Administration</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{t('admin.title')}</h1>
|
||||
<p className="text-slate-500 text-sm">{t('admin.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -512,10 +524,10 @@ export default function AdminPage(): React.ReactElement {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-500">
|
||||
{new Date(u.created_at).toLocaleDateString(locale)}
|
||||
{new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-500">
|
||||
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
|
||||
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
@@ -584,7 +596,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
</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)}`}
|
||||
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
|
||||
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
|
||||
</div>
|
||||
</div>
|
||||
@@ -918,11 +930,58 @@ export default function AdminPage(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* SMTP / Notifications */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.smtp.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-3">
|
||||
{smtpLoaded && [
|
||||
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
|
||||
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
|
||||
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
|
||||
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
|
||||
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
|
||||
{ key: 'notification_webhook_url', label: 'Webhook URL (optional)', placeholder: 'https://discord.com/api/webhooks/...' },
|
||||
{ key: 'app_url', label: 'App URL (for email links)', placeholder: 'https://trek.example.com' },
|
||||
].map(field => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
|
||||
<input
|
||||
type={field.type || 'text'}
|
||||
value={smtpValues[field.key] || ''}
|
||||
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
onBlur={e => { if (e.target.value !== '') authApi.updateAppSettings({ [field.key]: e.target.value }).then(() => toast.success(t('common.saved'))).catch(() => {}) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={async () => {
|
||||
for (const k of ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from']) {
|
||||
if (smtpValues[k]) await authApi.updateAppSettings({ [k]: smtpValues[k] }).catch(() => {})
|
||||
}
|
||||
try {
|
||||
const result = await notificationsApi.testSmtp()
|
||||
if (result.success) toast.success(t('admin.smtp.testSuccess'))
|
||||
else toast.error(result.error || t('admin.smtp.testFailed'))
|
||||
} catch { toast.error(t('admin.smtp.testFailed')) }
|
||||
}}
|
||||
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
{t('admin.smtp.testButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'backup' && <BackupPanel />}
|
||||
|
||||
{activeTab === 'audit' && <AuditLogPanel />}
|
||||
|
||||
{activeTab === 'github' && <GitHubPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+155
-19
@@ -3,9 +3,9 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import apiClient from '../api/client'
|
||||
import apiClient, { mapsApi } from '../api/client'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2 } from 'lucide-react'
|
||||
import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
|
||||
import L from 'leaflet'
|
||||
import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types'
|
||||
|
||||
@@ -154,14 +154,19 @@ export default function AtlasPage(): React.ReactElement {
|
||||
const [countryDetail, setCountryDetail] = useState<CountryDetail | 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())
|
||||
const [bucketMonth, setBucketMonth] = useState(0)
|
||||
const [bucketYear, setBucketYear] = useState(0)
|
||||
|
||||
// Bucket list
|
||||
interface BucketItem { id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null }
|
||||
interface BucketItem { id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null; target_date: string | null }
|
||||
const [bucketList, setBucketList] = useState<BucketItem[]>([])
|
||||
const [showBucketAdd, setShowBucketAdd] = useState(false)
|
||||
const [bucketForm, setBucketForm] = useState({ name: '', notes: '' })
|
||||
const [bucketForm, setBucketForm] = useState({ name: '', notes: '', lat: '', lng: '', target_date: '' })
|
||||
const [bucketSearch, setBucketSearch] = useState('')
|
||||
const [bucketSearchResults, setBucketSearchResults] = useState<any[]>([])
|
||||
const [bucketSearching, setBucketSearching] = useState(false)
|
||||
const [bucketPoiMonth, setBucketPoiMonth] = useState(0)
|
||||
const [bucketPoiYear, setBucketPoiYear] = useState(0)
|
||||
const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats')
|
||||
const bucketMarkersRef = useRef<any>(null)
|
||||
|
||||
@@ -179,7 +184,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
|
||||
// Load GeoJSON world data (direct GeoJSON, no conversion needed)
|
||||
useEffect(() => {
|
||||
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson')
|
||||
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson')
|
||||
.then(r => r.json())
|
||||
.then(geo => {
|
||||
// Dynamically build A2→A3 mapping from GeoJSON
|
||||
@@ -397,9 +402,15 @@ export default function AtlasPage(): React.ReactElement {
|
||||
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 })
|
||||
const data: Record<string, unknown> = { name: bucketForm.name.trim() }
|
||||
if (bucketForm.notes.trim()) data.notes = bucketForm.notes.trim()
|
||||
if (bucketForm.lat && bucketForm.lng) { data.lat = parseFloat(bucketForm.lat); data.lng = parseFloat(bucketForm.lng) }
|
||||
const targetDate = bucketForm.target_date || (bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null)
|
||||
if (targetDate) data.target_date = targetDate
|
||||
const r = await apiClient.post('/addons/atlas/bucket-list', data)
|
||||
setBucketList(prev => [r.data.item, ...prev])
|
||||
setBucketForm({ name: '', notes: '' })
|
||||
setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' })
|
||||
setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0)
|
||||
setShowBucketAdd(false)
|
||||
} catch { /* */ }
|
||||
}
|
||||
@@ -411,6 +422,28 @@ export default function AtlasPage(): React.ReactElement {
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const handleBucketPoiSearch = async () => {
|
||||
if (!bucketSearch.trim()) return
|
||||
setBucketSearching(true)
|
||||
try {
|
||||
const result = await mapsApi.search(bucketSearch, language)
|
||||
setBucketSearchResults(result.places || [])
|
||||
} catch {} finally { setBucketSearching(false) }
|
||||
}
|
||||
|
||||
const handleSelectBucketPoi = (result: any) => {
|
||||
const targetDate = bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null
|
||||
setBucketForm({
|
||||
name: result.name || bucketSearch,
|
||||
notes: '',
|
||||
lat: String(result.lat || ''),
|
||||
lng: String(result.lng || ''),
|
||||
target_date: targetDate || '',
|
||||
})
|
||||
setBucketSearchResults([])
|
||||
setBucketSearch('')
|
||||
}
|
||||
|
||||
// Render bucket list markers on map
|
||||
useEffect(() => {
|
||||
if (!mapInstance.current) return
|
||||
@@ -517,6 +550,10 @@ export default function AtlasPage(): React.ReactElement {
|
||||
showBucketAdd={showBucketAdd} setShowBucketAdd={setShowBucketAdd}
|
||||
bucketForm={bucketForm} setBucketForm={setBucketForm}
|
||||
onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem}
|
||||
onSearchBucket={handleBucketPoiSearch} onSelectBucketPoi={handleSelectBucketPoi}
|
||||
bucketSearchResults={bucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth}
|
||||
bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching}
|
||||
bucketSearch={bucketSearch} setBucketSearch={setBucketSearch}
|
||||
t={t} dark={dark}
|
||||
/>
|
||||
</div>
|
||||
@@ -594,7 +631,11 @@ export default function AtlasPage(): React.ReactElement {
|
||||
<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' }) }))}
|
||||
placeholder={t('atlas.month')}
|
||||
options={[
|
||||
{ value: 0, label: '—' },
|
||||
...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })),
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -602,22 +643,27 @@ export default function AtlasPage(): React.ReactElement {
|
||||
<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) }))}
|
||||
placeholder={t('atlas.year')}
|
||||
options={[
|
||||
{ value: 0, label: '—' },
|
||||
...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) })),
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'choose' })}
|
||||
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
<button onClick={async () => {
|
||||
const monthStr = new Date(bucketYear, bucketMonth - 1).toLocaleString(language, { month: 'short', year: 'numeric' })
|
||||
const targetDate = bucketMonth > 0 && bucketYear > 0 ? `${bucketYear}-${String(bucketMonth).padStart(2, '0')}` : null
|
||||
try {
|
||||
const r = await apiClient.post('/addons/atlas/bucket-list', { name: confirmAction.name, country_code: confirmAction.code, notes: monthStr })
|
||||
const r = await apiClient.post('/addons/atlas/bucket-list', { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate })
|
||||
setBucketList(prev => [r.data.item, ...prev])
|
||||
} catch {}
|
||||
setBucketMonth(0); setBucketYear(0)
|
||||
setConfirmAction(null)
|
||||
}}
|
||||
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#fbbf24', color: '#1a1a1a' }}>
|
||||
@@ -664,15 +710,26 @@ interface SidebarContentProps {
|
||||
setBucketTab: (tab: 'stats' | 'bucket') => void
|
||||
showBucketAdd: boolean
|
||||
setShowBucketAdd: (v: boolean) => void
|
||||
bucketForm: { name: string; notes: string }
|
||||
setBucketForm: (f: { name: string; notes: string }) => void
|
||||
bucketForm: { name: string; notes: string; lat: string; lng: string; target_date: string }
|
||||
setBucketForm: (f: { name: string; notes: string; lat: string; lng: string; target_date: string }) => void
|
||||
onAddBucket: () => Promise<void>
|
||||
onDeleteBucket: (id: number) => Promise<void>
|
||||
onSearchBucket: () => Promise<void>
|
||||
onSelectBucketPoi: (result: any) => void
|
||||
bucketSearchResults: any[]
|
||||
bucketPoiMonth: number
|
||||
setBucketPoiMonth: (v: number) => void
|
||||
bucketPoiYear: number
|
||||
setBucketPoiYear: (v: number) => void
|
||||
bucketSearching: boolean
|
||||
bucketSearch: string
|
||||
setBucketSearch: (v: string) => void
|
||||
t: TranslationFn
|
||||
dark: boolean
|
||||
}
|
||||
|
||||
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 {
|
||||
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
|
||||
const { language } = useTranslation()
|
||||
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
|
||||
const tp = dark ? '#f1f5f9' : '#0f172a'
|
||||
const tm = dark ? '#94a3b8' : '#64748b'
|
||||
@@ -722,6 +779,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
|
||||
// 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 }}>
|
||||
@@ -732,7 +790,12 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
) : <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>}
|
||||
{item.target_date && (() => {
|
||||
const [y, m] = item.target_date.split('-')
|
||||
const label = m ? new Date(Number(y), Number(m) - 1).toLocaleString(language, { month: 'short', year: 'numeric' }) : y
|
||||
return <span className="text-[9px] mt-0.5 text-center" style={{ color: tf }}>{label}</span>
|
||||
})()}
|
||||
{!item.target_date && item.notes && <span className="text-[9px] mt-0.5 text-center" style={{ color: tf, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.notes}</span>}
|
||||
<button onClick={() => onDeleteBucket(item.id)}
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
style={{ position: 'absolute', top: 4, right: 4, background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: tf, display: 'flex', transition: 'opacity 0.15s' }}>
|
||||
@@ -740,12 +803,85 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{bucketList.length === 0 && (
|
||||
{bucketList.length === 0 && !showBucketAdd && (
|
||||
<div className="flex items-center justify-center py-4 px-6" style={{ color: tf, fontSize: 12 }}>
|
||||
{t('atlas.bucketEmptyHint')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showBucketAdd ? (
|
||||
<div style={{ padding: '8px 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{/* Search or manual name */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={bucketForm.name || bucketSearch}
|
||||
onChange={e => { const v = e.target.value; if (bucketForm.name) setBucketForm({ ...bucketForm, name: v }); else setBucketSearch(v) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !bucketForm.name) onSearchBucket(); else if (e.key === 'Enter') onAddBucket(); if (e.key === 'Escape') setShowBucketAdd(false) }}
|
||||
placeholder={t('atlas.bucketNamePlaceholder')}
|
||||
autoFocus
|
||||
style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 12, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)' }}
|
||||
/>
|
||||
{!bucketForm.name && (
|
||||
<button onClick={onSearchBucket} disabled={bucketSearching}
|
||||
style={{ padding: '6px 10px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||||
<Search size={12} />
|
||||
</button>
|
||||
)}
|
||||
{bucketForm.name && (
|
||||
<button onClick={() => { setBucketForm({ ...bucketForm, name: '', lat: '', lng: '' }); setBucketSearch('') }}
|
||||
style={{ padding: '6px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{bucketSearchResults.length > 0 && (
|
||||
<div style={{ position: 'absolute', bottom: '100%', left: 0, right: 0, zIndex: 50, marginBottom: 4, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.12)', maxHeight: 160, overflowY: 'auto' }}>
|
||||
{bucketSearchResults.slice(0, 6).map((r, i) => (
|
||||
<button key={i} onClick={() => onSelectBucketPoi(r)} style={{ display: 'flex', flexDirection: 'column', gap: 1, width: '100%', padding: '6px 10px', border: 'none', background: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-primary)' }}>{r.name}</span>
|
||||
{r.address && <span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{r.address}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Selected place indicator */}
|
||||
{bucketForm.lat && bucketForm.lng && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<MapPin size={10} /> {Number(bucketForm.lat).toFixed(4)}, {Number(bucketForm.lng).toFixed(4)}
|
||||
</div>
|
||||
)}
|
||||
{/* Month / Year with CustomSelect */}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<CustomSelect value={bucketPoiMonth} onChange={v => setBucketPoiMonth(Number(v))} placeholder={t('atlas.month')} size="sm"
|
||||
options={[{ value: 0, label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<CustomSelect value={bucketPoiYear} onChange={v => setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm"
|
||||
options={[{ value: 0, label: '—' }, ...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) }))]} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => { setShowBucketAdd(false); setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' }); setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0) }}
|
||||
style={{ fontSize: 11, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={onAddBucket} disabled={!bucketForm.name.trim()}
|
||||
style={{ fontSize: 11, padding: '4px 12px', borderRadius: 6, border: 'none', background: '#fbbf24', color: '#1a1a1a', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: bucketForm.name.trim() ? 1 : 0.5 }}>
|
||||
{t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '4px 16px 8px' }}>
|
||||
<button onClick={() => setShowBucketAdd(true)}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, width: '100%', padding: '5px 0', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', fontSize: 11, color: tf, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={11} /> {t('atlas.addPoi')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -33,18 +33,12 @@ export default function LoginPage(): React.ReactElement {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
|
||||
if (config) {
|
||||
setAppConfig(config)
|
||||
if (!config.has_users) setMode('register')
|
||||
}
|
||||
})
|
||||
|
||||
// Handle query params (invite token, OIDC callback)
|
||||
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')
|
||||
const oidcCode = params.get('oidc_code')
|
||||
const oidcError = params.get('oidc_error')
|
||||
|
||||
if (invite) {
|
||||
setInviteToken(invite)
|
||||
setMode('register')
|
||||
@@ -54,26 +48,27 @@ export default function LoginPage(): React.ReactElement {
|
||||
setError('Invalid or expired invite link')
|
||||
})
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle OIDC callback via short-lived auth code (secure exchange)
|
||||
const oidcCode = params.get('oidc_code')
|
||||
const oidcError = params.get('oidc_error')
|
||||
if (oidcCode) {
|
||||
setIsLoading(true)
|
||||
window.history.replaceState({}, '', '/login')
|
||||
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode))
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.token) {
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
navigate('/dashboard')
|
||||
window.location.reload()
|
||||
navigate('/dashboard', { replace: true })
|
||||
} else {
|
||||
setError(data.error || 'OIDC login failed')
|
||||
}
|
||||
})
|
||||
.catch(() => setError('OIDC login failed'))
|
||||
.finally(() => setIsLoading(false))
|
||||
return
|
||||
}
|
||||
|
||||
if (oidcError) {
|
||||
const errorMessages: Record<string, string> = {
|
||||
registration_disabled: t('login.oidc.registrationDisabled'),
|
||||
@@ -83,8 +78,19 @@ export default function LoginPage(): React.ReactElement {
|
||||
}
|
||||
setError(errorMessages[oidcError] || oidcError)
|
||||
window.history.replaceState({}, '', '/login')
|
||||
return
|
||||
}
|
||||
}, [])
|
||||
|
||||
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
|
||||
if (config) {
|
||||
setAppConfig(config)
|
||||
if (!config.has_users) setMode('register')
|
||||
if (config.oidc_only_mode && config.oidc_configured && config.has_users) {
|
||||
window.location.href = '/api/auth/oidc/login'
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [navigate, t])
|
||||
|
||||
const handleDemoLogin = async (): Promise<void> => {
|
||||
setError('')
|
||||
@@ -490,7 +496,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<a href="/api/auth/oidc/login"
|
||||
<a href={`/api/auth/oidc/login${inviteToken ? '?invite=' + encodeURIComponent(inviteToken) : ''}`}
|
||||
style={{
|
||||
width: '100%', padding: '12px',
|
||||
background: '#111827', color: 'white',
|
||||
@@ -651,7 +657,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
<span style={{ fontSize: 12, color: '#9ca3af' }}>{t('common.or')}</span>
|
||||
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
||||
</div>
|
||||
<a href="/api/auth/oidc/login"
|
||||
<a href={`/api/auth/oidc/login${inviteToken ? '?invite=' + encodeURIComponent(inviteToken) : ''}`}
|
||||
style={{
|
||||
marginTop: 12, width: '100%', padding: '12px',
|
||||
background: 'white', color: '#374151',
|
||||
|
||||
@@ -7,7 +7,8 @@ import Navbar from '../components/Layout/Navbar'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound } from 'lucide-react'
|
||||
import { authApi, adminApi } from '../api/client'
|
||||
import { authApi, adminApi, notificationsApi } from '../api/client'
|
||||
import apiClient from '../api/client'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { UserWithOidc } from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
@@ -33,7 +34,7 @@ interface SectionProps {
|
||||
|
||||
function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', breakInside: 'avoid', marginBottom: 24 }}>
|
||||
<div className="px-6 py-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<Icon className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
||||
@@ -45,6 +46,60 @@ function Section({ title, icon: Icon, children }: SectionProps): React.ReactElem
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationPreferences({ t, memoriesEnabled }: { t: any; memoriesEnabled: boolean }) {
|
||||
const [prefs, setPrefs] = useState<Record<string, number> | null>(null)
|
||||
const [addons, setAddons] = useState<Record<string, boolean>>({})
|
||||
useEffect(() => { notificationsApi.getPreferences().then(d => setPrefs(d.preferences)).catch(() => {}) }, [])
|
||||
useEffect(() => {
|
||||
apiClient.get('/addons').then(r => {
|
||||
const map: Record<string, boolean> = {}
|
||||
for (const a of (r.data.addons || [])) map[a.id] = !!a.enabled
|
||||
setAddons(map)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const toggle = async (key: string) => {
|
||||
if (!prefs) return
|
||||
const newVal = prefs[key] ? 0 : 1
|
||||
setPrefs(prev => prev ? { ...prev, [key]: newVal } : prev)
|
||||
try { await notificationsApi.updatePreferences({ [key]: !!newVal }) } catch {}
|
||||
}
|
||||
|
||||
if (!prefs) return <p style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</p>
|
||||
|
||||
const options = [
|
||||
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
|
||||
{ key: 'notify_booking_change', label: t('settings.notifyBookingChange') },
|
||||
...(addons.vacay ? [{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') }] : []),
|
||||
...(memoriesEnabled ? [{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') }] : []),
|
||||
...(addons.collab ? [{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') }] : []),
|
||||
...(addons.documents ? [{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') }] : []),
|
||||
{ key: 'notify_webhook', label: t('settings.notifyWebhook') },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{options.map(opt => (
|
||||
<div key={opt.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{opt.label}</span>
|
||||
<button onClick={() => toggle(opt.key)}
|
||||
style={{
|
||||
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||
background: prefs[opt.key] ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||
transition: 'background 0.2s',
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, left: prefs[opt.key] ? 22 : 2,
|
||||
width: 20, height: 20, borderRadius: '50%', background: 'white',
|
||||
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsPage(): React.ReactElement {
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode } = useAuthStore()
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||
@@ -56,6 +111,59 @@ export default function SettingsPage(): React.ReactElement {
|
||||
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Immich
|
||||
const [memoriesEnabled, setMemoriesEnabled] = useState(false)
|
||||
const [immichUrl, setImmichUrl] = useState('')
|
||||
const [immichApiKey, setImmichApiKey] = useState('')
|
||||
const [immichConnected, setImmichConnected] = useState(false)
|
||||
const [immichTesting, setImmichTesting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.get('/addons').then(r => {
|
||||
const mem = r.data.addons?.find((a: any) => a.id === 'memories' && a.enabled)
|
||||
setMemoriesEnabled(!!mem)
|
||||
if (mem) {
|
||||
apiClient.get('/integrations/immich/settings').then(r2 => {
|
||||
setImmichUrl(r2.data.immich_url || '')
|
||||
setImmichConnected(r2.data.connected)
|
||||
}).catch(() => {})
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleSaveImmich = async () => {
|
||||
setSaving(s => ({ ...s, immich: true }))
|
||||
try {
|
||||
await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined })
|
||||
toast.success(t('memories.saved'))
|
||||
// Test connection
|
||||
const res = await apiClient.get('/integrations/immich/status')
|
||||
setImmichConnected(res.data.connected)
|
||||
} catch {
|
||||
toast.error(t('memories.connectionError'))
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, immich: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestImmich = async () => {
|
||||
setImmichTesting(true)
|
||||
try {
|
||||
const res = await apiClient.get('/integrations/immich/status')
|
||||
if (res.data.connected) {
|
||||
toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''}`)
|
||||
setImmichConnected(true)
|
||||
} else {
|
||||
toast.error(`${t('memories.connectionError')}: ${res.data.error}`)
|
||||
setImmichConnected(false)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('memories.connectionError'))
|
||||
} finally {
|
||||
setImmichTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Map settings
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
||||
@@ -166,12 +274,15 @@ export default function SettingsPage(): React.ReactElement {
|
||||
<Navbar />
|
||||
|
||||
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
|
||||
<div>
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<style>{`@media (max-width: 900px) { .settings-columns { column-count: 1 !important; } }`}</style>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>{t('settings.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-columns" style={{ columnCount: 2, columnGap: 24 }}>
|
||||
|
||||
{/* Map settings */}
|
||||
<Section title={t('settings.map')} icon={Map}>
|
||||
<div>
|
||||
@@ -385,8 +496,82 @@ export default function SettingsPage(): React.ReactElement {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.blurBookingCodes')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('blur_booking_codes', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: (!!settings.blur_booking_codes) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (!!settings.blur_booking_codes) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Notifications */}
|
||||
<Section title={t('settings.notifications')} icon={Lock}>
|
||||
<NotificationPreferences t={t} memoriesEnabled={memoriesEnabled} />
|
||||
</Section>
|
||||
|
||||
{/* Immich — only when Memories addon is enabled */}
|
||||
{memoriesEnabled && (
|
||||
<Section title="Immich" icon={Camera}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichUrl')}</label>
|
||||
<input type="url" value={immichUrl} onChange={e => setImmichUrl(e.target.value)}
|
||||
placeholder="https://immich.example.com"
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichApiKey')}</label>
|
||||
<input type="password" value={immichApiKey} onChange={e => setImmichApiKey(e.target.value)}
|
||||
placeholder={immichConnected ? '••••••••' : 'API Key'}
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={handleSaveImmich} disabled={saving.immich}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400">
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
<button onClick={handleTestImmich} disabled={immichTesting}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50">
|
||||
{immichTesting
|
||||
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
||||
: <Camera className="w-4 h-4" />}
|
||||
{t('memories.testConnection')}
|
||||
</button>
|
||||
{immichConnected && (
|
||||
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
{t('memories.connected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Account */}
|
||||
<Section title={t('settings.account')} icon={User}>
|
||||
<div>
|
||||
@@ -795,6 +980,7 @@ export default function SettingsPage(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { shareApi } from '../api/client'
|
||||
import { getCategoryIcon } from '../components/shared/categoryIcons'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||
|
||||
function createMarkerIcon(place: any) {
|
||||
const cat = place.category
|
||||
const color = cat?.color || '#6366f1'
|
||||
const CatIcon = getCategoryIcon(cat?.icon)
|
||||
const iconSvg = renderToStaticMarkup(createElement(CatIcon, { size: 14, strokeWidth: 2, color: 'white' }))
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
iconSize: [28, 28],
|
||||
iconAnchor: [14, 14],
|
||||
html: `<div style="width:28px;height:28px;border-radius:50%;background:${color};display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(0,0,0,0.3);border:2px solid white;">${iconSvg}</div>`,
|
||||
})
|
||||
}
|
||||
|
||||
function FitBoundsToPlaces({ places }: { places: any[] }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (places.length === 0) return
|
||||
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 })
|
||||
}, [places, map])
|
||||
return null
|
||||
}
|
||||
|
||||
export default function SharedTripPage() {
|
||||
const { token } = useParams<{ token: string }>()
|
||||
const { t, locale } = useTranslation()
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [selectedDay, setSelectedDay] = useState<number | null>(null)
|
||||
const [activeTab, setActiveTab] = useState('plan')
|
||||
const { updateSetting } = useSettingsStore()
|
||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
shareApi.getSharedTrip(token).then(setData).catch(() => setError(true))
|
||||
}, [token])
|
||||
|
||||
if (error) return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: '#f3f4f6' }}>
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>🔒</div>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, color: '#111827' }}>{t('shared.expired')}</h1>
|
||||
<p style={{ color: '#6b7280', marginTop: 8 }}>{t('shared.expiredHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!data) return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: '#f3f4f6' }}>
|
||||
<div style={{ width: 32, height: 32, border: '3px solid #e5e7eb', borderTopColor: '#111827', borderRadius: '50%', animation: 'spin 0.6s linear infinite' }} />
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
||||
</div>
|
||||
)
|
||||
|
||||
const { trip, days, assignments, dayNotes, places, reservations, accommodations, packing, budget, categories, permissions, collab } = data
|
||||
const sortedDays = [...(days || [])].sort((a: any, b: any) => a.day_number - b.day_number)
|
||||
|
||||
// Map places
|
||||
const mapPlaces = selectedDay
|
||||
? (assignments[String(selectedDay)] || []).map((a: any) => a.place).filter((p: any) => p?.lat && p?.lng)
|
||||
: (places || []).filter((p: any) => p?.lat && p?.lng)
|
||||
|
||||
const center = mapPlaces.length > 0 ? [mapPlaces[0].lat, mapPlaces[0].lng] : [48.85, 2.35]
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg-secondary, #f3f4f6)', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
{/* Header */}
|
||||
<div style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', color: 'white', padding: '32px 20px 28px', textAlign: 'center', position: 'relative' }}>
|
||||
{/* Cover image background */}
|
||||
{trip.cover_image && (
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(${trip.cover_image.startsWith('http') ? trip.cover_image : trip.cover_image.startsWith('/') ? trip.cover_image : '/uploads/' + trip.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
|
||||
)}
|
||||
{/* Background decoration */}
|
||||
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} />
|
||||
<div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
|
||||
|
||||
{/* Logo */}
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 44, height: 44, borderRadius: 12, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(8px)', marginBottom: 12, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<img src="/icons/icon-white.svg" alt="TREK" width="26" height="26" />
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: 3, textTransform: 'uppercase', opacity: 0.35, marginBottom: 12 }}>Travel Resource & Exploration Kit</div>
|
||||
|
||||
<h1 style={{ margin: '0 0 4px', fontSize: 26, fontWeight: 700, letterSpacing: -0.5 }}>{trip.title}</h1>
|
||||
|
||||
{trip.description && (
|
||||
<div style={{ fontSize: 13, opacity: 0.5, maxWidth: 400, margin: '0 auto', lineHeight: 1.5 }}>{trip.description}</div>
|
||||
)}
|
||||
|
||||
{(trip.start_date || trip.end_date) && (
|
||||
<div style={{ marginTop: 10, display: 'inline-flex', alignItems: 'center', gap: 8, padding: '6px 14px', borderRadius: 20, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(4px)', border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8 }}>
|
||||
{[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })).join(' — ')}
|
||||
</span>
|
||||
{days?.length > 0 && <span style={{ fontSize: 11, opacity: 0.4 }}>·</span>}
|
||||
{days?.length > 0 && <span style={{ fontSize: 11, opacity: 0.5 }}>{days.length} {t('shared.days')}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 12, fontSize: 9, fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase', opacity: 0.25 }}>{t('shared.readOnly')}</div>
|
||||
|
||||
{/* Language picker - top right */}
|
||||
<div style={{ position: 'absolute', top: 12, right: 12, zIndex: 10 }}>
|
||||
<button onClick={() => setShowLangPicker(v => !v)} style={{
|
||||
padding: '5px 12px', borderRadius: 20, border: '1px solid rgba(255,255,255,0.15)',
|
||||
background: 'rgba(255,255,255,0.1)', backdropFilter: 'blur(8px)',
|
||||
color: 'rgba(255,255,255,0.7)', fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
{SUPPORTED_LANGUAGES.find(l => l.value === (locale?.split('-')[0] || 'en'))?.label || 'Language'}
|
||||
</button>
|
||||
{showLangPicker && (
|
||||
<div style={{ position: 'absolute', top: '100%', right: 0, marginTop: 6, background: 'white', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.2)', padding: 4, zIndex: 50, minWidth: 150 }}>
|
||||
{SUPPORTED_LANGUAGES.map(lang => (
|
||||
<button key={lang.value} onClick={() => { updateSetting('language', lang.value); setShowLangPicker(false) }}
|
||||
style={{ display: 'block', width: '100%', padding: '6px 12px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 12, color: '#374151', borderRadius: 6, fontFamily: 'inherit' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#f3f4f6'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
>{lang.label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 900, margin: '0 auto', padding: '20px 16px' }}>
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 20, overflowX: 'auto', padding: '2px 0' }}>
|
||||
{[
|
||||
{ id: 'plan', label: t('shared.tabPlan'), Icon: Map },
|
||||
...(permissions?.share_bookings ? [{ id: 'bookings', label: t('shared.tabBookings'), Icon: Ticket }] : []),
|
||||
...(permissions?.share_packing ? [{ id: 'packing', label: t('shared.tabPacking'), Icon: Luggage }] : []),
|
||||
...(permissions?.share_budget ? [{ id: 'budget', label: t('shared.tabBudget'), Icon: Wallet }] : []),
|
||||
...(permissions?.share_collab ? [{ id: 'collab', label: t('shared.tabChat'), Icon: MessageCircle }] : []),
|
||||
].map(tab => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)} style={{
|
||||
padding: '8px 18px', borderRadius: 12, border: '1.5px solid', cursor: 'pointer',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', transition: 'all 0.15s', whiteSpace: 'nowrap',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: activeTab === tab.id ? '#111827' : 'var(--bg-card, white)',
|
||||
borderColor: activeTab === tab.id ? '#111827' : 'var(--border-faint, #e5e7eb)',
|
||||
color: activeTab === tab.id ? 'white' : '#6b7280',
|
||||
boxShadow: activeTab === tab.id ? '0 2px 8px rgba(0,0,0,0.15)' : '0 1px 3px rgba(0,0,0,0.04)',
|
||||
}}><tab.Icon size={13} /><span className="hidden sm:inline">{tab.label}</span></button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
{activeTab === 'plan' && (<>
|
||||
<div style={{ borderRadius: 16, overflow: 'hidden', height: 300, marginBottom: 20, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||
<MapContainer center={center as [number, number]} zoom={11} zoomControl={false} style={{ width: '100%', height: '100%' }}>
|
||||
<TileLayer url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" />
|
||||
<FitBoundsToPlaces places={mapPlaces} />
|
||||
{mapPlaces.map((p: any) => (
|
||||
<Marker key={p.id} position={[p.lat, p.lng]} icon={createMarkerIcon(p)}>
|
||||
<Tooltip>{p.name}</Tooltip>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
{/* Day Plan */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{sortedDays.map((day: any, di: number) => {
|
||||
const da = assignments[String(day.id)] || []
|
||||
const notes = (dayNotes[String(day.id)] || [])
|
||||
const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
|
||||
const dayAccs = (accommodations || []).filter((a: any) => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
||||
|
||||
const merged = [
|
||||
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
|
||||
...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
|
||||
...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
|
||||
].sort((a, b) => a.k - b.k)
|
||||
|
||||
return (
|
||||
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
|
||||
<div onClick={() => setSelectedDay(selectedDay === day.id ? null : day.id)}
|
||||
style={{ padding: '12px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: '50%', background: selectedDay === day.id ? '#111827' : '#f3f4f6', color: selectedDay === day.id ? 'white' : '#6b7280', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flexShrink: 0 }}>{di + 1}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: '#111827' }}>{day.title || `Day ${day.day_number}`}</div>
|
||||
{day.date && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>{new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>}
|
||||
</div>
|
||||
{dayAccs.map((acc: any) => (
|
||||
<span key={acc.id} style={{ fontSize: 9, padding: '2px 6px', borderRadius: 4, background: '#f3f4f6', color: '#6b7280', display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
<Hotel size={8} /> {acc.place_name}
|
||||
</span>
|
||||
))}
|
||||
<span style={{ fontSize: 11, color: '#9ca3af' }}>{da.length} {t('shared.places')}</span>
|
||||
</div>
|
||||
|
||||
{selectedDay === day.id && merged.length > 0 && (
|
||||
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{merged.map((item: any, idx: number) => {
|
||||
if (item.type === 'transport') {
|
||||
const r = item.data
|
||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||
let sub = ''
|
||||
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<div key={`t-${r.id}`} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, background: 'rgba(59,130,246,0.06)', border: '1px solid rgba(59,130,246,0.15)' }}>
|
||||
<div style={{ width: 24, height: 24, borderRadius: '50%', background: 'rgba(59,130,246,0.12)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<TIcon size={12} color="#3b82f6" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 500, color: '#111827' }}>{r.title}{time ? ` · ${time}` : ''}</div>
|
||||
{sub && <div style={{ fontSize: 10, color: '#6b7280' }}>{sub}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (item.type === 'note') {
|
||||
return (
|
||||
<div key={`n-${item.data.id}`} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 8px', borderRadius: 6, background: '#f9fafb', border: '1px solid #f3f4f6' }}>
|
||||
<FileText size={12} color="#9ca3af" />
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: '#374151' }}>{item.data.text}</div>
|
||||
{item.data.time && <div style={{ fontSize: 10, color: '#9ca3af' }}>{item.data.time}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const place = item.data.place
|
||||
if (!place) return null
|
||||
const cat = categories?.find((c: any) => c.id === place.category_id)
|
||||
return (
|
||||
<div key={`p-${item.data.id}`} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 8px', borderRadius: 6 }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: '50%', background: cat?.color || '#6366f1', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
{place.image_url ? <img src={place.image_url} style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} /> : <MapPin size={13} color="white" />}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 500, color: '#111827' }}>{place.name}</div>
|
||||
{(place.address || place.description) && <div style={{ fontSize: 10, color: '#9ca3af', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{place.address || place.description}</div>}
|
||||
</div>
|
||||
{place.place_time && <span style={{ fontSize: 10, color: '#6b7280', display: 'flex', alignItems: 'center', gap: 3, flexShrink: 0 }}><Clock size={9} />{place.place_time}{place.end_time ? ` – ${place.end_time}` : ''}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
{/* Bookings */}
|
||||
{activeTab === 'bookings' && (reservations || []).length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{(reservations || []).map((r: any) => {
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||
const date = r.reservation_time ? new Date(r.reservation_time.includes('T') ? r.reservation_time : r.reservation_time + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' }) : ''
|
||||
return (
|
||||
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<TIcon size={15} color="#6b7280" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#111827' }}>{r.title}</div>
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 2 }}>
|
||||
{date && <span>{date}</span>}
|
||||
{time && <span>{time}</span>}
|
||||
{r.location && <span>{r.location}</span>}
|
||||
{meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
|
||||
{meta.train_number && <span>{meta.train_number}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: 10, padding: '2px 8px', borderRadius: 20, fontWeight: 600, background: r.status === 'confirmed' ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)', color: r.status === 'confirmed' ? '#16a34a' : '#d97706' }}>
|
||||
{r.status === 'confirmed' ? t('shared.confirmed') : t('shared.pending')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packing */}
|
||||
{activeTab === 'packing' && (packing || []).length > 0 && (
|
||||
<div style={{ background: 'var(--bg-card, white)', borderRadius: 14, border: '1px solid var(--border-faint, #e5e7eb)', overflow: 'hidden' }}>
|
||||
{Object.entries((packing || []).reduce((g: any, i: any) => { const c = i.category || t('shared.other'); (g[c] = g[c] || []).push(i); return g }, {})).map(([cat, items]: [string, any]) => (
|
||||
<div key={cat}>
|
||||
<div style={{ padding: '8px 16px', background: '#f9fafb', fontSize: 11, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '1px solid #f3f4f6' }}>{cat}</div>
|
||||
{items.map((item: any) => (
|
||||
<div key={item.id} style={{ padding: '6px 16px', display: 'flex', alignItems: 'center', gap: 8, borderBottom: '1px solid #f9fafb' }}>
|
||||
<span style={{ fontSize: 13, color: item.checked ? '#9ca3af' : '#111827', textDecoration: item.checked ? 'line-through' : 'none' }}>{item.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Budget */}
|
||||
{activeTab === 'budget' && (budget || []).length > 0 && (() => {
|
||||
const grouped = (budget || []).reduce((g: any, i: any) => { const c = i.category || t('shared.other'); (g[c] = g[c] || []).push(i); return g }, {})
|
||||
const total = (budget || []).reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{/* Total card */}
|
||||
<div style={{ background: 'linear-gradient(135deg, #000 0%, #1a1a2e 100%)', borderRadius: 14, padding: '20px 24px', color: 'white' }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, letterSpacing: 1, textTransform: 'uppercase', opacity: 0.5 }}>{t('shared.totalBudget')}</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, marginTop: 4 }}>{total.toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || 'EUR'}</div>
|
||||
</div>
|
||||
{/* By category */}
|
||||
{Object.entries(grouped).map(([cat, items]: [string, any]) => (
|
||||
<div key={cat} style={{ background: 'var(--bg-card, white)', borderRadius: 12, border: '1px solid var(--border-faint, #e5e7eb)', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '10px 16px', background: '#f9fafb', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #f3f4f6' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{cat}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#6b7280' }}>{items.reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0).toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || ''}</span>
|
||||
</div>
|
||||
{items.map((item: any) => (
|
||||
<div key={item.id} style={{ padding: '8px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #fafafa' }}>
|
||||
<span style={{ fontSize: 13, color: '#111827' }}>{item.name}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#111827' }}>{item.total_price ? Number(item.total_price).toLocaleString(locale, { minimumFractionDigits: 2 }) : '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Collab Chat */}
|
||||
{activeTab === 'collab' && (collab || []).length > 0 && (
|
||||
<div style={{ background: 'var(--bg-card, white)', borderRadius: 14, border: '1px solid var(--border-faint, #e5e7eb)', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '12px 16px', background: '#f9fafb', borderBottom: '1px solid #f3f4f6', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<MessageCircle size={14} color="#6b7280" />
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{t('shared.tabChat')} · {(collab || []).length} {t('shared.messages')}</span>
|
||||
</div>
|
||||
<div style={{ maxHeight: 500, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{(collab || []).map((msg: any, i: number) => {
|
||||
const prevMsg = i > 0 ? collab[i - 1] : null
|
||||
const showDate = !prevMsg || new Date(msg.created_at).toDateString() !== new Date(prevMsg.created_at).toDateString()
|
||||
return (
|
||||
<div key={msg.id}>
|
||||
{showDate && (
|
||||
<div style={{ textAlign: 'center', margin: '8px 0', fontSize: 10, fontWeight: 600, color: '#9ca3af' }}>
|
||||
{new Date(msg.created_at).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700, color: '#6b7280', flexShrink: 0, overflow: 'hidden' }}>
|
||||
{msg.avatar ? <img src={`/uploads/avatars/${msg.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (msg.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#111827' }}>{msg.username}</span>
|
||||
<span style={{ fontSize: 10, color: '#9ca3af' }}>{new Date(msg.created_at).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#374151', marginTop: 3, lineHeight: 1.5, whiteSpace: 'pre-wrap' }}>{msg.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ textAlign: 'center', padding: '40px 0 20px' }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 16px', borderRadius: 20, background: 'var(--bg-card, white)', border: '1px solid var(--border-faint, #e5e7eb)', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
|
||||
<img src="/icons/icon.svg" alt="TREK" width="18" height="18" style={{ borderRadius: 4 }} />
|
||||
<span style={{ fontSize: 11, color: '#9ca3af' }}>{t('shared.sharedVia')} <strong style={{ color: '#6b7280' }}>TREK</strong></span>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: '#d1d5db' }}>Made with <span style={{ color: '#ef4444' }}>♥</span> by Maurice · <a href="https://github.com/mauriceboe/TREK" style={{ color: '#9ca3af', textDecoration: 'none' }}>GitHub</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import PlaceFormModal from '../components/Planner/PlaceFormModal'
|
||||
import TripFormModal from '../components/Trips/TripFormModal'
|
||||
import TripMembersModal from '../components/Trips/TripMembersModal'
|
||||
import { ReservationModal } from '../components/Planner/ReservationModal'
|
||||
import MemoriesPanel from '../components/Memories/MemoriesPanel'
|
||||
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||
import FileManager from '../components/Files/FileManager'
|
||||
@@ -54,7 +55,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
addonsApi.enabled().then(data => {
|
||||
const map = {}
|
||||
data.addons.forEach(a => { map[a.id] = true })
|
||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
|
||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: !!map.memories })
|
||||
}).catch(() => {})
|
||||
authApi.getAppConfig().then(config => {
|
||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||
@@ -67,6 +68,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
|
||||
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
|
||||
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
|
||||
...(enabledAddons.memories ? [{ id: 'memories', label: t('memories.title') }] : []),
|
||||
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name') }] : []),
|
||||
]
|
||||
|
||||
@@ -444,6 +446,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
accommodations={tripAccommodations}
|
||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||
/>
|
||||
{!leftCollapsed && (
|
||||
<div
|
||||
@@ -492,6 +495,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
)}
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', paddingLeft: 4 }}>
|
||||
<PlacesSidebar
|
||||
tripId={tripId}
|
||||
places={places}
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
@@ -540,6 +544,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
lng={geoPlace?.lng}
|
||||
onClose={() => setShowDayDetail(null)}
|
||||
onAccommodationChange={loadAccommodations}
|
||||
leftWidth={leftCollapsed ? 0 : leftWidth}
|
||||
rightWidth={rightCollapsed ? 0 : rightWidth}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
@@ -586,6 +592,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
} catch {}
|
||||
}}
|
||||
onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }}
|
||||
leftWidth={leftCollapsed ? 0 : leftWidth}
|
||||
rightWidth={rightCollapsed ? 0 : rightWidth}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -600,8 +608,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{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} />
|
||||
: <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} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -656,6 +664,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'memories' && (
|
||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||
<MemoriesPanel tripId={Number(tripId)} startDate={trip?.start_date || null} endDate={trip?.end_date || null} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'collab' && (
|
||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||
<CollabPanel tripId={tripId} tripMembers={tripMembers} />
|
||||
|
||||
@@ -23,6 +23,7 @@ interface AuthState {
|
||||
error: string | null
|
||||
demoMode: boolean
|
||||
hasMapsKey: boolean
|
||||
serverTimezone: string
|
||||
|
||||
login: (email: string, password: string) => Promise<LoginResult>
|
||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||
@@ -36,6 +37,7 @@ interface AuthState {
|
||||
deleteAvatar: () => Promise<void>
|
||||
setDemoMode: (val: boolean) => void
|
||||
setHasMapsKey: (val: boolean) => void
|
||||
setServerTimezone: (tz: string) => void
|
||||
demoLogin: () => Promise<AuthResponse>
|
||||
}
|
||||
|
||||
@@ -47,6 +49,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
error: null,
|
||||
demoMode: localStorage.getItem('demo_mode') === 'true',
|
||||
hasMapsKey: false,
|
||||
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
@@ -201,6 +204,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
},
|
||||
|
||||
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
|
||||
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
|
||||
|
||||
demoLogin: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
@@ -232,6 +232,11 @@ export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
|
||||
files: state.files.filter(f => f.id !== payload.fileId),
|
||||
}
|
||||
|
||||
// Memories / Photos
|
||||
case 'memories:updated':
|
||||
window.dispatchEvent(new CustomEvent('memories:updated', { detail: payload }))
|
||||
return {}
|
||||
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
|
||||
+14
-4
@@ -118,15 +118,22 @@ export interface Reservation {
|
||||
trip_id: number
|
||||
name: string
|
||||
title?: string
|
||||
type: string | null
|
||||
type: string
|
||||
status: 'pending' | 'confirmed'
|
||||
date: string | null
|
||||
time: string | null
|
||||
reservation_time?: string | null
|
||||
reservation_end_time?: string | null
|
||||
location?: string | null
|
||||
confirmation_number: string | null
|
||||
notes: string | null
|
||||
url: string | null
|
||||
day_id?: number | null
|
||||
place_id?: number | null
|
||||
assignment_id?: number | null
|
||||
accommodation_id?: number | null
|
||||
metadata?: Record<string, string> | null
|
||||
day_plan_position?: number | null
|
||||
metadata?: Record<string, string> | string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -148,6 +155,7 @@ export interface TripFile {
|
||||
deleted_at?: string | null
|
||||
created_at: string
|
||||
reservation_title?: string
|
||||
linked_reservation_ids?: number[]
|
||||
url?: string
|
||||
}
|
||||
|
||||
@@ -163,6 +171,7 @@ export interface Settings {
|
||||
time_format: string
|
||||
show_place_description: boolean
|
||||
route_calculation?: boolean
|
||||
blur_booking_codes?: boolean
|
||||
}
|
||||
|
||||
export interface AssignmentsMap {
|
||||
@@ -271,6 +280,7 @@ export interface AppConfig {
|
||||
oidc_display_name?: string
|
||||
has_maps_key?: boolean
|
||||
allowed_file_types?: string
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
// Translation function type
|
||||
@@ -361,7 +371,7 @@ export function getApiErrorMessage(err: unknown, fallback: string): string {
|
||||
|
||||
// MergedItem used in day notes hook
|
||||
export interface MergedItem {
|
||||
type: 'assignment' | 'note'
|
||||
type: 'assignment' | 'note' | 'place' | 'transport'
|
||||
sortKey: number
|
||||
data: Assignment | DayNote
|
||||
data: Assignment | DayNote | Reservation
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ export function currencyDecimals(currency: string): number {
|
||||
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string | null | undefined, locale: string): string | null {
|
||||
export function formatDate(dateStr: string | null | undefined, locale: string, timeZone?: string): string | null {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'short', day: 'numeric', month: 'short',
|
||||
})
|
||||
}
|
||||
if (timeZone) opts.timeZone = timeZone
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, opts)
|
||||
}
|
||||
|
||||
export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {
|
||||
|
||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
||||
navigateFallback: 'index.html',
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/uploads/],
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
services:
|
||||
init-permissions:
|
||||
image: alpine:3.20
|
||||
container_name: trek-init-permissions
|
||||
user: "0:0"
|
||||
command: >
|
||||
sh -c "mkdir -p /app/data /app/uploads &&
|
||||
chown -R 1000:1000 /app/data /app/uploads &&
|
||||
chmod -R u+rwX /app/data /app/uploads"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
restart: "no"
|
||||
|
||||
app:
|
||||
image: mauriceboe/trek:latest
|
||||
container_name: trek
|
||||
depends_on:
|
||||
init-permissions:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
@@ -9,6 +25,7 @@ services:
|
||||
- JWT_SECRET=${JWT_SECRET:-}
|
||||
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
|
||||
- PORT=3000
|
||||
- TZ=${TZ:-UTC}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
PORT=3001
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
NODE_ENV=development
|
||||
DEBUG=false
|
||||
|
||||
Generated
+23
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "2.6.2",
|
||||
"version": "2.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-server",
|
||||
"version": "2.6.2",
|
||||
"version": "2.7.0",
|
||||
"dependencies": {
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -19,6 +19,7 @@
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^8.0.4",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"tsx": "^4.21.0",
|
||||
@@ -37,6 +38,7 @@
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
@@ -653,6 +655,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
@@ -2520,6 +2532,15 @@
|
||||
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
|
||||
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --import tsx src/index.ts",
|
||||
@@ -17,9 +17,10 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^8.0.4",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"node-fetch": "^2.7.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"unzipper": "^0.12.3",
|
||||
@@ -36,6 +37,7 @@
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
|
||||
@@ -285,6 +285,115 @@ function runMigrations(db: Database.Database): void {
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
},
|
||||
() => {
|
||||
// Configurable weekend days
|
||||
try { db.exec("ALTER TABLE vacay_plans ADD COLUMN weekend_days TEXT DEFAULT '0,6'"); } catch {}
|
||||
},
|
||||
() => {
|
||||
// Immich integration
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN immich_url TEXT"); } catch {}
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN immich_api_key TEXT"); } catch {}
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS trip_photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
immich_asset_id TEXT NOT NULL,
|
||||
shared INTEGER NOT NULL DEFAULT 1,
|
||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(trip_id, user_id, immich_asset_id)
|
||||
)`);
|
||||
// Add memories addon
|
||||
try {
|
||||
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)").run('memories', 'Photos', 'trip', 'Image', 0, 7);
|
||||
} catch {}
|
||||
},
|
||||
() => {
|
||||
// Allow files to be linked to multiple reservations/assignments
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS file_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_id INTEGER NOT NULL REFERENCES trip_files(id) ON DELETE CASCADE,
|
||||
reservation_id INTEGER REFERENCES reservations(id) ON DELETE CASCADE,
|
||||
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE,
|
||||
place_id INTEGER REFERENCES places(id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(file_id, reservation_id),
|
||||
UNIQUE(file_id, assignment_id),
|
||||
UNIQUE(file_id, place_id)
|
||||
)`);
|
||||
},
|
||||
() => {
|
||||
// Add day_plan_position to reservations for persistent transport ordering in day timeline
|
||||
try { db.exec('ALTER TABLE reservations ADD COLUMN day_plan_position REAL DEFAULT NULL'); } catch {}
|
||||
},
|
||||
() => {
|
||||
// Add paid_by_user_id to budget_items for expense tracking / settlement
|
||||
try { db.exec('ALTER TABLE budget_items ADD COLUMN paid_by_user_id INTEGER REFERENCES users(id)'); } catch {}
|
||||
},
|
||||
() => {
|
||||
// Add target_date to bucket_list for optional visit planning
|
||||
try { db.exec('ALTER TABLE bucket_list ADD COLUMN target_date TEXT DEFAULT NULL'); } catch {}
|
||||
},
|
||||
() => {
|
||||
// Notification preferences per user
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
notify_trip_invite INTEGER DEFAULT 1,
|
||||
notify_booking_change INTEGER DEFAULT 1,
|
||||
notify_trip_reminder INTEGER DEFAULT 1,
|
||||
notify_vacay_invite INTEGER DEFAULT 1,
|
||||
notify_photos_shared INTEGER DEFAULT 1,
|
||||
notify_collab_message INTEGER DEFAULT 1,
|
||||
notify_packing_tagged INTEGER DEFAULT 1,
|
||||
notify_webhook INTEGER DEFAULT 0,
|
||||
UNIQUE(user_id)
|
||||
)`);
|
||||
},
|
||||
() => {
|
||||
// Add missing notification preference columns for existing tables
|
||||
try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_vacay_invite INTEGER DEFAULT 1'); } catch {}
|
||||
try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_photos_shared INTEGER DEFAULT 1'); } catch {}
|
||||
try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_collab_message INTEGER DEFAULT 1'); } catch {}
|
||||
try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_packing_tagged INTEGER DEFAULT 1'); } catch {}
|
||||
},
|
||||
() => {
|
||||
// Public share links for read-only trip access
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS share_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id),
|
||||
share_map INTEGER DEFAULT 1,
|
||||
share_bookings INTEGER DEFAULT 1,
|
||||
share_packing INTEGER DEFAULT 0,
|
||||
share_budget INTEGER DEFAULT 0,
|
||||
share_collab INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
},
|
||||
() => {
|
||||
// Add permission columns to share_tokens
|
||||
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_map INTEGER DEFAULT 1'); } catch {}
|
||||
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_bookings INTEGER DEFAULT 1'); } catch {}
|
||||
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_packing INTEGER DEFAULT 0'); } catch {}
|
||||
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_budget INTEGER DEFAULT 0'); } catch {}
|
||||
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_collab INTEGER DEFAULT 0'); } catch {}
|
||||
},
|
||||
() => {
|
||||
// Audit log
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource TEXT,
|
||||
details TEXT,
|
||||
ip TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -380,6 +380,17 @@ function createTables(db: Database.Database): void {
|
||||
UNIQUE(assignment_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource TEXT,
|
||||
details TEXT,
|
||||
ip TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
+52
-4
@@ -6,6 +6,7 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const app = express();
|
||||
const DEBUG = String(process.env.DEBUG || 'false').toLowerCase() === 'true';
|
||||
|
||||
// Trust first proxy (nginx/Docker) for correct req.ip
|
||||
if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
|
||||
@@ -79,10 +80,40 @@ if (shouldForceHttps) {
|
||||
app.use(express.json({ limit: '100kb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Avatars are public (shown on login, sharing screens)
|
||||
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
||||
if (DEBUG) {
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
const startedAt = Date.now();
|
||||
const requestId = Math.random().toString(36).slice(2, 10);
|
||||
const redact = (value: unknown): unknown => {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return value.map(redact);
|
||||
const hidden = new Set(['password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code']);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = hidden.has(k.toLowerCase()) ? '[REDACTED]' : redact(v);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// All other uploads require authentication
|
||||
const safeQuery = redact(req.query);
|
||||
const safeBody = redact(req.body);
|
||||
console.log(`[DEBUG][REQ ${requestId}] ${req.method} ${req.originalUrl} ip=${req.ip} query=${JSON.stringify(safeQuery)} body=${JSON.stringify(safeBody)}`);
|
||||
|
||||
res.on('finish', () => {
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
console.log(`[DEBUG][RES ${requestId}] ${req.method} ${req.originalUrl} status=${res.statusCode} elapsed_ms=${elapsedMs}`);
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// Avatars are public (shown on login, sharing screens)
|
||||
import { authenticate } from './middleware/auth';
|
||||
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
||||
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
|
||||
|
||||
// Serve uploaded files (UUIDs are unguessable, path traversal protected)
|
||||
app.get('/uploads/:type/:filename', (req: Request, res: Response) => {
|
||||
const { type, filename } = req.params;
|
||||
const allowedTypes = ['covers', 'files', 'photos'];
|
||||
@@ -152,17 +183,33 @@ import vacayRoutes from './routes/vacay';
|
||||
app.use('/api/addons/vacay', vacayRoutes);
|
||||
import atlasRoutes from './routes/atlas';
|
||||
app.use('/api/addons/atlas', atlasRoutes);
|
||||
import immichRoutes from './routes/immich';
|
||||
app.use('/api/integrations/immich', immichRoutes);
|
||||
|
||||
app.use('/api/maps', mapsRoutes);
|
||||
app.use('/api/weather', weatherRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/backup', backupRoutes);
|
||||
|
||||
import notificationRoutes from './routes/notifications';
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
|
||||
import shareRoutes from './routes/share';
|
||||
app.use('/api', shareRoutes);
|
||||
|
||||
// Serve static files in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const publicPath = path.join(__dirname, '../public');
|
||||
app.use(express.static(publicPath));
|
||||
app.use(express.static(publicPath, {
|
||||
setHeaders: (res, filePath) => {
|
||||
// Never cache index.html so version updates are picked up immediately
|
||||
if (filePath.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
}
|
||||
},
|
||||
}));
|
||||
app.get('*', (req: Request, res: Response) => {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.sendFile(path.join(publicPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
@@ -179,6 +226,7 @@ const PORT = process.env.PORT || 3001;
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`TREK API running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
console.log(`Debug logs: ${DEBUG ? 'ENABLED' : 'disabled'}`);
|
||||
if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED');
|
||||
if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') {
|
||||
console.warn('[SECURITY WARNING] DEMO_MODE is enabled in production! Demo credentials are publicly exposed.');
|
||||
|
||||
+164
-13
@@ -7,11 +7,17 @@ import fs from 'fs';
|
||||
import { db } from '../db/database';
|
||||
import { authenticate, adminOnly } from '../middleware/auth';
|
||||
import { AuthRequest, User, Addon } from '../types';
|
||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate, adminOnly);
|
||||
|
||||
function utcSuffix(ts: string | null | undefined): string | null {
|
||||
if (!ts) return null;
|
||||
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
|
||||
router.get('/users', (req: Request, res: Response) => {
|
||||
const users = db.prepare(
|
||||
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
|
||||
@@ -21,7 +27,13 @@ router.get('/users', (req: Request, res: Response) => {
|
||||
const { getOnlineUserIds } = require('../websocket');
|
||||
onlineUserIds = getOnlineUserIds();
|
||||
} catch { /* */ }
|
||||
const usersWithStatus = users.map(u => ({ ...u, online: onlineUserIds.has(u.id) }));
|
||||
const usersWithStatus = users.map(u => ({
|
||||
...u,
|
||||
created_at: utcSuffix(u.created_at),
|
||||
updated_at: utcSuffix(u.updated_at as string),
|
||||
last_login: utcSuffix(u.last_login),
|
||||
online: onlineUserIds.has(u.id),
|
||||
}));
|
||||
res.json({ users: usersWithStatus });
|
||||
});
|
||||
|
||||
@@ -52,6 +64,14 @@ router.post('/users', (req: Request, res: Response) => {
|
||||
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
|
||||
).get(result.lastInsertRowid);
|
||||
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.user_create',
|
||||
resource: String(result.lastInsertRowid),
|
||||
ip: getClientIp(req),
|
||||
details: { username: username.trim(), email: email.trim(), role: role || 'user' },
|
||||
});
|
||||
res.status(201).json({ user });
|
||||
});
|
||||
|
||||
@@ -90,6 +110,19 @@ router.put('/users/:id', (req: Request, res: Response) => {
|
||||
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
|
||||
).get(req.params.id);
|
||||
|
||||
const authReq = req as AuthRequest;
|
||||
const changed: string[] = [];
|
||||
if (username) changed.push('username');
|
||||
if (email) changed.push('email');
|
||||
if (role) changed.push('role');
|
||||
if (password) changed.push('password');
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.user_update',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
details: { fields: changed },
|
||||
});
|
||||
res.json({ user: updated });
|
||||
});
|
||||
|
||||
@@ -103,6 +136,12 @@ router.delete('/users/:id', (req: Request, res: Response) => {
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.user_delete',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -115,6 +154,48 @@ router.get('/stats', (_req: Request, res: Response) => {
|
||||
res.json({ totalUsers, totalTrips, totalPlaces, totalFiles });
|
||||
});
|
||||
|
||||
router.get('/audit-log', (req: Request, res: Response) => {
|
||||
const limitRaw = parseInt(String(req.query.limit || '100'), 10);
|
||||
const offsetRaw = parseInt(String(req.query.offset || '0'), 10);
|
||||
const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500);
|
||||
const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0);
|
||||
type Row = {
|
||||
id: number;
|
||||
created_at: string;
|
||||
user_id: number | null;
|
||||
username: string | null;
|
||||
user_email: string | null;
|
||||
action: string;
|
||||
resource: string | null;
|
||||
details: string | null;
|
||||
ip: string | null;
|
||||
};
|
||||
const rows = db.prepare(`
|
||||
SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.user_id
|
||||
ORDER BY a.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(limit, offset) as Row[];
|
||||
const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c;
|
||||
res.json({
|
||||
entries: rows.map((r) => {
|
||||
let details: Record<string, unknown> | null = null;
|
||||
if (r.details) {
|
||||
try {
|
||||
details = JSON.parse(r.details) as Record<string, unknown>;
|
||||
} catch {
|
||||
details = { _parse_error: true };
|
||||
}
|
||||
}
|
||||
return { ...r, details };
|
||||
}),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/oidc', (_req: Request, res: Response) => {
|
||||
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || '';
|
||||
const secret = get('oidc_client_secret');
|
||||
@@ -135,16 +216,25 @@ router.put('/oidc', (req: Request, res: Response) => {
|
||||
if (client_secret !== undefined) set('oidc_client_secret', client_secret);
|
||||
set('oidc_display_name', display_name);
|
||||
set('oidc_only', oidc_only ? 'true' : 'false');
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.oidc_update',
|
||||
ip: getClientIp(req),
|
||||
details: { oidc_only: !!oidc_only, issuer_set: !!issuer },
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/save-demo-baseline', (_req: Request, res: Response) => {
|
||||
router.post('/save-demo-baseline', (req: Request, res: Response) => {
|
||||
if (process.env.DEMO_MODE !== 'true') {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
try {
|
||||
const { saveBaseline } = require('../demo/demo-reset');
|
||||
saveBaseline();
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
|
||||
res.json({ success: true, message: 'Demo baseline saved. Hourly resets will restore to this state.' });
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
@@ -169,11 +259,26 @@ function compareVersions(a: string, b: string): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
router.get('/github-releases', async (req: Request, res: Response) => {
|
||||
const { per_page = '10', page = '1' } = req.query;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`https://api.github.com/repos/mauriceboe/TREK/releases?per_page=${per_page}&page=${page}`,
|
||||
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
|
||||
);
|
||||
if (!resp.ok) return res.json([]);
|
||||
const data = await resp.json();
|
||||
res.json(Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
res.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/version-check', async (_req: Request, res: Response) => {
|
||||
const { version: currentVersion } = require('../../package.json');
|
||||
try {
|
||||
const resp = await fetch(
|
||||
'https://api.github.com/repos/mauriceboe/NOMAD/releases/latest',
|
||||
'https://api.github.com/repos/mauriceboe/TREK/releases/latest',
|
||||
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
|
||||
);
|
||||
if (!resp.ok) return res.json({ current: currentVersion, latest: currentVersion, update_available: false });
|
||||
@@ -186,7 +291,7 @@ router.get('/version-check', async (_req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/update', async (_req: Request, res: Response) => {
|
||||
router.post('/update', async (req: Request, res: Response) => {
|
||||
const rootDir = path.resolve(__dirname, '../../..');
|
||||
const serverDir = path.resolve(__dirname, '../..');
|
||||
const clientDir = path.join(rootDir, 'client');
|
||||
@@ -209,6 +314,13 @@ router.post('/update', async (_req: Request, res: Response) => {
|
||||
const { version: newVersion } = require('../../package.json');
|
||||
steps.push({ step: 'version', version: newVersion });
|
||||
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.system_update',
|
||||
resource: newVersion,
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true, steps, restarting: true });
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -245,24 +357,39 @@ router.post('/invites', (req: Request, res: Response) => {
|
||||
? new Date(Date.now() + parseInt(expires_in_days) * 86400000).toISOString()
|
||||
: null;
|
||||
|
||||
db.prepare(
|
||||
const ins = db.prepare(
|
||||
'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)'
|
||||
).run(token, uses, expiresAt, authReq.user.id);
|
||||
|
||||
const inviteId = Number(ins.lastInsertRowid);
|
||||
const invite = db.prepare(`
|
||||
SELECT i.*, u.username as created_by_name
|
||||
FROM invite_tokens i
|
||||
JOIN users u ON i.created_by = u.id
|
||||
WHERE i.id = last_insert_rowid()
|
||||
`).get();
|
||||
WHERE i.id = ?
|
||||
`).get(inviteId);
|
||||
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.invite_create',
|
||||
resource: String(inviteId),
|
||||
ip: getClientIp(req),
|
||||
details: { max_uses: uses, expires_in_days: expires_in_days ?? null },
|
||||
});
|
||||
res.status(201).json({ invite });
|
||||
});
|
||||
|
||||
router.delete('/invites/:id', (_req: Request, res: Response) => {
|
||||
const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(_req.params.id);
|
||||
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);
|
||||
db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(req.params.id);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.invite_delete',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -276,6 +403,13 @@ router.get('/bag-tracking', (_req: Request, res: Response) => {
|
||||
router.put('/bag-tracking', (req: Request, res: Response) => {
|
||||
const { enabled } = req.body;
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false');
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.bag_tracking',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: !!enabled },
|
||||
});
|
||||
res.json({ enabled: !!enabled });
|
||||
});
|
||||
|
||||
@@ -322,10 +456,19 @@ router.put('/packing-templates/:id', (req: Request, res: Response) => {
|
||||
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);
|
||||
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);
|
||||
db.prepare('DELETE FROM packing_templates WHERE id = ?').run(req.params.id);
|
||||
const authReq = req as AuthRequest;
|
||||
const t = template as { name?: string };
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.packing_template_delete',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
details: { name: t.name },
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -393,6 +536,14 @@ router.put('/addons/:id', (req: Request, res: Response) => {
|
||||
if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id);
|
||||
if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id);
|
||||
const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon;
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.addon_update',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: enabled !== undefined ? !!enabled : undefined, config_changed: config !== undefined },
|
||||
});
|
||||
res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } });
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ router.use(authenticate);
|
||||
const COUNTRY_BOXES: Record<string, [number, number, number, number]> = {
|
||||
AF:[60.5,29.4,75,38.5],AL:[19,39.6,21.1,42.7],DZ:[-8.7,19,12,37.1],AD:[1.4,42.4,1.8,42.7],AO:[11.7,-18.1,24.1,-4.4],
|
||||
AR:[-73.6,-55.1,-53.6,-21.8],AM:[43.4,38.8,46.6,41.3],AU:[112.9,-43.6,153.6,-10.7],AT:[9.5,46.4,17.2,49],AZ:[44.8,38.4,50.4,41.9],
|
||||
BR:[-73.9,-33.8,-34.8,5.3],BE:[2.5,49.5,6.4,51.5],BG:[22.4,41.2,28.6,44.2],CA:[-141,41.7,-52.6,83.1],CL:[-75.6,-55.9,-66.9,-17.5],
|
||||
BD:[88.0,20.7,92.7,26.6],BR:[-73.9,-33.8,-34.8,5.3],BE:[2.5,49.5,6.4,51.5],BG:[22.4,41.2,28.6,44.2],CA:[-141,41.7,-52.6,83.1],CL:[-75.6,-55.9,-66.9,-17.5],
|
||||
CN:[73.6,18.2,134.8,53.6],CO:[-79.1,-4.3,-66.9,12.5],HR:[13.5,42.4,19.5,46.6],CZ:[12.1,48.6,18.9,51.1],DK:[8,54.6,15.2,57.8],
|
||||
EG:[24.7,22,37,31.7],EE:[21.8,57.5,28.2,59.7],FI:[20.6,59.8,31.6,70.1],FR:[-5.1,41.3,9.6,51.1],DE:[5.9,47.3,15.1,55.1],
|
||||
GR:[19.4,34.8,29.7,41.8],HU:[16,45.7,22.9,48.6],IS:[-24.5,63.4,-13.5,66.6],IN:[68.2,6.7,97.4,35.5],ID:[95.3,-11,141,5.9],
|
||||
@@ -96,7 +96,10 @@ router.get('/stats', (req: Request, res: Response) => {
|
||||
|
||||
const tripIds = trips.map(t => t.id);
|
||||
if (tripIds.length === 0) {
|
||||
return res.json({ countries: [], trips: [], stats: { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 } });
|
||||
// Still include manually marked countries even without trips
|
||||
const manualCountries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(userId) as { country_code: string }[];
|
||||
const countries = manualCountries.map(mc => ({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }));
|
||||
return res.json({ countries, trips: [], stats: { totalTrips: 0, totalPlaces: 0, totalCountries: countries.length, totalDays: 0 } });
|
||||
}
|
||||
|
||||
const placeholders = tripIds.map(() => '?').join(',');
|
||||
@@ -274,10 +277,10 @@ router.get('/bucket-list', (req: Request, res: Response) => {
|
||||
|
||||
router.post('/bucket-list', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { name, lat, lng, country_code, notes } = req.body;
|
||||
const { name, lat, lng, country_code, notes, target_date } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
|
||||
const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes) VALUES (?, ?, ?, ?, ?, ?)').run(
|
||||
authReq.user.id, name.trim(), lat ?? null, lng ?? null, country_code ?? null, notes ?? null
|
||||
const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
|
||||
authReq.user.id, name.trim(), lat ?? null, lng ?? null, country_code ?? null, notes ?? null, target_date ?? null
|
||||
);
|
||||
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json({ item });
|
||||
@@ -285,10 +288,25 @@ router.post('/bucket-list', (req: Request, res: Response) => {
|
||||
|
||||
router.put('/bucket-list/:id', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { name, notes } = req.body;
|
||||
const { name, notes, lat, lng, country_code, target_date } = req.body;
|
||||
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(req.params.id, authReq.user.id);
|
||||
if (!item) return res.status(404).json({ error: 'Item not found' });
|
||||
db.prepare('UPDATE bucket_list SET name = COALESCE(?, name), notes = COALESCE(?, notes) WHERE id = ?').run(name?.trim() || null, notes ?? null, req.params.id);
|
||||
db.prepare(`UPDATE bucket_list SET
|
||||
name = COALESCE(?, name),
|
||||
notes = CASE WHEN ? THEN ? ELSE notes END,
|
||||
lat = CASE WHEN ? THEN ? ELSE lat END,
|
||||
lng = CASE WHEN ? THEN ? ELSE lng END,
|
||||
country_code = CASE WHEN ? THEN ? ELSE country_code END,
|
||||
target_date = CASE WHEN ? THEN ? ELSE target_date END
|
||||
WHERE id = ?`).run(
|
||||
name?.trim() || null,
|
||||
notes !== undefined ? 1 : 0, notes !== undefined ? (notes || null) : null,
|
||||
lat !== undefined ? 1 : 0, lat !== undefined ? (lat || null) : null,
|
||||
lng !== undefined ? 1 : 0, lng !== undefined ? (lng || null) : null,
|
||||
country_code !== undefined ? 1 : 0, country_code !== undefined ? (country_code || null) : null,
|
||||
target_date !== undefined ? 1 : 0, target_date !== undefined ? (target_date || null) : null,
|
||||
req.params.id
|
||||
);
|
||||
res.json({ item: db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(req.params.id) });
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { authenticate, demoUploadBlock } from '../middleware/auth';
|
||||
import { JWT_SECRET } from '../config';
|
||||
import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto';
|
||||
import { AuthRequest, User } from '../types';
|
||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||
|
||||
authenticator.options = { window: 1 };
|
||||
|
||||
@@ -28,6 +29,11 @@ function getPendingMfaSecret(userId: number): string | null {
|
||||
return row.secret;
|
||||
}
|
||||
|
||||
function utcSuffix(ts: string | null | undefined): string | null {
|
||||
if (!ts) return null;
|
||||
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
|
||||
function stripUserForClient(user: User): Record<string, unknown> {
|
||||
const {
|
||||
password_hash: _p,
|
||||
@@ -39,6 +45,9 @@ function stripUserForClient(user: User): Record<string, unknown> {
|
||||
} = user;
|
||||
return {
|
||||
...rest,
|
||||
created_at: utcSuffix(rest.created_at),
|
||||
updated_at: utcSuffix(rest.updated_at),
|
||||
last_login: utcSuffix(rest.last_login),
|
||||
mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true),
|
||||
};
|
||||
}
|
||||
@@ -146,6 +155,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
||||
demo_mode: isDemo,
|
||||
demo_email: isDemo ? 'demo@trek.app' : undefined,
|
||||
demo_password: isDemo ? 'demo12345' : undefined,
|
||||
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,7 +189,7 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
|
||||
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.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) return res.status(410).json({ error: 'Invite link has been fully used' });
|
||||
if (validInvite.expires_at && new Date(validInvite.expires_at) < new Date()) return res.status(410).json({ error: 'Invite link has expired' });
|
||||
}
|
||||
|
||||
@@ -506,18 +516,43 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
const ADMIN_SETTINGS_KEYS = ['allow_registration', 'allowed_file_types', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'notification_webhook_url', 'app_url'];
|
||||
|
||||
router.get('/app-settings', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
|
||||
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const key of ADMIN_SETTINGS_KEYS) {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined;
|
||||
if (row) result[key] = key === 'smtp_pass' ? '••••••••' : row.value;
|
||||
}
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.put('/app-settings', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
|
||||
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||
|
||||
const { allow_registration, allowed_file_types } = req.body;
|
||||
if (allow_registration !== undefined) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration));
|
||||
}
|
||||
if (allowed_file_types !== undefined) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', ?)").run(String(allowed_file_types));
|
||||
for (const key of ADMIN_SETTINGS_KEYS) {
|
||||
if (req.body[key] !== undefined) {
|
||||
const val = String(req.body[key]);
|
||||
// Don't save masked password
|
||||
if (key === 'smtp_pass' && val === '••••••••') continue;
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
|
||||
}
|
||||
}
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'settings.app_update',
|
||||
ip: getClientIp(req),
|
||||
details: {
|
||||
allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : undefined,
|
||||
allowed_file_types_changed: allowed_file_types !== undefined,
|
||||
},
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -673,6 +708,7 @@ router.post('/mfa/enable', authenticate, (req: Request, res: Response) => {
|
||||
authReq.user.id
|
||||
);
|
||||
mfaSetupPending.delete(authReq.user.id);
|
||||
writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
|
||||
res.json({ success: true, mfa_enabled: true });
|
||||
});
|
||||
|
||||
@@ -702,6 +738,7 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re
|
||||
authReq.user.id
|
||||
);
|
||||
mfaSetupPending.delete(authReq.user.id);
|
||||
writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
|
||||
res.json({ success: true, mfa_enabled: false });
|
||||
});
|
||||
|
||||
|
||||
+69
-15
@@ -7,6 +7,10 @@ import fs from 'fs';
|
||||
import { authenticate, adminOnly } from '../middleware/auth';
|
||||
import * as scheduler from '../scheduler';
|
||||
import { db, closeDb, reinitialize } from '../db/database';
|
||||
import { AuthRequest } from '../types';
|
||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||
|
||||
type RestoreAuditInfo = { userId: number; ip: string | null; source: 'backup.restore' | 'backup.upload_restore'; label: string };
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -103,6 +107,14 @@ router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (_req: Re
|
||||
});
|
||||
|
||||
const stat = fs.statSync(outputPath);
|
||||
const authReq = _req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'backup.create',
|
||||
resource: filename,
|
||||
ip: getClientIp(_req),
|
||||
details: { size: stat.size },
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
backup: {
|
||||
@@ -134,7 +146,7 @@ router.get('/download/:filename', (req: Request, res: Response) => {
|
||||
res.download(filePath, filename);
|
||||
});
|
||||
|
||||
async function restoreFromZip(zipPath: string, res: Response) {
|
||||
async function restoreFromZip(zipPath: string, res: Response, audit?: RestoreAuditInfo) {
|
||||
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
|
||||
try {
|
||||
await fs.createReadStream(zipPath)
|
||||
@@ -174,6 +186,14 @@ async function restoreFromZip(zipPath: string, res: Response) {
|
||||
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
|
||||
if (audit) {
|
||||
writeAudit({
|
||||
userId: audit.userId,
|
||||
action: audit.source,
|
||||
resource: audit.label,
|
||||
ip: audit.ip,
|
||||
});
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
console.error('Restore error:', err);
|
||||
@@ -191,7 +211,13 @@ router.post('/restore/:filename', async (req: Request, res: Response) => {
|
||||
if (!fs.existsSync(zipPath)) {
|
||||
return res.status(404).json({ error: 'Backup not found' });
|
||||
}
|
||||
await restoreFromZip(zipPath, res);
|
||||
const authReq = req as AuthRequest;
|
||||
await restoreFromZip(zipPath, res, {
|
||||
userId: authReq.user.id,
|
||||
ip: getClientIp(req),
|
||||
source: 'backup.restore',
|
||||
label: filename,
|
||||
});
|
||||
});
|
||||
|
||||
const uploadTmp = multer({
|
||||
@@ -206,23 +232,43 @@ const uploadTmp = multer({
|
||||
router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
const zipPath = req.file.path;
|
||||
await restoreFromZip(zipPath, res);
|
||||
const authReq = req as AuthRequest;
|
||||
const origName = req.file.originalname || 'upload.zip';
|
||||
await restoreFromZip(zipPath, res, {
|
||||
userId: authReq.user.id,
|
||||
ip: getClientIp(req),
|
||||
source: 'backup.upload_restore',
|
||||
label: origName,
|
||||
});
|
||||
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
|
||||
});
|
||||
|
||||
router.get('/auto-settings', (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({ settings: scheduler.loadSettings() });
|
||||
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
res.json({ settings: scheduler.loadSettings(), timezone: tz });
|
||||
} catch (err: unknown) {
|
||||
console.error('[backup] GET auto-settings:', err);
|
||||
res.status(500).json({ error: 'Could not load backup settings' });
|
||||
}
|
||||
});
|
||||
|
||||
function parseIntField(raw: unknown, fallback: number): number {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
|
||||
if (typeof raw === 'string' && raw.trim() !== '') {
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseAutoBackupBody(body: Record<string, unknown>): {
|
||||
enabled: boolean;
|
||||
interval: string;
|
||||
keep_days: number;
|
||||
hour: number;
|
||||
day_of_week: number;
|
||||
day_of_month: number;
|
||||
} {
|
||||
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
|
||||
const rawInterval = body.interval;
|
||||
@@ -230,17 +276,11 @@ function parseAutoBackupBody(body: Record<string, unknown>): {
|
||||
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval)
|
||||
? rawInterval
|
||||
: 'daily';
|
||||
const rawKeep = body.keep_days;
|
||||
let keepNum: number;
|
||||
if (typeof rawKeep === 'number' && Number.isFinite(rawKeep)) {
|
||||
keepNum = Math.floor(rawKeep);
|
||||
} else if (typeof rawKeep === 'string' && rawKeep.trim() !== '') {
|
||||
keepNum = parseInt(rawKeep, 10);
|
||||
} else {
|
||||
keepNum = NaN;
|
||||
}
|
||||
const keep_days = Number.isFinite(keepNum) && keepNum >= 0 ? keepNum : 7;
|
||||
return { enabled, interval, keep_days };
|
||||
const keep_days = Math.max(0, parseIntField(body.keep_days, 7));
|
||||
const hour = Math.min(23, Math.max(0, parseIntField(body.hour, 2)));
|
||||
const day_of_week = Math.min(6, Math.max(0, parseIntField(body.day_of_week, 0)));
|
||||
const day_of_month = Math.min(28, Math.max(1, parseIntField(body.day_of_month, 1)));
|
||||
return { enabled, interval, keep_days, hour, day_of_week, day_of_month };
|
||||
}
|
||||
|
||||
router.put('/auto-settings', (req: Request, res: Response) => {
|
||||
@@ -248,6 +288,13 @@ router.put('/auto-settings', (req: Request, res: Response) => {
|
||||
const settings = parseAutoBackupBody((req.body || {}) as Record<string, unknown>);
|
||||
scheduler.saveSettings(settings);
|
||||
scheduler.start();
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'backup.auto_settings',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days },
|
||||
});
|
||||
res.json({ settings });
|
||||
} catch (err: unknown) {
|
||||
console.error('[backup] PUT auto-settings:', err);
|
||||
@@ -272,6 +319,13 @@ router.delete('/:filename', (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
fs.unlinkSync(filePath);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'backup.delete',
|
||||
resource: filename,
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -195,6 +195,77 @@ router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Respon
|
||||
broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Settlement calculation: who owes whom
|
||||
router.get('/settlement', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
|
||||
const allMembers = db.prepare(`
|
||||
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
|
||||
FROM budget_item_members bm
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
||||
`).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
|
||||
|
||||
// Calculate net balance per user: positive = is owed money, negative = owes money
|
||||
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
|
||||
|
||||
for (const item of items) {
|
||||
const members = allMembers.filter(m => m.budget_item_id === item.id);
|
||||
if (members.length === 0) continue;
|
||||
|
||||
const payers = members.filter(m => m.paid);
|
||||
if (payers.length === 0) continue; // no one marked as paid
|
||||
|
||||
const sharePerMember = item.total_price / members.length;
|
||||
const paidPerPayer = item.total_price / payers.length;
|
||||
|
||||
for (const m of members) {
|
||||
if (!balances[m.user_id]) {
|
||||
balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 };
|
||||
}
|
||||
// Everyone owes their share
|
||||
balances[m.user_id].balance -= sharePerMember;
|
||||
// Payers get credited what they paid
|
||||
if (m.paid) balances[m.user_id].balance += paidPerPayer;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate optimized payment flows (greedy algorithm)
|
||||
const people = Object.values(balances).filter(b => Math.abs(b.balance) > 0.01);
|
||||
const debtors = people.filter(p => p.balance < -0.01).map(p => ({ ...p, amount: -p.balance }));
|
||||
const creditors = people.filter(p => p.balance > 0.01).map(p => ({ ...p, amount: p.balance }));
|
||||
|
||||
// Sort by amount descending for efficient matching
|
||||
debtors.sort((a, b) => b.amount - a.amount);
|
||||
creditors.sort((a, b) => b.amount - a.amount);
|
||||
|
||||
const flows: { from: { user_id: number; username: string; avatar_url: string | null }; to: { user_id: number; username: string; avatar_url: string | null }; amount: number }[] = [];
|
||||
|
||||
let di = 0, ci = 0;
|
||||
while (di < debtors.length && ci < creditors.length) {
|
||||
const transfer = Math.min(debtors[di].amount, creditors[ci].amount);
|
||||
if (transfer > 0.01) {
|
||||
flows.push({
|
||||
from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url },
|
||||
to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url },
|
||||
amount: Math.round(transfer * 100) / 100,
|
||||
});
|
||||
}
|
||||
debtors[di].amount -= transfer;
|
||||
creditors[ci].amount -= transfer;
|
||||
if (debtors[di].amount < 0.01) di++;
|
||||
if (creditors[ci].amount < 0.01) ci++;
|
||||
}
|
||||
|
||||
res.json({
|
||||
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
|
||||
flows,
|
||||
});
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
@@ -419,6 +419,13 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
|
||||
const formatted = formatMessage(message);
|
||||
res.status(201).json({ message: formatted });
|
||||
broadcast(tripId, 'collab:message:created', { message: formatted }, req.headers['x-socket-id'] as string);
|
||||
|
||||
// Notify trip members about new chat message
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
const preview = text.trim().length > 80 ? text.trim().substring(0, 80) + '...' : text.trim();
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, preview }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/messages/:id/react', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
@@ -82,7 +82,27 @@ router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL';
|
||||
const files = db.prepare(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`).all(tripId) as TripFile[];
|
||||
res.json({ files: files.map(formatFile) });
|
||||
|
||||
// Get all file_links for this trip's files
|
||||
const fileIds = files.map(f => f.id);
|
||||
let linksMap: Record<number, number[]> = {};
|
||||
if (fileIds.length > 0) {
|
||||
const placeholders = fileIds.map(() => '?').join(',');
|
||||
const links = db.prepare(`SELECT file_id, reservation_id, place_id FROM file_links WHERE file_id IN (${placeholders})`).all(...fileIds) as { file_id: number; reservation_id: number | null; place_id: number | null }[];
|
||||
for (const link of links) {
|
||||
if (!linksMap[link.file_id]) linksMap[link.file_id] = [];
|
||||
linksMap[link.file_id].push(link);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ files: files.map(f => {
|
||||
const fileLinks = linksMap[f.id] || [];
|
||||
return {
|
||||
...formatFile(f),
|
||||
linked_reservation_ids: fileLinks.filter(l => l.reservation_id).map(l => l.reservation_id),
|
||||
linked_place_ids: fileLinks.filter(l => l.place_id).map(l => l.place_id),
|
||||
};
|
||||
})});
|
||||
});
|
||||
|
||||
// Upload file
|
||||
@@ -239,4 +259,55 @@ router.delete('/trash/empty', authenticate, (req: Request, res: Response) => {
|
||||
res.json({ success: true, deleted: trashed.length });
|
||||
});
|
||||
|
||||
// Link a file to a reservation (many-to-many)
|
||||
router.post('/:id/link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const { reservation_id, assignment_id, place_id } = req.body;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
try {
|
||||
db.prepare('INSERT OR IGNORE INTO file_links (file_id, reservation_id, assignment_id, place_id) VALUES (?, ?, ?, ?)').run(
|
||||
id, reservation_id || null, assignment_id || null, place_id || null
|
||||
);
|
||||
} catch {}
|
||||
|
||||
const links = db.prepare('SELECT * FROM file_links WHERE file_id = ?').all(id);
|
||||
res.json({ success: true, links });
|
||||
});
|
||||
|
||||
// Unlink a file from a reservation
|
||||
router.delete('/:id/link/:linkId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id, linkId } = req.params;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
db.prepare('DELETE FROM file_links WHERE id = ? AND file_id = ?').run(linkId, id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Get all links for a file
|
||||
router.get('/:id/links', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const links = db.prepare(`
|
||||
SELECT fl.*, r.title as reservation_title
|
||||
FROM file_links fl
|
||||
LEFT JOIN reservations r ON fl.reservation_id = r.id
|
||||
WHERE fl.file_id = ?
|
||||
`).all(id);
|
||||
res.json({ links });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { db } from '../db/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { broadcast } from '../websocket';
|
||||
import { AuthRequest } from '../types';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ── Immich Connection Settings ──────────────────────────────────────────────
|
||||
|
||||
router.get('/settings', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||
res.json({
|
||||
immich_url: user?.immich_url || '',
|
||||
connected: !!(user?.immich_url && user?.immich_api_key),
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/settings', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { immich_url, immich_api_key } = req.body;
|
||||
db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
|
||||
immich_url?.trim() || null,
|
||||
immich_api_key?.trim() || null,
|
||||
authReq.user.id
|
||||
);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/status', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||
if (!user?.immich_url || !user?.immich_api_key) {
|
||||
return res.json({ connected: false, error: 'Not configured' });
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${user.immich_url}/api/users/me`, {
|
||||
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` });
|
||||
const data = await resp.json() as { name?: string; email?: string };
|
||||
res.json({ connected: true, user: { name: data.name, email: data.email } });
|
||||
} catch (err: unknown) {
|
||||
res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Browse Immich Library (for photo picker) ────────────────────────────────
|
||||
|
||||
router.get('/browse', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { page = '1', size = '50' } = req.query;
|
||||
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${user.immich_url}/api/timeline/buckets`, {
|
||||
method: 'GET',
|
||||
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch from Immich' });
|
||||
const buckets = await resp.json();
|
||||
res.json({ buckets });
|
||||
} catch (err: unknown) {
|
||||
res.status(502).json({ error: 'Could not reach Immich' });
|
||||
}
|
||||
});
|
||||
|
||||
// Search photos by date range (for the date-filter in picker)
|
||||
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { from, to } = req.body;
|
||||
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
|
||||
|
||||
try {
|
||||
// Paginate through all results (Immich limits per-page to 1000)
|
||||
const allAssets: any[] = [];
|
||||
let page = 1;
|
||||
const pageSize = 1000;
|
||||
while (true) {
|
||||
const resp = await fetch(`${user.immich_url}/api/search/metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': user.immich_api_key, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
|
||||
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
|
||||
type: 'IMAGE',
|
||||
size: pageSize,
|
||||
page,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!resp.ok) return res.status(resp.status).json({ error: 'Search failed' });
|
||||
const data = await resp.json() as { assets?: { items?: any[] } };
|
||||
const items = data.assets?.items || [];
|
||||
allAssets.push(...items);
|
||||
if (items.length < pageSize) break; // Last page
|
||||
page++;
|
||||
if (page > 20) break; // Safety limit (20k photos max)
|
||||
}
|
||||
const assets = allAssets.map((a: any) => ({
|
||||
id: a.id,
|
||||
takenAt: a.fileCreatedAt || a.createdAt,
|
||||
city: a.exifInfo?.city || null,
|
||||
country: a.exifInfo?.country || null,
|
||||
}));
|
||||
res.json({ assets });
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Could not reach Immich' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Trip Photos (selected by user) ──────────────────────────────────────────
|
||||
|
||||
// Get all photos for a trip (own + shared by others)
|
||||
router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
|
||||
const photos = db.prepare(`
|
||||
SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at,
|
||||
u.username, u.avatar, u.immich_url
|
||||
FROM trip_photos tp
|
||||
JOIN users u ON tp.user_id = u.id
|
||||
WHERE tp.trip_id = ?
|
||||
AND (tp.user_id = ? OR tp.shared = 1)
|
||||
ORDER BY tp.added_at ASC
|
||||
`).all(tripId, authReq.user.id);
|
||||
|
||||
res.json({ photos });
|
||||
});
|
||||
|
||||
// Add photos to a trip
|
||||
router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { asset_ids, shared = true } = req.body;
|
||||
|
||||
if (!Array.isArray(asset_ids) || asset_ids.length === 0) {
|
||||
return res.status(400).json({ error: 'asset_ids required' });
|
||||
}
|
||||
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
let added = 0;
|
||||
for (const assetId of asset_ids) {
|
||||
const result = insert.run(tripId, authReq.user.id, assetId, shared ? 1 : 0);
|
||||
if (result.changes > 0) added++;
|
||||
}
|
||||
|
||||
res.json({ success: true, added });
|
||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||
|
||||
// Notify trip members about shared photos
|
||||
if (shared && added > 0) {
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, count: String(added) }).catch(() => {});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Remove a photo from a trip (own photos only)
|
||||
router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
|
||||
.run(req.params.tripId, authReq.user.id, req.params.assetId);
|
||||
res.json({ success: true });
|
||||
broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Toggle sharing for a specific photo
|
||||
router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { shared } = req.body;
|
||||
db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
|
||||
.run(shared ? 1 : 0, req.params.tripId, authReq.user.id, req.params.assetId);
|
||||
res.json({ success: true });
|
||||
broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// ── Asset Details ───────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { assetId } = req.params;
|
||||
const { userId } = req.query;
|
||||
|
||||
const targetUserId = userId ? Number(userId) : authReq.user.id;
|
||||
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
|
||||
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${user.immich_url}/api/assets/${assetId}`, {
|
||||
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed' });
|
||||
const asset = await resp.json() as any;
|
||||
res.json({
|
||||
id: asset.id,
|
||||
takenAt: asset.fileCreatedAt || asset.createdAt,
|
||||
width: asset.exifInfo?.exifImageWidth || null,
|
||||
height: asset.exifInfo?.exifImageHeight || null,
|
||||
camera: asset.exifInfo?.make && asset.exifInfo?.model ? `${asset.exifInfo.make} ${asset.exifInfo.model}` : null,
|
||||
lens: asset.exifInfo?.lensModel || null,
|
||||
focalLength: asset.exifInfo?.focalLength ? `${asset.exifInfo.focalLength}mm` : null,
|
||||
aperture: asset.exifInfo?.fNumber ? `f/${asset.exifInfo.fNumber}` : null,
|
||||
shutter: asset.exifInfo?.exposureTime || null,
|
||||
iso: asset.exifInfo?.iso || null,
|
||||
city: asset.exifInfo?.city || null,
|
||||
state: asset.exifInfo?.state || null,
|
||||
country: asset.exifInfo?.country || null,
|
||||
lat: asset.exifInfo?.latitude || null,
|
||||
lng: asset.exifInfo?.longitude || null,
|
||||
fileSize: asset.exifInfo?.fileSizeInByte || null,
|
||||
fileName: asset.originalFileName || null,
|
||||
});
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Proxy error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Proxy Immich Assets ─────────────────────────────────────────────────────
|
||||
|
||||
// Asset proxy routes accept token via query param (for <img> src usage)
|
||||
function authFromQuery(req: Request, res: Response, next: Function) {
|
||||
const token = req.query.token as string;
|
||||
if (token && !req.headers.authorization) {
|
||||
req.headers.authorization = `Bearer ${token}`;
|
||||
}
|
||||
return (authenticate as any)(req, res, next);
|
||||
}
|
||||
|
||||
router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { assetId } = req.params;
|
||||
const { userId } = req.query;
|
||||
|
||||
const targetUserId = userId ? Number(userId) : authReq.user.id;
|
||||
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
|
||||
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found');
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${user.immich_url}/api/assets/${assetId}/thumbnail`, {
|
||||
headers: { 'x-api-key': user.immich_api_key },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!resp.ok) return res.status(resp.status).send('Failed');
|
||||
res.set('Content-Type', resp.headers.get('content-type') || 'image/webp');
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||
res.send(buffer);
|
||||
} catch {
|
||||
res.status(502).send('Proxy error');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { assetId } = req.params;
|
||||
const { userId } = req.query;
|
||||
|
||||
const targetUserId = userId ? Number(userId) : authReq.user.id;
|
||||
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
|
||||
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found');
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${user.immich_url}/api/assets/${assetId}/original`, {
|
||||
headers: { 'x-api-key': user.immich_api_key },
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!resp.ok) return res.status(resp.status).send('Failed');
|
||||
res.set('Content-Type', resp.headers.get('content-type') || 'image/jpeg');
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||
res.send(buffer);
|
||||
} catch {
|
||||
res.status(502).send('Proxy error');
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -474,4 +474,68 @@ router.get('/reverse', authenticate, async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve a Google Maps URL to place data (coordinates, name, address)
|
||||
router.post('/resolve-url', authenticate, async (req: Request, res: Response) => {
|
||||
const { url } = req.body;
|
||||
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
|
||||
|
||||
try {
|
||||
let resolvedUrl = url;
|
||||
|
||||
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl)
|
||||
if (url.includes('goo.gl') || url.includes('maps.app')) {
|
||||
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
|
||||
resolvedUrl = redirectRes.url;
|
||||
}
|
||||
|
||||
// Extract coordinates from Google Maps URL patterns:
|
||||
// /@48.8566,2.3522,15z or /place/.../@48.8566,2.3522
|
||||
// ?q=48.8566,2.3522 or ?ll=48.8566,2.3522
|
||||
let lat: number | null = null;
|
||||
let lng: number | null = null;
|
||||
let placeName: string | null = null;
|
||||
|
||||
// Pattern: /@lat,lng
|
||||
const atMatch = resolvedUrl.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
||||
if (atMatch) { lat = parseFloat(atMatch[1]); lng = parseFloat(atMatch[2]); }
|
||||
|
||||
// Pattern: !3dlat!4dlng (Google Maps data params)
|
||||
if (!lat) {
|
||||
const dataMatch = resolvedUrl.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
|
||||
if (dataMatch) { lat = parseFloat(dataMatch[1]); lng = parseFloat(dataMatch[2]); }
|
||||
}
|
||||
|
||||
// Pattern: ?q=lat,lng or &q=lat,lng
|
||||
if (!lat) {
|
||||
const qMatch = resolvedUrl.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
||||
if (qMatch) { lat = parseFloat(qMatch[1]); lng = parseFloat(qMatch[2]); }
|
||||
}
|
||||
|
||||
// Extract place name from URL path: /place/Place+Name/@...
|
||||
const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
|
||||
if (placeMatch) {
|
||||
placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
|
||||
}
|
||||
|
||||
if (!lat || !lng || isNaN(lat) || isNaN(lng)) {
|
||||
return res.status(400).json({ error: 'Could not extract coordinates from URL' });
|
||||
}
|
||||
|
||||
// Reverse geocode to get address
|
||||
const nominatimRes = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`,
|
||||
{ headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) }
|
||||
);
|
||||
const nominatim = await nominatimRes.json() as { display_name?: string; name?: string; address?: Record<string, string> };
|
||||
|
||||
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
|
||||
const address = nominatim.display_name || null;
|
||||
|
||||
res.json({ lat, lng, name, address });
|
||||
} catch (err: unknown) {
|
||||
console.error('[Maps] URL resolve error:', err instanceof Error ? err.message : err);
|
||||
res.status(400).json({ error: 'Failed to resolve URL' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { db } from '../db/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { testSmtp } from '../services/notifications';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get user's notification preferences
|
||||
router.get('/preferences', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
|
||||
if (!prefs) {
|
||||
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(authReq.user.id);
|
||||
prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
|
||||
}
|
||||
res.json({ preferences: prefs });
|
||||
});
|
||||
|
||||
// Update user's notification preferences
|
||||
router.put('/preferences', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook } = req.body;
|
||||
|
||||
// Ensure row exists
|
||||
const existing = db.prepare('SELECT id FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
|
||||
if (!existing) {
|
||||
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(authReq.user.id);
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE notification_preferences SET
|
||||
notify_trip_invite = COALESCE(?, notify_trip_invite),
|
||||
notify_booking_change = COALESCE(?, notify_booking_change),
|
||||
notify_trip_reminder = COALESCE(?, notify_trip_reminder),
|
||||
notify_webhook = COALESCE(?, notify_webhook)
|
||||
WHERE user_id = ?`).run(
|
||||
notify_trip_invite !== undefined ? (notify_trip_invite ? 1 : 0) : null,
|
||||
notify_booking_change !== undefined ? (notify_booking_change ? 1 : 0) : null,
|
||||
notify_trip_reminder !== undefined ? (notify_trip_reminder ? 1 : 0) : null,
|
||||
notify_webhook !== undefined ? (notify_webhook ? 1 : 0) : null,
|
||||
authReq.user.id
|
||||
);
|
||||
|
||||
const prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
|
||||
res.json({ preferences: prefs });
|
||||
});
|
||||
|
||||
// Admin: test SMTP configuration
|
||||
router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
|
||||
|
||||
const { email } = req.body;
|
||||
const result = await testSmtp(email || authReq.user.email);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -24,6 +24,9 @@ interface OidcUserInfo {
|
||||
email?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
groups?: string[];
|
||||
roles?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
@@ -41,7 +44,7 @@ setInterval(() => {
|
||||
}
|
||||
}, AUTH_CODE_CLEANUP);
|
||||
|
||||
const pendingStates = new Map<string, { createdAt: number; redirectUri: string }>();
|
||||
const pendingStates = new Map<string, { createdAt: number; redirectUri: string; inviteToken?: string }>();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
@@ -85,6 +88,23 @@ function generateToken(user: { id: number; username: string; email: string; role
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user should be admin based on OIDC claims
|
||||
// Env: OIDC_ADMIN_CLAIM (default: "groups"), OIDC_ADMIN_VALUE (required, e.g. "app-trek-admins")
|
||||
function resolveOidcRole(userInfo: OidcUserInfo, isFirstUser: boolean): 'admin' | 'user' {
|
||||
if (isFirstUser) return 'admin';
|
||||
const adminValue = process.env.OIDC_ADMIN_VALUE;
|
||||
if (!adminValue) return 'user'; // No claim mapping configured
|
||||
const claimKey = process.env.OIDC_ADMIN_CLAIM || 'groups';
|
||||
const claimData = userInfo[claimKey];
|
||||
if (Array.isArray(claimData)) {
|
||||
return claimData.some(v => String(v) === adminValue) ? 'admin' : 'user';
|
||||
}
|
||||
if (typeof claimData === 'string') {
|
||||
return claimData === adminValue ? 'admin' : 'user';
|
||||
}
|
||||
return 'user';
|
||||
}
|
||||
|
||||
function frontendUrl(path: string): string {
|
||||
const base = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5173';
|
||||
return base + path;
|
||||
@@ -104,8 +124,9 @@ router.get('/login', async (req: Request, res: Response) => {
|
||||
const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol;
|
||||
const host = (req.headers['x-forwarded-host'] as string) || req.headers.host;
|
||||
const redirectUri = `${proto}://${host}/api/auth/oidc/callback`;
|
||||
const inviteToken = req.query.invite as string | undefined;
|
||||
|
||||
pendingStates.set(state, { createdAt: Date.now(), redirectUri });
|
||||
pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken });
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
@@ -190,18 +211,35 @@ router.get('/callback', async (req: Request, res: Response) => {
|
||||
if (!user.oidc_sub) {
|
||||
db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id);
|
||||
}
|
||||
// Update role based on OIDC claims on every login (if claim mapping is configured)
|
||||
if (process.env.OIDC_ADMIN_VALUE) {
|
||||
const newRole = resolveOidcRole(userInfo, false);
|
||||
if (user.role !== newRole) {
|
||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
|
||||
user = { ...user, role: newRole } as User;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
||||
const isFirstUser = userCount === 0;
|
||||
|
||||
if (!isFirstUser) {
|
||||
let validInvite: any = null;
|
||||
if (pending.inviteToken) {
|
||||
validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(pending.inviteToken);
|
||||
if (validInvite) {
|
||||
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) validInvite = null;
|
||||
if (validInvite?.expires_at && new Date(validInvite.expires_at) < new Date()) validInvite = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isFirstUser && !validInvite) {
|
||||
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
|
||||
if (setting?.value === 'false') {
|
||||
return res.redirect(frontendUrl('/login?oidc_error=registration_disabled'));
|
||||
}
|
||||
}
|
||||
|
||||
const role = isFirstUser ? 'admin' : 'user';
|
||||
const role = resolveOidcRole(userInfo, isFirstUser);
|
||||
const randomPass = crypto.randomBytes(32).toString('hex');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const hash = bcrypt.hashSync(randomPass, 10);
|
||||
@@ -214,6 +252,15 @@ router.get('/callback', async (req: Request, res: Response) => {
|
||||
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(username, email, hash, role, sub, config.issuer);
|
||||
|
||||
if (validInvite) {
|
||||
const updated = db.prepare(
|
||||
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)'
|
||||
).run(validInvite.id);
|
||||
if (updated.changes === 0) {
|
||||
console.warn(`[OIDC] Invite token ${pending.inviteToken?.slice(0, 8)}... exceeded max_uses (race condition)`);
|
||||
}
|
||||
}
|
||||
|
||||
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,53 @@ router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
res.json({ items });
|
||||
});
|
||||
|
||||
// Bulk import packing items (must be before /:id)
|
||||
router.post('/import', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { items } = req.body; // [{ name, category?, quantity? }]
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ error: 'items must be a non-empty array' });
|
||||
|
||||
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 stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
const created: any[] = [];
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const item of items) {
|
||||
if (!item.name?.trim()) continue;
|
||||
const checked = item.checked ? 1 : 0;
|
||||
const weight = item.weight_grams ? parseInt(item.weight_grams) || null : null;
|
||||
// Resolve bag by name if provided
|
||||
let bagId = null;
|
||||
if (item.bag?.trim()) {
|
||||
const bagName = item.bag.trim();
|
||||
const existing = db.prepare('SELECT id FROM packing_bags WHERE trip_id = ? AND name = ?').get(tripId, bagName) as { id: number } | undefined;
|
||||
if (existing) {
|
||||
bagId = existing.id;
|
||||
} else {
|
||||
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
|
||||
const bagCount = (db.prepare('SELECT COUNT(*) as c FROM packing_bags WHERE trip_id = ?').get(tripId) as { c: number }).c;
|
||||
const newBag = db.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(tripId, bagName, BAG_COLORS[bagCount % BAG_COLORS.length]);
|
||||
bagId = newBag.lastInsertRowid;
|
||||
}
|
||||
}
|
||||
const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++);
|
||||
created.push(db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid));
|
||||
}
|
||||
});
|
||||
insertAll();
|
||||
|
||||
res.status(201).json({ items: created, count: created.length });
|
||||
for (const item of created) {
|
||||
broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
@@ -231,6 +278,18 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
|
||||
|
||||
res.json({ assignees: rows });
|
||||
broadcast(tripId, 'packing:assignees', { category: cat, assignees: rows }, req.headers['x-socket-id'] as string);
|
||||
|
||||
// Notify newly assigned users
|
||||
if (Array.isArray(user_ids) && user_ids.length > 0) {
|
||||
import('../services/notifications').then(({ notify }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
for (const uid of user_ids) {
|
||||
if (uid !== authReq.user.id) {
|
||||
notify({ userId: uid, event: 'packing_tagged', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, category: cat } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/reorder', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
import multer from 'multer';
|
||||
import { db, getPlaceWithTags } from '../db/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { requireTripAccess } from '../middleware/tripAccess';
|
||||
@@ -8,6 +9,8 @@ import { loadTagsByPlaceIds } from '../services/queryHelpers';
|
||||
import { validateStringLengths } from '../middleware/validate';
|
||||
import { AuthRequest, Place } from '../types';
|
||||
|
||||
const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
|
||||
interface PlaceWithCategory extends Place {
|
||||
category_name: string | null;
|
||||
category_color: string | null;
|
||||
@@ -112,6 +115,94 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Import places from GPX file (must be before /:id)
|
||||
router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('file'), (req: Request, res: Response) => {
|
||||
const { tripId } = req.params;
|
||||
const file = (req as any).file;
|
||||
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const xml = file.buffer.toString('utf-8');
|
||||
|
||||
const parseCoords = (attrs: string): { lat: number; lng: number } | null => {
|
||||
const latMatch = attrs.match(/lat=["']([^"']+)["']/i);
|
||||
const lonMatch = attrs.match(/lon=["']([^"']+)["']/i);
|
||||
if (!latMatch || !lonMatch) return null;
|
||||
const lat = parseFloat(latMatch[1]);
|
||||
const lng = parseFloat(lonMatch[1]);
|
||||
return (!isNaN(lat) && !isNaN(lng)) ? { lat, lng } : null;
|
||||
};
|
||||
|
||||
const stripCdata = (s: string) => s.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').trim();
|
||||
const extractName = (body: string) => { const m = body.match(/<name[^>]*>([\s\S]*?)<\/name>/i); return m ? stripCdata(m[1]) : null };
|
||||
const extractDesc = (body: string) => { const m = body.match(/<desc[^>]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null };
|
||||
|
||||
const waypoints: { name: string; lat: number; lng: number; description: string | null }[] = [];
|
||||
|
||||
// 1) Parse <wpt> elements (named waypoints / POIs)
|
||||
const wptRegex = /<wpt\s([^>]+)>([\s\S]*?)<\/wpt>/gi;
|
||||
let match;
|
||||
while ((match = wptRegex.exec(xml)) !== null) {
|
||||
const coords = parseCoords(match[1]);
|
||||
if (!coords) continue;
|
||||
const name = extractName(match[2]) || `Waypoint ${waypoints.length + 1}`;
|
||||
waypoints.push({ ...coords, name, description: extractDesc(match[2]) });
|
||||
}
|
||||
|
||||
// 2) If no <wpt>, try <rtept> (route points)
|
||||
if (waypoints.length === 0) {
|
||||
const rteptRegex = /<rtept\s([^>]+)>([\s\S]*?)<\/rtept>/gi;
|
||||
while ((match = rteptRegex.exec(xml)) !== null) {
|
||||
const coords = parseCoords(match[1]);
|
||||
if (!coords) continue;
|
||||
const name = extractName(match[2]) || `Route Point ${waypoints.length + 1}`;
|
||||
waypoints.push({ ...coords, name, description: extractDesc(match[2]) });
|
||||
}
|
||||
}
|
||||
|
||||
// 3) If still nothing, extract track name + start/end points from <trkpt>
|
||||
if (waypoints.length === 0) {
|
||||
const trackNameMatch = xml.match(/<trk[^>]*>[\s\S]*?<name[^>]*>([\s\S]*?)<\/name>/i);
|
||||
const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track';
|
||||
const trkptRegex = /<trkpt\s([^>]*?)(?:\/>|>([\s\S]*?)<\/trkpt>)/gi;
|
||||
const trackPoints: { lat: number; lng: number }[] = [];
|
||||
while ((match = trkptRegex.exec(xml)) !== null) {
|
||||
const coords = parseCoords(match[1]);
|
||||
if (coords) trackPoints.push(coords);
|
||||
}
|
||||
if (trackPoints.length > 0) {
|
||||
const start = trackPoints[0];
|
||||
waypoints.push({ ...start, name: `${trackName} — Start`, description: null });
|
||||
if (trackPoints.length > 1) {
|
||||
const end = trackPoints[trackPoints.length - 1];
|
||||
waypoints.push({ ...end, name: `${trackName} — End`, description: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (waypoints.length === 0) {
|
||||
return res.status(400).json({ error: 'No waypoints found in GPX file' });
|
||||
}
|
||||
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, 'walking')
|
||||
`);
|
||||
const created: any[] = [];
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const wp of waypoints) {
|
||||
const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng);
|
||||
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
||||
created.push(place);
|
||||
}
|
||||
});
|
||||
insertAll();
|
||||
|
||||
res.status(201).json({ places: created, count: created.length });
|
||||
for (const place of created) {
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const { tripId, id } = req.params
|
||||
|
||||
|
||||
@@ -101,6 +101,35 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
res.status(201).json({ reservation });
|
||||
broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string);
|
||||
|
||||
// Notify trip members about new booking
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, booking: title, type: type || 'booking' }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
// Batch update day_plan_position for multiple reservations (must be before /:id)
|
||||
router.put('/positions', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { positions } = req.body;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' });
|
||||
|
||||
const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
|
||||
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
|
||||
for (const item of items) {
|
||||
stmt.run(item.day_plan_position, item.id, tripId);
|
||||
}
|
||||
});
|
||||
updateMany(positions);
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'reservation:positions', { positions }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { loadTagsByPlaceIds } from '../services/queryHelpers';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Create a share link for a trip (owner/member only)
|
||||
router.post('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const { share_map = true, share_bookings = true, share_packing = false, share_budget = false, share_collab = false } = req.body || {};
|
||||
|
||||
// Check if token already exists
|
||||
const existing = db.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined;
|
||||
if (existing) {
|
||||
// Update permissions
|
||||
db.prepare('UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ? WHERE trip_id = ?')
|
||||
.run(share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, tripId);
|
||||
return res.json({ token: existing.token });
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(tripId, token, authReq.user.id, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0);
|
||||
res.status(201).json({ token });
|
||||
});
|
||||
|
||||
// Get share link status
|
||||
router.get('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const row = db.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any;
|
||||
res.json(row ? { token: row.token, created_at: row.created_at, share_map: !!row.share_map, share_bookings: !!row.share_bookings, share_packing: !!row.share_packing, share_budget: !!row.share_budget, share_collab: !!row.share_collab } : { token: null });
|
||||
});
|
||||
|
||||
// Delete share link
|
||||
router.delete('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Public read-only trip data (no auth required)
|
||||
router.get('/shared/:token', (req: Request, res: Response) => {
|
||||
const { token } = req.params;
|
||||
const shareRow = db.prepare('SELECT * FROM share_tokens WHERE token = ?').get(token) as any;
|
||||
if (!shareRow) return res.status(404).json({ error: 'Invalid or expired link' });
|
||||
|
||||
const tripId = shareRow.trip_id;
|
||||
|
||||
// Trip
|
||||
const trip = db.prepare('SELECT id, title, description, start_date, end_date, cover_image, currency FROM trips WHERE id = ?').get(tripId);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
// Days with assignments
|
||||
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[];
|
||||
const dayIds = days.map(d => d.id);
|
||||
|
||||
let assignments = {};
|
||||
let dayNotes = {};
|
||||
if (dayIds.length > 0) {
|
||||
const ph = dayIds.map(() => '?').join(',');
|
||||
const allAssignments = db.prepare(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.day_id IN (${ph})
|
||||
ORDER BY da.order_index ASC
|
||||
`).all(...dayIds);
|
||||
|
||||
const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))];
|
||||
const tagsByPlace = loadTagsByPlaceIds(placeIds, { compact: true });
|
||||
|
||||
const byDay: Record<number, any[]> = {};
|
||||
for (const a of allAssignments as any[]) {
|
||||
if (!byDay[a.day_id]) byDay[a.day_id] = [];
|
||||
byDay[a.day_id].push({
|
||||
id: a.id, day_id: a.day_id, order_index: a.order_index, notes: a.notes,
|
||||
place: {
|
||||
id: a.place_id, name: a.place_name, description: a.place_description,
|
||||
lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id,
|
||||
price: a.price, place_time: a.place_time, end_time: a.end_time,
|
||||
image_url: a.image_url, transport_mode: a.transport_mode,
|
||||
category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null,
|
||||
tags: tagsByPlace[a.place_id] || [],
|
||||
}
|
||||
});
|
||||
}
|
||||
assignments = byDay;
|
||||
|
||||
const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC`).all(...dayIds);
|
||||
const notesByDay: Record<number, any[]> = {};
|
||||
for (const n of allNotes as any[]) {
|
||||
if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
|
||||
notesByDay[n.day_id].push(n);
|
||||
}
|
||||
dayNotes = notesByDay;
|
||||
}
|
||||
|
||||
// Places
|
||||
const places = db.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.trip_id = ? ORDER BY p.created_at DESC
|
||||
`).all(tripId);
|
||||
|
||||
// Reservations
|
||||
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId);
|
||||
|
||||
// Accommodations
|
||||
const accommodations = db.prepare(`
|
||||
SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng
|
||||
FROM day_accommodations a JOIN places p ON a.place_id = p.id
|
||||
WHERE a.trip_id = ?
|
||||
`).all(tripId);
|
||||
|
||||
// Packing
|
||||
const packing = db.prepare('SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC').all(tripId);
|
||||
|
||||
// Budget
|
||||
const budget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC').all(tripId);
|
||||
|
||||
// Categories
|
||||
const categories = db.prepare('SELECT * FROM categories').all();
|
||||
|
||||
const permissions = {
|
||||
share_map: !!shareRow.share_map,
|
||||
share_bookings: !!shareRow.share_bookings,
|
||||
share_packing: !!shareRow.share_packing,
|
||||
share_budget: !!shareRow.share_budget,
|
||||
share_collab: !!shareRow.share_collab,
|
||||
};
|
||||
|
||||
// Only include data the owner chose to share
|
||||
const collabMessages = permissions.share_collab
|
||||
? db.prepare('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? ORDER BY m.created_at ASC').all(tripId)
|
||||
: [];
|
||||
|
||||
res.json({
|
||||
trip, days, assignments, dayNotes, places, categories, permissions,
|
||||
reservations: permissions.share_bookings ? reservations : [],
|
||||
accommodations: permissions.share_bookings ? accommodations : [],
|
||||
packing: permissions.share_packing ? packing : [],
|
||||
budget: permissions.share_budget ? budget : [],
|
||||
collab: collabMessages,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -284,6 +284,12 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(req.params.id, target.id, authReq.user.id);
|
||||
|
||||
// Notify invited user
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(req.params.id) as { title: string } | undefined;
|
||||
import('../services/notifications').then(({ notify }) => {
|
||||
notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username } }).catch(() => {});
|
||||
});
|
||||
|
||||
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });
|
||||
});
|
||||
|
||||
@@ -301,4 +307,83 @@ router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ICS calendar export
|
||||
router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!canAccessTrip(req.params.id, authReq.user.id))
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as any;
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(req.params.id) as any[];
|
||||
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
|
||||
const esc = (s: string) => s.replace(/[\\;,\n]/g, m => m === '\n' ? '\\n' : '\\' + m);
|
||||
const fmtDate = (d: string) => d.replace(/-/g, '');
|
||||
const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
const uid = (id: number, type: string) => `trek-${type}-${id}@trek`;
|
||||
|
||||
// Format datetime: handles full ISO "2026-03-30T09:00" and time-only "10:00"
|
||||
const fmtDateTime = (d: string, refDate?: string) => {
|
||||
if (d.includes('T')) return d.replace(/[-:]/g, '').split('.')[0];
|
||||
// Time-only: combine with reference date
|
||||
if (refDate && d.match(/^\d{2}:\d{2}/)) {
|
||||
const datePart = refDate.split('T')[0];
|
||||
return `${datePart}T${d.replace(/:/g, '')}00`.replace(/-/g, '');
|
||||
}
|
||||
return d.replace(/[-:]/g, '');
|
||||
};
|
||||
|
||||
let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n';
|
||||
ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`;
|
||||
|
||||
// Trip as all-day event
|
||||
if (trip.start_date && trip.end_date) {
|
||||
const endNext = new Date(trip.end_date + 'T00:00:00');
|
||||
endNext.setDate(endNext.getDate() + 1);
|
||||
const endStr = endNext.toISOString().split('T')[0].replace(/-/g, '');
|
||||
ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`;
|
||||
if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`;
|
||||
ics += `END:VEVENT\r\n`;
|
||||
}
|
||||
|
||||
// Reservations as events
|
||||
for (const r of reservations) {
|
||||
if (!r.reservation_time) continue;
|
||||
const hasTime = r.reservation_time.includes('T');
|
||||
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
|
||||
|
||||
ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`;
|
||||
if (hasTime) {
|
||||
ics += `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`;
|
||||
if (r.reservation_end_time) {
|
||||
const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time);
|
||||
if (endDt.length >= 15) ics += `DTEND:${endDt}\r\n`;
|
||||
}
|
||||
} else {
|
||||
ics += `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`;
|
||||
}
|
||||
ics += `SUMMARY:${esc(r.title)}\r\n`;
|
||||
|
||||
let desc = r.type ? `Type: ${r.type}` : '';
|
||||
if (r.confirmation_number) desc += `\\nConfirmation: ${r.confirmation_number}`;
|
||||
if (meta.airline) desc += `\\nAirline: ${meta.airline}`;
|
||||
if (meta.flight_number) desc += `\\nFlight: ${meta.flight_number}`;
|
||||
if (meta.departure_airport) desc += `\\nFrom: ${meta.departure_airport}`;
|
||||
if (meta.arrival_airport) desc += `\\nTo: ${meta.arrival_airport}`;
|
||||
if (meta.train_number) desc += `\\nTrain: ${meta.train_number}`;
|
||||
if (r.notes) desc += `\\n${r.notes}`;
|
||||
if (desc) ics += `DESCRIPTION:${desc}\r\n`;
|
||||
if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`;
|
||||
ics += `END:VEVENT\r\n`;
|
||||
}
|
||||
|
||||
ics += 'END:VCALENDAR\r\n';
|
||||
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${esc(trip.title || 'trek-trip')}.ics"`);
|
||||
res.send(ics);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -196,7 +196,7 @@ router.get('/plan', (req: Request, res: Response) => {
|
||||
router.put('/plan', async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const planId = getActivePlanId(authReq.user.id);
|
||||
const { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled } = req.body;
|
||||
const { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled, weekend_days } = req.body;
|
||||
|
||||
const updates: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
@@ -205,6 +205,7 @@ router.put('/plan', async (req: Request, res: Response) => {
|
||||
if (holidays_region !== undefined) { updates.push('holidays_region = ?'); params.push(holidays_region); }
|
||||
if (company_holidays_enabled !== undefined) { updates.push('company_holidays_enabled = ?'); params.push(company_holidays_enabled ? 1 : 0); }
|
||||
if (carry_over_enabled !== undefined) { updates.push('carry_over_enabled = ?'); params.push(carry_over_enabled ? 1 : 0); }
|
||||
if (weekend_days !== undefined) { updates.push('weekend_days = ?'); params.push(String(weekend_days)); }
|
||||
|
||||
if (updates.length > 0) {
|
||||
params.push(planId);
|
||||
@@ -348,6 +349,11 @@ router.post('/invite', (req: Request, res: Response) => {
|
||||
});
|
||||
} catch { /* websocket not available */ }
|
||||
|
||||
// Notify invited user
|
||||
import('../services/notifications').then(({ notify }) => {
|
||||
notify({ userId: user_id, event: 'vacay_invite', params: { actor: authReq.user.username } }).catch(() => {});
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
||||
+32
-13
@@ -8,30 +8,48 @@ const backupsDir = path.join(dataDir, 'backups');
|
||||
const uploadsDir = path.join(__dirname, '../uploads');
|
||||
const settingsFile = path.join(dataDir, 'backup-settings.json');
|
||||
|
||||
const CRON_EXPRESSIONS: Record<string, string> = {
|
||||
hourly: '0 * * * *',
|
||||
daily: '0 2 * * *',
|
||||
weekly: '0 2 * * 0',
|
||||
monthly: '0 2 1 * *',
|
||||
};
|
||||
|
||||
const VALID_INTERVALS = Object.keys(CRON_EXPRESSIONS);
|
||||
const VALID_INTERVALS = ['hourly', 'daily', 'weekly', 'monthly'];
|
||||
const VALID_DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6]; // 0=Sunday
|
||||
const VALID_HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
interface BackupSettings {
|
||||
enabled: boolean;
|
||||
interval: string;
|
||||
keep_days: number;
|
||||
hour: number;
|
||||
day_of_week: number;
|
||||
day_of_month: number;
|
||||
}
|
||||
|
||||
function buildCronExpression(settings: BackupSettings): string {
|
||||
const hour = VALID_HOURS.includes(settings.hour) ? settings.hour : 2;
|
||||
const dow = VALID_DAYS_OF_WEEK.includes(settings.day_of_week) ? settings.day_of_week : 0;
|
||||
const dom = settings.day_of_month >= 1 && settings.day_of_month <= 28 ? settings.day_of_month : 1;
|
||||
|
||||
switch (settings.interval) {
|
||||
case 'hourly': return '0 * * * *';
|
||||
case 'daily': return `0 ${hour} * * *`;
|
||||
case 'weekly': return `0 ${hour} * * ${dow}`;
|
||||
case 'monthly': return `0 ${hour} ${dom} * *`;
|
||||
default: return `0 ${hour} * * *`;
|
||||
}
|
||||
}
|
||||
|
||||
let currentTask: ScheduledTask | null = null;
|
||||
|
||||
function getDefaults(): BackupSettings {
|
||||
return { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 };
|
||||
}
|
||||
|
||||
function loadSettings(): BackupSettings {
|
||||
let settings = getDefaults();
|
||||
try {
|
||||
if (fs.existsSync(settingsFile)) {
|
||||
return JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
||||
const saved = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
||||
settings = { ...settings, ...saved };
|
||||
}
|
||||
} catch (e) {}
|
||||
return { enabled: false, interval: 'daily', keep_days: 7 };
|
||||
return settings;
|
||||
}
|
||||
|
||||
function saveSettings(settings: BackupSettings): void {
|
||||
@@ -104,9 +122,10 @@ function start(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const expression = CRON_EXPRESSIONS[settings.interval] || CRON_EXPRESSIONS.daily;
|
||||
currentTask = cron.schedule(expression, runBackup);
|
||||
console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
|
||||
const expression = buildCronExpression(settings);
|
||||
const tz = process.env.TZ || 'UTC';
|
||||
currentTask = cron.schedule(expression, runBackup, { timezone: tz });
|
||||
console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
|
||||
}
|
||||
|
||||
// Demo mode: hourly reset of demo user data
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Request } from 'express';
|
||||
import { db } from '../db/database';
|
||||
|
||||
export function getClientIp(req: Request): string | null {
|
||||
const xff = req.headers['x-forwarded-for'];
|
||||
if (typeof xff === 'string') {
|
||||
const first = xff.split(',')[0]?.trim();
|
||||
return first || null;
|
||||
}
|
||||
if (Array.isArray(xff) && xff[0]) return String(xff[0]).trim() || null;
|
||||
return req.socket?.remoteAddress || null;
|
||||
}
|
||||
|
||||
/** Best-effort; never throws — failures are logged only. */
|
||||
export function writeAudit(entry: {
|
||||
userId: number | null;
|
||||
action: string;
|
||||
resource?: string | null;
|
||||
details?: Record<string, unknown>;
|
||||
ip?: string | null;
|
||||
}): void {
|
||||
try {
|
||||
const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null;
|
||||
db.prepare(
|
||||
`INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null);
|
||||
} catch (e) {
|
||||
console.error('[audit] write failed:', e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import fetch from 'node-fetch';
|
||||
import { db } from '../db/database';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type EventType = 'trip_invite' | 'booking_change' | 'trip_reminder' | 'vacay_invite' | 'photos_shared' | 'collab_message' | 'packing_tagged';
|
||||
|
||||
interface NotificationPayload {
|
||||
userId: number;
|
||||
event: EventType;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
interface SmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
from: string;
|
||||
secure: boolean;
|
||||
}
|
||||
|
||||
// ── Settings helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function getAppSetting(key: string): string | null {
|
||||
return (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
|
||||
}
|
||||
|
||||
function getSmtpConfig(): SmtpConfig | null {
|
||||
const host = process.env.SMTP_HOST || getAppSetting('smtp_host');
|
||||
const port = process.env.SMTP_PORT || getAppSetting('smtp_port');
|
||||
const user = process.env.SMTP_USER || getAppSetting('smtp_user');
|
||||
const pass = process.env.SMTP_PASS || getAppSetting('smtp_pass');
|
||||
const from = process.env.SMTP_FROM || getAppSetting('smtp_from');
|
||||
if (!host || !port || !from) return null;
|
||||
return { host, port: parseInt(port, 10), user: user || '', pass: pass || '', from, secure: parseInt(port, 10) === 465 };
|
||||
}
|
||||
|
||||
function getWebhookUrl(): string | null {
|
||||
return process.env.NOTIFICATION_WEBHOOK_URL || getAppSetting('notification_webhook_url');
|
||||
}
|
||||
|
||||
function getAppUrl(): string {
|
||||
return process.env.APP_URL || getAppSetting('app_url') || '';
|
||||
}
|
||||
|
||||
function getUserEmail(userId: number): string | null {
|
||||
return (db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined)?.email || null;
|
||||
}
|
||||
|
||||
function getUserLanguage(userId: number): string {
|
||||
return (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as { value: string } | undefined)?.value || 'en';
|
||||
}
|
||||
|
||||
function getUserPrefs(userId: number): Record<string, number> {
|
||||
const row = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as any;
|
||||
return row || { notify_trip_invite: 1, notify_booking_change: 1, notify_trip_reminder: 1, notify_vacay_invite: 1, notify_photos_shared: 1, notify_collab_message: 1, notify_packing_tagged: 1, notify_webhook: 0 };
|
||||
}
|
||||
|
||||
// Event → preference column mapping
|
||||
const EVENT_PREF_MAP: Record<EventType, string> = {
|
||||
trip_invite: 'notify_trip_invite',
|
||||
booking_change: 'notify_booking_change',
|
||||
trip_reminder: 'notify_trip_reminder',
|
||||
vacay_invite: 'notify_vacay_invite',
|
||||
photos_shared: 'notify_photos_shared',
|
||||
collab_message: 'notify_collab_message',
|
||||
packing_tagged: 'notify_packing_tagged',
|
||||
};
|
||||
|
||||
// ── Email i18n strings ─────────────────────────────────────────────────────
|
||||
|
||||
interface EmailStrings { footer: string; manage: string; madeWith: string; openTrek: string }
|
||||
|
||||
const I18N: Record<string, EmailStrings> = {
|
||||
en: { footer: 'You received this because you have notifications enabled in TREK.', manage: 'Manage preferences in Settings', madeWith: 'Made with', openTrek: 'Open TREK' },
|
||||
de: { footer: 'Du erhältst diese E-Mail, weil du Benachrichtigungen in TREK aktiviert hast.', manage: 'Einstellungen verwalten', madeWith: 'Made with', openTrek: 'TREK öffnen' },
|
||||
fr: { footer: 'Vous recevez cet e-mail car les notifications sont activées dans TREK.', manage: 'Gérer les préférences', madeWith: 'Made with', openTrek: 'Ouvrir TREK' },
|
||||
es: { footer: 'Recibiste esto porque tienes las notificaciones activadas en TREK.', manage: 'Gestionar preferencias', madeWith: 'Made with', openTrek: 'Abrir TREK' },
|
||||
nl: { footer: 'Je ontvangt dit omdat je meldingen hebt ingeschakeld in TREK.', manage: 'Voorkeuren beheren', madeWith: 'Made with', openTrek: 'TREK openen' },
|
||||
ru: { footer: 'Вы получили это, потому что у вас включены уведомления в TREK.', manage: 'Управление настройками', madeWith: 'Made with', openTrek: 'Открыть TREK' },
|
||||
zh: { footer: '您收到此邮件是因为您在 TREK 中启用了通知。', manage: '管理偏好设置', madeWith: 'Made with', openTrek: '打开 TREK' },
|
||||
ar: { footer: 'تلقيت هذا لأنك قمت بتفعيل الإشعارات في TREK.', manage: 'إدارة التفضيلات', madeWith: 'Made with', openTrek: 'فتح TREK' },
|
||||
};
|
||||
|
||||
// Translated notification texts per event type
|
||||
interface EventText { title: string; body: string }
|
||||
type EventTextFn = (params: Record<string, string>) => EventText
|
||||
|
||||
const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
en: {
|
||||
trip_invite: p => ({ title: `You've been invited to "${p.trip}"`, body: `${p.actor} invited you to the trip "${p.trip}". Open TREK to view and start planning!` }),
|
||||
booking_change: p => ({ title: `New booking: ${p.booking}`, body: `${p.actor} added a new ${p.type} "${p.booking}" to "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Trip reminder: ${p.trip}`, body: `Your trip "${p.trip}" is coming up soon!` }),
|
||||
vacay_invite: p => ({ title: 'Vacay Fusion Invite', body: `${p.actor} invited you to fuse vacation plans. Open TREK to accept or decline.` }),
|
||||
photos_shared: p => ({ title: `${p.count} photos shared`, body: `${p.actor} shared ${p.count} photo(s) in "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }),
|
||||
},
|
||||
de: {
|
||||
trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat dich zur Reise "${p.trip}" eingeladen. Öffne TREK um die Planung zu starten!` }),
|
||||
booking_change: p => ({ title: `Neue Buchung: ${p.booking}`, body: `${p.actor} hat eine neue Buchung "${p.booking}" (${p.type}) zu "${p.trip}" hinzugefügt.` }),
|
||||
trip_reminder: p => ({ title: `Reiseerinnerung: ${p.trip}`, body: `Deine Reise "${p.trip}" steht bald an!` }),
|
||||
vacay_invite: p => ({ title: 'Vacay Fusion-Einladung', body: `${p.actor} hat dich eingeladen, Urlaubspläne zu fusionieren. Öffne TREK um anzunehmen oder abzulehnen.` }),
|
||||
photos_shared: p => ({ title: `${p.count} Fotos geteilt`, body: `${p.actor} hat ${p.count} Foto(s) in "${p.trip}" geteilt.` }),
|
||||
collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }),
|
||||
},
|
||||
fr: {
|
||||
trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} vous a invité au voyage "${p.trip}". Ouvrez TREK pour commencer la planification !` }),
|
||||
booking_change: p => ({ title: `Nouvelle réservation : ${p.booking}`, body: `${p.actor} a ajouté une réservation "${p.booking}" (${p.type}) à "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Rappel de voyage : ${p.trip}`, body: `Votre voyage "${p.trip}" approche !` }),
|
||||
vacay_invite: p => ({ title: 'Invitation Vacay Fusion', body: `${p.actor} vous invite à fusionner les plans de vacances. Ouvrez TREK pour accepter ou refuser.` }),
|
||||
photos_shared: p => ({ title: `${p.count} photos partagées`, body: `${p.actor} a partagé ${p.count} photo(s) dans "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }),
|
||||
},
|
||||
es: {
|
||||
trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} te invitó al viaje "${p.trip}". ¡Abre TREK para comenzar a planificar!` }),
|
||||
booking_change: p => ({ title: `Nueva reserva: ${p.booking}`, body: `${p.actor} añadió una reserva "${p.booking}" (${p.type}) a "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Recordatorio: ${p.trip}`, body: `¡Tu viaje "${p.trip}" se acerca!` }),
|
||||
vacay_invite: p => ({ title: 'Invitación Vacay Fusion', body: `${p.actor} te invitó a fusionar planes de vacaciones. Abre TREK para aceptar o rechazar.` }),
|
||||
photos_shared: p => ({ title: `${p.count} fotos compartidas`, body: `${p.actor} compartió ${p.count} foto(s) en "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }),
|
||||
},
|
||||
nl: {
|
||||
trip_invite: p => ({ title: `Uitgenodigd voor "${p.trip}"`, body: `${p.actor} heeft je uitgenodigd voor de reis "${p.trip}". Open TREK om te beginnen met plannen!` }),
|
||||
booking_change: p => ({ title: `Nieuwe boeking: ${p.booking}`, body: `${p.actor} heeft een boeking "${p.booking}" (${p.type}) toegevoegd aan "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Reisherinnering: ${p.trip}`, body: `Je reis "${p.trip}" komt eraan!` }),
|
||||
vacay_invite: p => ({ title: 'Vacay Fusion uitnodiging', body: `${p.actor} nodigt je uit om vakantieplannen te fuseren. Open TREK om te accepteren of af te wijzen.` }),
|
||||
photos_shared: p => ({ title: `${p.count} foto's gedeeld`, body: `${p.actor} heeft ${p.count} foto('s) gedeeld in "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }),
|
||||
},
|
||||
ru: {
|
||||
trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил вас в поездку "${p.trip}". Откройте TREK чтобы начать планирование!` }),
|
||||
booking_change: p => ({ title: `Новое бронирование: ${p.booking}`, body: `${p.actor} добавил бронирование "${p.booking}" (${p.type}) в "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Напоминание: ${p.trip}`, body: `Ваша поездка "${p.trip}" скоро начнётся!` }),
|
||||
vacay_invite: p => ({ title: 'Приглашение Vacay Fusion', body: `${p.actor} приглашает вас объединить планы отпуска. Откройте TREK для подтверждения.` }),
|
||||
photos_shared: p => ({ title: `${p.count} фото`, body: `${p.actor} поделился ${p.count} фото в "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }),
|
||||
},
|
||||
zh: {
|
||||
trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请你加入旅行"${p.trip}"。打开 TREK 开始规划!` }),
|
||||
booking_change: p => ({ title: `新预订:${p.booking}`, body: `${p.actor} 在"${p.trip}"中添加了预订"${p.booking}"(${p.type})。` }),
|
||||
trip_reminder: p => ({ title: `旅行提醒:${p.trip}`, body: `你的旅行"${p.trip}"即将开始!` }),
|
||||
vacay_invite: p => ({ title: 'Vacay 融合邀请', body: `${p.actor} 邀请你合并假期计划。打开 TREK 接受或拒绝。` }),
|
||||
photos_shared: p => ({ title: `${p.count} 张照片已分享`, body: `${p.actor} 在"${p.trip}"中分享了 ${p.count} 张照片。` }),
|
||||
collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}:${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }),
|
||||
},
|
||||
ar: {
|
||||
trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعاك إلى الرحلة "${p.trip}". افتح TREK لبدء التخطيط!` }),
|
||||
booking_change: p => ({ title: `حجز جديد: ${p.booking}`, body: `${p.actor} أضاف حجز "${p.booking}" (${p.type}) إلى "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `تذكير: ${p.trip}`, body: `رحلتك "${p.trip}" تقترب!` }),
|
||||
vacay_invite: p => ({ title: 'دعوة دمج الإجازة', body: `${p.actor} يدعوك لدمج خطط الإجازة. افتح TREK للقبول أو الرفض.` }),
|
||||
photos_shared: p => ({ title: `${p.count} صور مشتركة`, body: `${p.actor} شارك ${p.count} صورة في "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `قائمة التعبئة: ${p.category}`, body: `${p.actor} عيّنك في فئة "${p.category}" في "${p.trip}".` }),
|
||||
},
|
||||
};
|
||||
|
||||
// Get localized event text
|
||||
function getEventText(lang: string, event: EventType, params: Record<string, string>): EventText {
|
||||
const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en;
|
||||
return texts[event](params);
|
||||
}
|
||||
|
||||
// ── Email HTML builder ─────────────────────────────────────────────────────
|
||||
|
||||
function buildEmailHtml(subject: string, body: string, lang: string): string {
|
||||
const s = I18N[lang] || I18N.en;
|
||||
const appUrl = getAppUrl();
|
||||
const ctaHref = appUrl || '#';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f3f4f6; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f3f4f6; padding: 40px 20px;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px; background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.06);">
|
||||
<!-- Header -->
|
||||
<tr><td style="background: linear-gradient(135deg, #000000 0%, #1a1a2e 100%); padding: 32px 32px 28px; text-align: center;">
|
||||
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj4NCiAgPGRlZnM+DQogICAgPGxpbmVhckdyYWRpZW50IGlkPSJiZyIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzFlMjkzYiIvPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMGYxNzJhIi8+DQogICAgPC9saW5lYXJHcmFkaWVudD4NCiAgICA8Y2xpcFBhdGggaWQ9Imljb24iPg0KICAgICAgPHBhdGggZD0iTSA4NTUuNjM2NzE5IDY5OS4yMDMxMjUgTCAyMjIuMjQ2MDk0IDY5OS4yMDMxMjUgQyAxOTcuNjc5Njg4IDY5OS4yMDMxMjUgMTc5LjkwNjI1IDY3NS43NSAxODYuNTM5MDYyIDY1Mi4xMDE1NjIgTCAzNjAuNDI5Njg4IDMyLjM5MDYyNSBDIDM2NC45MjE4NzUgMTYuMzg2NzE5IDM3OS41MTE3MTkgNS4zMjgxMjUgMzk2LjEzMjgxMiA1LjMyODEyNSBMIDEwMjkuNTI3MzQ0IDUuMzI4MTI1IEMgMTA1NC4wODk4NDQgNS4zMjgxMjUgMTA3MS44NjcxODggMjguNzc3MzQ0IDEwNjUuMjMwNDY5IDUyLjQyOTY4OCBMIDg5MS4zMzk4NDQgNjcyLjEzNjcxOSBDIDg4Ni44NTE1NjIgNjg4LjE0MDYyNSA4NzIuMjU3ODEyIDY5OS4yMDMxMjUgODU1LjYzNjcxOSA2OTkuMjAzMTI1IFogTSA0NDQuMjM4MjgxIDExNjYuOTgwNDY5IEwgNTMzLjc3MzQzOCA4NDcuODk4NDM4IEMgNTQwLjQxMDE1NiA4MjQuMjQ2MDk0IDUyMi42MzI4MTIgODAwLjc5Njg3NSA0OTguMDcwMzEyIDgwMC43OTY4NzUgTCAxNzIuNDcyNjU2IDgwMC43OTY4NzUgQyAxNTUuODUxNTYyIDgwMC43OTY4NzUgMTQxLjI2MTcxOSA4MTEuODU1NDY5IDEzNi43Njk1MzEgODI3Ljg1OTM3NSBMIDQ3LjIzNDM3NSAxMTQ2Ljk0MTQwNiBDIDQwLjU5NzY1NiAxMTcwLjU5Mzc1IDU4LjM3NSAxMTk0LjA0Mjk2OSA4Mi45Mzc1IDExOTQuMDQyOTY5IEwgNDA4LjUzNTE1NiAxMTk0LjA0Mjk2OSBDIDQyNS4xNTYyNSAxMTk0LjA0Mjk2OSA0MzkuNzUgMTE4Mi45ODQzNzUgNDQ0LjIzODI4MSAxMTY2Ljk4MDQ2OSBaIE0gNjA5LjAwMzkwNiA4MjcuODU5Mzc1IEwgNDM1LjExMzI4MSAxNDQ3LjU3MDMxMiBDIDQyOC40NzY1NjIgMTQ3MS4yMTg3NSA0NDYuMjUzOTA2IDE0OTQuNjcxODc1IDQ3MC44MTY0MDYgMTQ5NC42NzE4NzUgTCAxMTA0LjIxMDkzOCAxNDk0LjY3MTg3NSBDIDExMjAuODMyMDMxIDE0OTQuNjcxODc1IDExMzUuNDIxODc1IDE0ODMuNjA5Mzc1IDExMzkuOTE0MDYyIDE0NjcuNjA1NDY5IEwgMTMxMy44MDQ2ODggODQ3Ljg5ODQzOCBDIDEzMjAuNDQxNDA2IDgyNC4yNDYwOTQgMTMwMi42NjQwNjIgODAwLjc5Njg3NSAxMjc4LjEwMTU2MiA4MDAuNzk2ODc1IEwgNjQ0LjcwNzAzMSA4MDAuNzk2ODc1IEMgNjI4LjA4NTkzOCA4MDAuNzk2ODc1IDYxMy40OTIxODggODExLjg1NTQ2OSA2MDkuMDAzOTA2IDgyNy44NTkzNzUgWiBNIDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgTCA5NjYuNTcwMzEyIDY1Mi4xMDE1NjIgQyA5NTkuOTMzNTk0IDY3NS43NSA5NzcuNzEwOTM4IDY5OS4yMDMxMjUgMTAwMi4yNzM0MzggNjk5LjIwMzEyNSBMIDEzMjcuODcxMDk0IDY5OS4yMDMxMjUgQyAxMzQ0LjQ5MjE4OCA2OTkuMjAzMTI1IDEzNTkuMDg1OTM4IDY4OC4xNDA2MjUgMTM2My41NzQyMTkgNjcyLjEzNjcxOSBMIDE0NTMuMTA5Mzc1IDM1My4wNTQ2ODggQyAxNDU5Ljc0NjA5NCAzMjkuNDA2MjUgMTQ0MS45Njg3NSAzMDUuOTUzMTI1IDE0MTcuNDA2MjUgMzA1Ljk1MzEyNSBMIDEwOTEuODA4NTk0IDMwNS45NTMxMjUgQyAxMDc1LjE4NzUgMzA1Ljk1MzEyNSAxMDYwLjU5NzY1NiAzMTcuMDE1NjI1IDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgWiIvPg0KICAgIDwvY2xpcFBhdGg+DQogIDwvZGVmcz4NCiAgPHJlY3Qgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIGZpbGw9InVybCgjYmcpIi8+DQogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDU2LDUxKSBzY2FsZSgwLjI2NykiPg0KICAgIDxyZWN0IHdpZHRoPSIxNTAwIiBoZWlnaHQ9IjE1MDAiIGZpbGw9IiNmZmZmZmYiIGNsaXAtcGF0aD0idXJsKCNpY29uKSIvPg0KICA8L2c+DQo8L3N2Zz4NCg==" alt="TREK" width="48" height="48" style="border-radius: 14px; margin-bottom: 14px; display: block; margin-left: auto; margin-right: auto;" />
|
||||
<div style="color: #ffffff; font-size: 24px; font-weight: 700; letter-spacing: -0.5px;">TREK</div>
|
||||
<div style="color: rgba(255,255,255,0.4); font-size: 10px; font-weight: 500; letter-spacing: 2px; text-transform: uppercase; margin-top: 4px;">Travel Resource & Exploration Kit</div>
|
||||
</td></tr>
|
||||
<!-- Content -->
|
||||
<tr><td style="padding: 32px 32px 16px;">
|
||||
<h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 700; color: #111827; line-height: 1.3;">${subject}</h1>
|
||||
<div style="width: 32px; height: 3px; background: #111827; border-radius: 2px; margin-bottom: 20px;"></div>
|
||||
<p style="margin: 0; font-size: 14px; color: #4b5563; line-height: 1.7; white-space: pre-wrap;">${body}</p>
|
||||
</td></tr>
|
||||
<!-- CTA -->
|
||||
${appUrl ? `<tr><td style="padding: 8px 32px 32px; text-align: center;">
|
||||
<a href="${ctaHref}" style="display: inline-block; padding: 12px 28px; background: #111827; color: #ffffff; font-size: 13px; font-weight: 600; text-decoration: none; border-radius: 10px; letter-spacing: 0.2px;">${s.openTrek}</a>
|
||||
</td></tr>` : ''}
|
||||
<!-- Footer -->
|
||||
<tr><td style="padding: 20px 32px; background: #f9fafb; border-top: 1px solid #f3f4f6; text-align: center;">
|
||||
<p style="margin: 0 0 8px; font-size: 11px; color: #9ca3af; line-height: 1.5;">${s.footer}<br>${s.manage}</p>
|
||||
<p style="margin: 0; font-size: 10px; color: #d1d5db;">${s.madeWith} <span style="color: #ef4444;">♥</span> by Maurice · <a href="https://github.com/mauriceboe/TREK" style="color: #9ca3af; text-decoration: none;">GitHub</a></p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ── Send functions ─────────────────────────────────────────────────────────
|
||||
|
||||
async function sendEmail(to: string, subject: string, body: string, userId?: number): Promise<boolean> {
|
||||
const config = getSmtpConfig();
|
||||
if (!config) return false;
|
||||
|
||||
const lang = userId ? getUserLanguage(userId) : 'en';
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: config.from,
|
||||
to,
|
||||
subject: `TREK — ${subject}`,
|
||||
text: body,
|
||||
html: buildEmailHtml(subject, body, lang),
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Notifications] Email send failed:', err instanceof Error ? err.message : err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendWebhook(payload: { event: string; title: string; body: string; tripName?: string }): Promise<boolean> {
|
||||
const url = getWebhookUrl();
|
||||
if (!url) return false;
|
||||
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Notifications] Webhook failed:', err instanceof Error ? err.message : err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function notify(payload: NotificationPayload): Promise<void> {
|
||||
const prefs = getUserPrefs(payload.userId);
|
||||
const prefKey = EVENT_PREF_MAP[payload.event];
|
||||
if (prefKey && !prefs[prefKey]) return;
|
||||
|
||||
const lang = getUserLanguage(payload.userId);
|
||||
const { title, body } = getEventText(lang, payload.event, payload.params);
|
||||
|
||||
const email = getUserEmail(payload.userId);
|
||||
if (email) await sendEmail(email, title, body, payload.userId);
|
||||
if (prefs.notify_webhook) await sendWebhook({ event: payload.event, title, body, tripName: payload.params.trip });
|
||||
}
|
||||
|
||||
export async function notifyTripMembers(tripId: number, actorUserId: number, event: EventType, params: Record<string, string>): Promise<void> {
|
||||
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
|
||||
if (!trip) return;
|
||||
|
||||
const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(tripId) as { user_id: number }[];
|
||||
const allIds = [trip.user_id, ...members.map(m => m.user_id)].filter(id => id !== actorUserId);
|
||||
const unique = [...new Set(allIds)];
|
||||
|
||||
for (const userId of unique) {
|
||||
await notify({ userId, event, params });
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const sent = await sendEmail(to, 'Test Notification', 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.');
|
||||
return sent ? { success: true } : { success: false, error: 'SMTP not configured' };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user