Merge branch 'main' into feat/mfa

This commit is contained in:
Fernando Bona
2026-03-28 18:59:06 -03:00
committed by GitHub
71 changed files with 4878 additions and 636 deletions
+70 -4
View File
@@ -7,8 +7,19 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps: steps:
- name: Prepare platform tag-safe name
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
@@ -18,8 +29,63 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v6 - name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with: with:
context: . context: .
push: true platforms: ${{ matrix.platform }}
tags: mauriceboe/nomad:latest outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
no-cache: true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- name: Get version from package.json
id: version
run: echo "VERSION=$(node -p "require('./server/package.json').version")" >> $GITHUB_OUTPUT
- name: Download build digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
docker buildx imagetools create \
-t mauriceboe/trek:latest \
-t mauriceboe/trek:${{ steps.version.outputs.VERSION }} \
-t mauriceboe/nomad:latest \
-t mauriceboe/nomad:${{ steps.version.outputs.VERSION }} \
"${digests[@]}"
- name: Inspect manifest
run: docker buildx imagetools inspect mauriceboe/trek:latest
+23 -23
View File
@@ -2,26 +2,26 @@
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" /> <source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
<source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.svg" /> <source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.svg" />
<img src="client/public/logo-light.svg" alt="NOMAD" height="60" /> <img src="client/public/logo-light.svg" alt="TREK" height="60" />
</picture> </picture>
<br /> <br />
<em>Navigation Organizer for Maps, Activities & Destinations</em> <em>Your Trips. Your Plan.</em>
</p> </p>
<p align="center"> <p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a> <a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
<a href="https://hub.docker.com/r/mauriceboe/nomad"><img src="https://img.shields.io/docker/pulls/mauriceboe/nomad" alt="Docker Pulls" /></a> <a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
<a href="https://github.com/mauriceboe/NOMAD"><img src="https://img.shields.io/github/stars/mauriceboe/NOMAD" alt="GitHub Stars" /></a> <a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
<a href="https://github.com/mauriceboe/NOMAD/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/NOMAD" alt="Last Commit" /></a> <a href="https://github.com/mauriceboe/TREK/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/TREK" alt="Last Commit" /></a>
</p> </p>
<p align="center"> <p align="center">
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more. A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
<br /> <br />
<strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try NOMAD without installing. Resets hourly. <strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try TREK without installing. Resets hourly.
</p> </p>
![NOMAD Screenshot](docs/screenshot.png) ![TREK Screenshot](docs/screenshot.png)
![NOMAD Screenshot 2](docs/screenshot-2.png) ![NOMAD Screenshot 2](docs/screenshot-2.png)
<details> <details>
@@ -50,7 +50,7 @@
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support - **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
- **Packing Lists** — Categorized checklists with progress tracking, color coding, and smart suggestions - **Packing Lists** — Categorized checklists with progress tracking, color coding, and smart suggestions
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file) - **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and NOMAD branding - **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and TREK branding
### Mobile & PWA ### Mobile & PWA
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed - **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
@@ -92,19 +92,19 @@
## Quick Start ## Quick Start
```bash ```bash
docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/nomad docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
``` ```
The app runs on port `3000`. The first user to register becomes the admin. The app runs on port `3000`. The first user to register becomes the admin.
### Install as App (PWA) ### Install as App (PWA)
NOMAD works as a Progressive Web App — no App Store needed: TREK works as a Progressive Web App — no App Store needed:
1. Open your NOMAD instance in the browser (HTTPS required) 1. Open your TREK instance in the browser (HTTPS required)
2. **iOS**: Share button → "Add to Home Screen" 2. **iOS**: Share button → "Add to Home Screen"
3. **Android**: Menu → "Install app" or "Add to Home Screen" 3. **Android**: Menu → "Install app" or "Add to Home Screen"
4. NOMAD launches fullscreen with its own icon, just like a native app 4. TREK launches fullscreen with its own icon, just like a native app
<details> <details>
<summary>Docker Compose (recommended for production)</summary> <summary>Docker Compose (recommended for production)</summary>
@@ -112,8 +112,8 @@ NOMAD works as a Progressive Web App — no App Store needed:
```yaml ```yaml
services: services:
app: app:
image: mauriceboe/nomad:latest image: mauriceboe/trek:latest
container_name: nomad container_name: trek
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
@@ -142,20 +142,20 @@ docker compose pull && docker compose up -d
**Docker Run** — use the same volume paths from your original `docker run` command: **Docker Run** — use the same volume paths from your original `docker run` command:
```bash ```bash
docker pull mauriceboe/nomad docker pull mauriceboe/trek
docker rm -f nomad docker rm -f trek
docker run -d --name nomad -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/nomad docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
``` ```
> **Tip:** Not sure which paths you used? Run `docker inspect nomad --format '{{json .Mounts}}'` before removing the container. > **Tip:** Not sure which paths you used? Run `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data. Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
### Reverse Proxy (recommended) ### Reverse Proxy (recommended)
For production, put NOMAD behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik). For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
> **Important:** NOMAD uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path. > **Important:** TREK uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
<details> <details>
<summary>Nginx</summary> <summary>Nginx</summary>
@@ -220,14 +220,14 @@ API keys are configured in the **Admin Panel** after login. Keys set by the admi
1. Go to [Google Cloud Console](https://console.cloud.google.com/) 1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a project and enable the **Places API (New)** 2. Create a project and enable the **Places API (New)**
3. Create an API key under Credentials 3. Create an API key under Credentials
4. In NOMAD: Admin Panel → Settings → Google Maps 4. In TREK: Admin Panel → Settings → Google Maps
## Building from Source ## Building from Source
```bash ```bash
git clone https://github.com/mauriceboe/NOMAD.git git clone https://github.com/mauriceboe/TREK.git
cd NOMAD cd NOMAD
docker build -t nomad . docker build -t trek .
``` ```
## Data & Backups ## Data & Backups
+1 -1
View File
@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
## Scope ## Scope
This policy covers the NOMAD application and its Docker image (`mauriceboe/nomad`). This policy covers the TREK application and its Docker image (`mauriceboe/nomad`).
Third-party dependencies are monitored via GitHub Dependabot. Third-party dependencies are monitored via GitHub Dependabot.
+3 -3
View File
@@ -1,15 +1,15 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>NOMAD</title> <title>TREK</title>
<!-- PWA / iOS --> <!-- PWA / iOS -->
<meta name="theme-color" content="#09090b" /> <meta name="theme-color" content="#09090b" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="NOMAD" /> <meta name="apple-mobile-web-app-title" content="TREK" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" /> <link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
<!-- Favicon --> <!-- Favicon -->
+1473 -10
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "nomad-client", "name": "trek-client",
"version": "2.6.1", "version": "2.6.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -19,8 +19,10 @@
"react-dropzone": "^14.4.1", "react-dropzone": "^14.4.1",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0", "react-leaflet-cluster": "^2.1.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2", "react-router-dom": "^6.22.2",
"react-window": "^2.2.7", "react-window": "^2.2.7",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0", "topojson-client": "^3.1.0",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

+8 -3
View File
@@ -147,8 +147,9 @@ export const addonsApi = {
export const mapsApi = { export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data), search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${placeId}`, { params: { lang } }).then(r => r.data), details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
placePhoto: (placeId: string) => apiClient.get(`/maps/place-photo/${placeId}`).then(r => r.data), placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
} }
export const budgetApi = { export const budgetApi = {
@@ -162,12 +163,16 @@ export const budgetApi = {
} }
export const filesApi = { export const filesApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/files`).then(r => r.data), list: (tripId: number | string, trash?: boolean) => apiClient.get(`/trips/${tripId}/files`, { params: trash ? { trash: 'true' } : {} }).then(r => r.data),
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, { upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
headers: { 'Content-Type': 'multipart/form-data' } headers: { 'Content-Type': 'multipart/form-data' }
}).then(r => r.data), }).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data), update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data),
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
} }
export const reservationsApi = { export const reservationsApi = {
+16 -3
View File
@@ -84,7 +84,7 @@ export default function AddonManager() {
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}> <div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2> <h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}> <p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="NOMAD" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')} {t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
</p> </p>
</div> </div>
@@ -136,8 +136,21 @@ interface AddonRowProps {
t: (key: string) => string t: (key: string) => string
} }
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
const nameKey = `admin.addons.catalog.${addon.id}.name`
const descKey = `admin.addons.catalog.${addon.id}.description`
const translatedName = t(nameKey)
const translatedDescription = t(descKey)
return {
name: translatedName !== nameKey ? translatedName : addon.name,
description: translatedDescription !== descKey ? translatedDescription : addon.description,
}
}
function AddonRow({ addon, onToggle, t }: AddonRowProps) { function AddonRow({ addon, onToggle, t }: AddonRowProps) {
const isComingSoon = false const isComingSoon = false
const label = getAddonLabel(t, addon)
return ( return (
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}> <div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
{/* Icon */} {/* Icon */}
@@ -148,7 +161,7 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
{/* Info */} {/* Info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{addon.name}</span> <span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{label.name}</span>
{isComingSoon && ( {isComingSoon && (
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}> <span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
Coming Soon Coming Soon
@@ -161,7 +174,7 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
{addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')} {addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
</span> </span>
</div> </div>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{addon.description}</p> <p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{label.description}</p>
</div> </div>
{/* Toggle */} {/* Toggle */}
+62 -28
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2 } from 'lucide-react' import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee } from 'lucide-react'
import { useTranslation } from '../../i18n' import { getLocaleForLanguage, useTranslation } from '../../i18n'
import apiClient from '../../api/client'
const REPO = 'mauriceboe/NOMAD' const REPO = 'mauriceboe/NOMAD'
const PER_PAGE = 10 const PER_PAGE = 10
@@ -17,9 +18,8 @@ export default function GitHubPanel() {
const fetchReleases = async (pageNum = 1, append = false) => { const fetchReleases = async (pageNum = 1, append = false) => {
try { try {
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=${PER_PAGE}&page=${pageNum}`) const res = await apiClient.get(`/auth/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
if (!res.ok) throw new Error(`GitHub API: ${res.status}`) const data = res.data
const data = await res.json()
setReleases(prev => append ? [...prev, ...data] : data) setReleases(prev => append ? [...prev, ...data] : data)
setHasMore(data.length === PER_PAGE) setHasMore(data.length === PER_PAGE)
} catch (err: unknown) { } catch (err: unknown) {
@@ -46,7 +46,7 @@ export default function GitHubPanel() {
const formatDate = (dateStr) => { const formatDate = (dateStr) => {
const d = new Date(dateStr) const d = new Date(dateStr)
return d.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' }) return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' })
} }
// Simple markdown-to-html for release notes (handles headers, bold, lists, links) // Simple markdown-to-html for release notes (handles headers, bold, lists, links)
@@ -112,30 +112,63 @@ export default function GitHubPanel() {
return elements return elements
} }
if (loading) {
return (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="p-8 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
</div>
</div>
)
}
if (error) {
return (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="p-6 text-center">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
</div>
</div>
)
}
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Header card */} {/* Support cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<a
href="https://ko-fi.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ff5e5b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Coffee size={20} style={{ color: '#ff5e5b' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{language === 'de' ? 'Hilft mir, TREK weiterzuentwickeln' : 'Helps me keep building TREK'}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://buymeacoffee.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ffdd0015', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Heart size={20} style={{ color: '#ffdd00' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{language === 'de' ? 'Hilft mir, TREK weiterzuentwickeln' : 'Helps me keep building TREK'}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
</div>
{/* Loading / Error / Releases */}
{loading ? (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="p-8 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
</div>
</div>
) : error ? (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="p-6 text-center">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
</div>
</div>
) : (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}> <div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}>
<div> <div>
@@ -258,6 +291,7 @@ export default function GitHubPanel() {
)} )}
</div> </div>
</div> </div>
)}
</div> </div>
) )
} }
+5 -3
View File
@@ -7,6 +7,7 @@ import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check } from 'lucide-r
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { budgetApi } from '../../api/client' import { budgetApi } from '../../api/client'
import type { BudgetItem, BudgetMember } from '../../types' import type { BudgetItem, BudgetMember } from '../../types'
import { currencyDecimals } from '../../utils/formatters'
interface TripMember { interface TripMember {
id: number id: number
@@ -34,7 +35,8 @@ const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5
const fmtNum = (v, locale, cur) => { const fmtNum = (v, locale, cur) => {
if (v == null || isNaN(v)) return '-' if (v == null || isNaN(v)) return '-'
return Number(v).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ' + (SYMBOLS[cur] || cur) const d = currencyDecimals(cur)
return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
} }
const calcPP = (p, n) => (n > 0 ? p / n : null) const calcPP = (p, n) => (n > 0 ? p / n : null)
@@ -543,7 +545,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
)} )}
</td> </td>
<td style={{ ...td, textAlign: 'center' }}> <td style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.total_price} type="number" onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder="0,00" locale={locale} editTooltip={t('budget.editTooltip')} /> <InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} />
</td> </td>
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}> <td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
{hasMultipleMembers ? ( {hasMultipleMembers ? (
@@ -620,7 +622,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
</div> </div>
</div> </div>
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}> <div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
</div> </div>
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div> <div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && ( {hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
+79 -8
View File
@@ -1,7 +1,9 @@
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import DOM from 'react-dom' import DOM from 'react-dom'
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink } from 'lucide-react' import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react'
import { collabApi } from '../../api/client' import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket' import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
@@ -412,7 +414,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
outline: 'none', outline: 'none',
boxSizing: 'border-box', boxSizing: 'border-box',
resize: 'vertical', resize: 'vertical',
minHeight: 90, minHeight: 180,
lineHeight: 1.5, lineHeight: 1.5,
}} }}
/> />
@@ -690,13 +692,14 @@ interface NoteCardProps {
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void> onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
onDelete: (noteId: number) => Promise<void> onDelete: (noteId: number) => Promise<void>
onEdit: (note: CollabNote) => void onEdit: (note: CollabNote) => void
onView: (note: CollabNote) => void
onPreviewFile: (file: NoteFile) => void onPreviewFile: (file: NoteFile) => void
getCategoryColor: (category: string) => string getCategoryColor: (category: string) => string
tripId: number tripId: number
t: (key: string) => string t: (key: string) => string
} }
function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) { function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) } const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
@@ -749,6 +752,14 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile
<div style={{ <div style={{
display: 'flex', gap: 2, display: 'flex', gap: 2,
}}> }}>
{note.content && (
<button onClick={() => onView?.(note)} title={t('collab.notes.expand') || 'Expand'}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Maximize2 size={10} />
</button>
)}
<button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')} <button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = color} onMouseEnter={e => e.currentTarget.style.color = color}
@@ -799,13 +810,13 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{note.content && ( {note.content && (
<p style={{ <div className="collab-note-md" style={{
fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, margin: 0, fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', maxHeight: '4.5em', overflow: 'hidden',
overflow: 'hidden', wordBreak: 'break-word', fontFamily: FONT, wordBreak: 'break-word', fontFamily: FONT,
}}> }}>
{note.content} <Markdown remarkPlugins={[remarkGfm]}>{note.content}</Markdown>
</p> </div>
)} )}
</div> </div>
{/* Right: website + attachment thumbnails */} {/* Right: website + attachment thumbnails */}
@@ -872,6 +883,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showNewModal, setShowNewModal] = useState(false) const [showNewModal, setShowNewModal] = useState(false)
const [editingNote, setEditingNote] = useState(null) const [editingNote, setEditingNote] = useState(null)
const [viewingNote, setViewingNote] = useState<CollabNote | null>(null)
const [previewFile, setPreviewFile] = useState(null) const [previewFile, setPreviewFile] = useState(null)
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [activeCategory, setActiveCategory] = useState(null) const [activeCategory, setActiveCategory] = useState(null)
@@ -1243,6 +1255,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
onUpdate={handleUpdateNote} onUpdate={handleUpdateNote}
onDelete={handleDeleteNote} onDelete={handleDeleteNote}
onEdit={setEditingNote} onEdit={setEditingNote}
onView={setViewingNote}
onPreviewFile={setPreviewFile} onPreviewFile={setPreviewFile}
getCategoryColor={getCategoryColor} getCategoryColor={getCategoryColor}
tripId={tripId} tripId={tripId}
@@ -1254,6 +1267,64 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
</div> </div>
{/* ── New Note Modal ── */} {/* ── New Note Modal ── */}
{/* View note modal */}
{viewingNote && ReactDOM.createPortal(
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10000, padding: 16,
}}
onClick={() => setViewingNote(null)}
>
<div
style={{
background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
width: 'min(700px, calc(100vw - 32px))', maxHeight: '80vh',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}
onClick={e => e.stopPropagation()}
>
<div style={{
padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 17, fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
{viewingNote.category && (
<span style={{
display: 'inline-block', marginTop: 4, fontSize: 10, fontWeight: 600,
color: getCategoryColor(viewingNote.category),
background: `${getCategoryColor(viewingNote.category)}18`,
padding: '2px 8px', borderRadius: 6,
}}>{viewingNote.category}</span>
)}
</div>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
<button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={16} />
</button>
<button onClick={() => setViewingNote(null)}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<X size={18} />
</button>
</div>
</div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
<Markdown remarkPlugins={[remarkGfm]}>{viewingNote.content || ''}</Markdown>
</div>
</div>
</div>,
document.body
)}
{showNewModal && ( {showNewModal && (
<NoteFormModal <NoteFormModal
onClose={() => setShowNewModal(false)} onClose={() => setShowNewModal(false)}
@@ -23,9 +23,9 @@ const POPULAR_ZONES = [
{ label: 'Cairo', tz: 'Africa/Cairo' }, { label: 'Cairo', tz: 'Africa/Cairo' },
] ]
function getTime(tz) { function getTime(tz, locale) {
try { try {
return new Date().toLocaleTimeString('de-DE', { timeZone: tz, hour: '2-digit', minute: '2-digit' }) return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit' })
} catch { return '—' } } catch { return '—' }
} }
@@ -41,7 +41,7 @@ function getOffset(tz) {
} }
export default function TimezoneWidget() { export default function TimezoneWidget() {
const { t } = useTranslation() const { t, locale } = useTranslation()
const [zones, setZones] = useState(() => { const [zones, setZones] = useState(() => {
const saved = localStorage.getItem('dashboard_timezones') const saved = localStorage.getItem('dashboard_timezones')
return saved ? JSON.parse(saved) : [ return saved ? JSON.parse(saved) : [
@@ -70,7 +70,7 @@ export default function TimezoneWidget() {
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz)) const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
const localTime = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
const localZone = rawZone.split('/').pop().replace(/_/g, ' ') const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
// Show abbreviated timezone name (e.g. CET, CEST, EST) // Show abbreviated timezone name (e.g. CET, CEST, EST)
@@ -96,7 +96,7 @@ export default function TimezoneWidget() {
{zones.map(z => ( {zones.map(z => (
<div key={z.tz} className="flex items-center justify-between group"> <div key={z.tz} className="flex items-center justify-between group">
<div> <div>
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz)}</p> <p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale)}</p>
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p> <p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
</div> </div>
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}> <button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
@@ -116,7 +116,7 @@ export default function TimezoneWidget() {
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<span className="font-medium">{z.label}</span> <span className="font-medium">{z.label}</span>
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz)}</span> <span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale)}</span>
</button> </button>
))} ))}
</div> </div>
+500 -172
View File
@@ -1,15 +1,15 @@
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { useState, useCallback } from 'react' import { useState, useCallback, useRef } from 'react'
import DOM from 'react-dom'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote } from 'lucide-react' import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import type { Place, Reservation, TripFile } from '../../types' import { filesApi } from '../../api/client'
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
function isImage(mimeType) { function isImage(mimeType) {
if (!mimeType) return false if (!mimeType) return false
return mimeType.startsWith('image/') // covers jpg, png, gif, webp, etc. return mimeType.startsWith('image/')
} }
function getFileIcon(mimeType) { function getFileIcon(mimeType) {
@@ -68,7 +68,7 @@ function ImageLightbox({ file, onClose }: ImageLightboxProps) {
) )
} }
// Source badge — unified style for both place and reservation // Source badge
interface SourceBadgeProps { interface SourceBadgeProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }> icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
label: string label: string
@@ -89,40 +89,156 @@ function SourceBadge({ icon: Icon, label }: SourceBadgeProps) {
) )
} }
function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?: string | null; size?: number }) {
const [hover, setHover] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const ref = useRef<HTMLDivElement>(null)
const onEnter = () => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect()
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
}
setHover(true)
}
return (
<>
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
style={{
width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)',
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
cursor: 'default',
}}>
{avatarUrl
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: name?.[0]?.toUpperCase()
}
</div>
{hover && ReactDOM.createPortal(
<div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
background: 'var(--bg-elevated)', color: 'var(--text-primary)',
fontSize: 11, fontWeight: 500, padding: '3px 8px', borderRadius: 6,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', whiteSpace: 'nowrap', zIndex: 9999,
pointerEvents: 'none',
}}>
{name}
</div>,
document.body
)}
</>
)
}
interface FileManagerProps { interface FileManagerProps {
files?: TripFile[] files?: TripFile[]
onUpload: (fd: FormData) => Promise<void> onUpload: (fd: FormData) => Promise<any>
onDelete: (fileId: number) => Promise<void> onDelete: (fileId: number) => Promise<void>
onUpdate: (fileId: number, data: Partial<TripFile>) => Promise<void> onUpdate: (fileId: number, data: Partial<TripFile>) => Promise<void>
places: Place[] places: Place[]
days?: Day[]
assignments?: AssignmentsMap
reservations?: Reservation[] reservations?: Reservation[]
tripId: number tripId: number
allowedFileTypes: Record<string, string[]> allowedFileTypes: Record<string, string[]>
} }
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId, allowedFileTypes }: FileManagerProps) { export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [filterType, setFilterType] = useState('all') const [filterType, setFilterType] = useState('all')
const [lightboxFile, setLightboxFile] = useState(null) const [lightboxFile, setLightboxFile] = useState(null)
const [showTrash, setShowTrash] = useState(false)
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
const [loadingTrash, setLoadingTrash] = useState(false)
const toast = useToast() const toast = useToast()
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const loadTrash = useCallback(async () => {
setLoadingTrash(true)
try {
const data = await filesApi.list(tripId, true)
setTrashFiles(data.files || [])
} catch { /* */ }
setLoadingTrash(false)
}, [tripId])
const toggleTrash = useCallback(() => {
if (!showTrash) loadTrash()
setShowTrash(v => !v)
}, [showTrash, loadTrash])
const refreshFiles = useCallback(async () => {
if (onUpdate) onUpdate(0, {} as any)
}, [onUpdate])
const handleStar = async (fileId: number) => {
try {
await filesApi.toggleStar(tripId, fileId)
refreshFiles()
} catch { /* */ }
}
const handleRestore = async (fileId: number) => {
try {
await filesApi.restore(tripId, fileId)
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
refreshFiles()
toast.success(t('files.toast.restored'))
} catch {
toast.error(t('files.toast.restoreError'))
}
}
const handlePermanentDelete = async (fileId: number) => {
if (!confirm(t('files.confirm.permanentDelete'))) return
try {
await filesApi.permanentDelete(tripId, fileId)
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
toast.success(t('files.toast.deleted'))
} catch {
toast.error(t('files.toast.deleteError'))
}
}
const handleEmptyTrash = async () => {
if (!confirm(t('files.confirm.emptyTrash'))) return
try {
await filesApi.emptyTrash(tripId)
setTrashFiles([])
toast.success(t('files.toast.trashEmptied') || 'Trash emptied')
} catch {
toast.error(t('files.toast.deleteError'))
}
}
const [lastUploadedIds, setLastUploadedIds] = useState<number[]>([])
const onDrop = useCallback(async (acceptedFiles) => { const onDrop = useCallback(async (acceptedFiles) => {
if (acceptedFiles.length === 0) return if (acceptedFiles.length === 0) return
setUploading(true) setUploading(true)
const uploadedIds: number[] = []
try { try {
for (const file of acceptedFiles) { for (const file of acceptedFiles) {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
await onUpload(formData) const result = await onUpload(formData)
const fileObj = result?.file || result
if (fileObj?.id) uploadedIds.push(fileObj.id)
} }
toast.success(t('files.uploaded', { count: acceptedFiles.length })) toast.success(t('files.uploaded', { count: acceptedFiles.length }))
// Open assign modal for the last uploaded file
const lastId = uploadedIds[uploadedIds.length - 1]
if (lastId && (places.length > 0 || reservations.length > 0)) {
setAssignFileId(lastId)
}
} catch { } catch {
toast.error(t('files.uploadError')) toast.error(t('files.uploadError'))
} finally { } finally {
setUploading(false) setUploading(false)
} }
}, [onUpload, toast, t]) }, [onUpload, toast, t, places, reservations])
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop, onDrop,
@@ -130,24 +246,24 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
noClick: false, noClick: false,
}) })
// Paste support
const handlePaste = useCallback((e) => { const handlePaste = useCallback((e) => {
const items = e.clipboardData?.items const items = e.clipboardData?.items
if (!items) return if (!items) return
const files = [] const pastedFiles = []
for (const item of Array.from(items)) { for (const item of Array.from(items)) {
if (item.kind === 'file') { if (item.kind === 'file') {
const file = item.getAsFile() const file = item.getAsFile()
if (file) files.push(file) if (file) pastedFiles.push(file)
} }
} }
if (files.length > 0) { if (pastedFiles.length > 0) {
e.preventDefault() e.preventDefault()
onDrop(files) onDrop(pastedFiles)
} }
}, [onDrop]) }, [onDrop])
const filteredFiles = files.filter(f => { const filteredFiles = files.filter(f => {
if (filterType === 'starred') return !!f.starred
if (filterType === 'pdf') return f.mime_type === 'application/pdf' if (filterType === 'pdf') return f.mime_type === 'application/pdf'
if (filterType === 'image') return isImage(f.mime_type) if (filterType === 'image') return isImage(f.mime_type)
if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text') if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text')
@@ -156,16 +272,25 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
}) })
const handleDelete = async (id) => { const handleDelete = async (id) => {
if (!confirm(t('files.confirm.delete'))) return
try { try {
await onDelete(id) await onDelete(id)
toast.success(t('files.toast.deleted')) toast.success(t('files.toast.trashed') || 'Moved to trash')
} catch { } catch {
toast.error(t('files.toast.deleteError')) toast.error(t('files.toast.deleteError'))
} }
} }
const [previewFile, setPreviewFile] = useState(null) const [previewFile, setPreviewFile] = useState(null)
const [assignFileId, setAssignFileId] = useState<number | null>(null)
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
try {
await filesApi.update(tripId, fileId, data)
refreshFiles()
} catch {
toast.error(t('files.toast.assignError'))
}
}
const openFile = (file) => { const openFile = (file) => {
if (isImage(file.mime_type)) { if (isImage(file.mime_type)) {
@@ -175,12 +300,259 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
} }
} }
const renderFileRow = (file: TripFile, isTrash = false) => {
const FileIcon = getFileIcon(file.mime_type)
const linkedPlace = places?.find(p => p.id === file.place_id)
const linkedReservation = file.reservation_id
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
: null
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
return (
<div key={file.id} style={{
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
transition: 'border-color 0.12s',
opacity: isTrash ? 0.7 : 1,
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
className="group"
>
{/* Icon or thumbnail */}
<div
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
style={{
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: isTrash ? 'default' : 'pointer', overflow: 'hidden',
}}
>
{isImage(file.mime_type)
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: (() => {
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
const isPdf = file.mime_type === 'application/pdf'
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
<span style={{ fontSize: 9, fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div>
)
})()
}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
{file.uploaded_by_name && (
<AvatarChip name={file.uploaded_by_name} avatarUrl={file.uploaded_by_avatar} size={20} />
)}
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
<span
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
>
{file.original_name}
</span>
</div>
{file.description && (
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '2px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
)}
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{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')}`} />
)}
{file.note_id && (
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
)}
</div>
</div>
{/* Actions — always visible on mobile, hover on desktop */}
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{isTrash ? (
<>
<button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<RotateCcw size={14} />
</button>
<button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={14} />
</button>
</>
) : (
<>
<button onClick={() => handleStar(file.id)} title={file.starred ? t('files.unstar') || 'Unstar' : t('files.star') || 'Star'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: file.starred ? '#facc15' : 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
</button>
<button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={14} />
</button>
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ExternalLink size={14} />
</button>
<button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={14} />
</button>
</>
)}
</div>
</div>
)
}
return ( return (
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}> <div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
{/* Lightbox */} {/* Lightbox */}
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />} {lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
{/* Datei-Vorschau Modal — portal to body to escape stacking context */} {/* Assign modal */}
{assignFileId && ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 5000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setAssignFileId(null)}>
<div style={{
background: 'var(--bg-card)', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
width: 'min(600px, calc(100vw - 32px))', maxHeight: '70vh', overflow: 'hidden', display: 'flex', flexDirection: 'column',
}} onClick={e => e.stopPropagation()}>
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('files.assignTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{files.find(f => f.id === assignFileId)?.original_name || ''}
</div>
</div>
<button onClick={() => setAssignFileId(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4, display: 'flex', flexShrink: 0 }}>
<X size={18} />
</button>
</div>
<div style={{ padding: '8px 12px 0' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '0 2px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('files.noteLabel') || 'Note'}
</div>
<input
type="text"
placeholder={t('files.notePlaceholder')}
defaultValue={files.find(f => f.id === assignFileId)?.description || ''}
onBlur={e => {
const val = e.target.value.trim()
const file = files.find(f => f.id === assignFileId)
if (file && val !== (file.description || '')) {
handleAssign(file.id, { description: val } as any)
}
}}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
style={{
width: '100%', padding: '7px 10px', fontSize: 13, borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
}}
/>
</div>
<div style={{ overflowY: 'auto', padding: 8 }}>
{(() => {
const file = files.find(f => f.id === assignFileId)
if (!file) return null
const assignedPlaceIds = new Set<number>()
const dayGroups: { day: Day; dayPlaces: Place[] }[] = []
for (const day of days) {
const da = assignments[String(day.id)] || []
const dayPlaces = da.map(a => places.find(p => p.id === a.place?.id || p.id === a.place_id)).filter(Boolean) as Place[]
if (dayPlaces.length > 0) {
dayGroups.push({ day, dayPlaces })
dayPlaces.forEach(p => assignedPlaceIds.add(p.id))
}
}
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
const placeBtn = (p: Place) => (
<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 placesSection = places.length > 0 && (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('files.assignPlace')}
</div>
{dayGroups.map(({ day, dayPlaces }) => (
<div key={day.id}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
{day.title || `${t('dayplan.dayN', { n: day.day_number })}${day.date ? ` · ${day.date}` : ''}`}
</div>
{dayPlaces.map(placeBtn)}
</div>
))}
{unassigned.length > 0 && (
<div>
{dayGroups.length > 0 && <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>{t('files.unassigned')}</div>}
{unassigned.map(placeBtn)}
</div>
)}
</div>
)
const bookingsSection = reservations.length > 0 && (
<div style={{ flex: 1, minWidth: 0 }}>
<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>
))}
</div>
)
const hasBoth = placesSection && bookingsSection
return (
<div className={hasBoth ? 'md:flex' : ''}>
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingRight: hasBoth ? 6 : 0 }}>{placesSection}</div>
{hasBoth && <div className="hidden md:block" style={{ width: 1, background: 'var(--border-primary)', flexShrink: 0 }} />}
{hasBoth && <div className="block md:hidden" style={{ height: 1, background: 'var(--border-primary)', margin: '8px 0' }} />}
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingLeft: hasBoth ? 6 : 0 }}>{bookingsSection}</div>
</div>
)
})()}
</div>
</div>
</div>,
document.body
)}
{/* PDF preview modal */}
{previewFile && ReactDOM.createPortal( {previewFile && ReactDOM.createPortal(
<div <div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
@@ -225,172 +597,128 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{/* Header */} {/* Header */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}> <div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div> <div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('files.title')}</h2> <h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}> <p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length })} {showTrash
? `${trashFiles.length} ${trashFiles.length === 1 ? 'file' : 'files'}`
: (files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length }))}
</p> </p>
</div> </div>
<button onClick={toggleTrash} style={{
padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)',
background: showTrash ? 'var(--accent)' : 'var(--bg-card)',
color: showTrash ? 'var(--accent-text)' : 'var(--text-muted)',
fontSize: 12, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 5,
fontFamily: 'inherit',
}}>
<Trash2 size={13} /> {t('files.trash') || 'Trash'}
</button>
</div> </div>
{/* Upload zone */} {showTrash ? (
<div /* Trash view */
{...getRootProps()} <div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
style={{ {trashFiles.length > 0 && (
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px', <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s', <button onClick={handleEmptyTrash} style={{
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)', padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)', background: '#fef2f2', color: '#dc2626', fontSize: 12, fontWeight: 500,
}} cursor: 'pointer', fontFamily: 'inherit',
> }}>
<input {...getInputProps()} /> {t('files.emptyTrash') || 'Empty Trash'}
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} /> </button>
{uploading ? ( </div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}> )}
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} /> {loadingTrash ? (
{t('files.uploading')} <div style={{ textAlign: 'center', padding: 40, color: 'var(--text-faint)' }}>
<div style={{ width: 20, height: 20, border: '2px solid var(--text-faint)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto' }} />
</div>
) : trashFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<Trash2 size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.trashEmpty') || 'Trash is empty'}</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{trashFiles.map(file => renderFileRow(file, true))}
</div>
)}
</div>
) : (
<>
{/* Upload zone */}
<div
{...getRootProps()}
style={{
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
}}
>
<input {...getInputProps()} />
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
{uploading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
{t('files.uploading')}
</div>
) : (
<>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
<p style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}>
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
</p>
</>
)}
</div> </div>
) : (
<>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
<p style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}>
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
</p>
</>
)}
</div>
{/* Filter tabs */} {/* Filter tabs */}
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0 }}> <div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
{[ {[
{ id: 'all', label: t('files.filterAll') }, { id: 'all', label: t('files.filterAll') },
{ id: 'pdf', label: t('files.filterPdf') }, ...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
{ id: 'image', label: t('files.filterImages') }, { id: 'pdf', label: t('files.filterPdf') },
{ id: 'doc', label: t('files.filterDocs') }, { id: 'image', label: t('files.filterImages') },
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []), { id: 'doc', label: t('files.filterDocs') },
].map(tab => ( ...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{ ].map(tab => (
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12, <button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
fontFamily: 'inherit', transition: 'all 0.12s', padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
background: filterType === tab.id ? 'var(--accent)' : 'transparent', fontFamily: 'inherit', transition: 'all 0.12s',
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)', background: filterType === tab.id ? 'var(--accent)' : 'transparent',
fontWeight: filterType === tab.id ? 600 : 400, color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
}}>{tab.label}</button> fontWeight: filterType === tab.id ? 600 : 400,
))} }}>{tab.icon ? <tab.icon size={13} fill={filterType === tab.id ? '#facc15' : 'none'} color={filterType === tab.id ? '#facc15' : 'currentColor'} /> : tab.label}</button>
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}> ))}
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })} <span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
</span> {filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
</div> </span>
{/* File list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
{filteredFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
</div> </div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredFiles.map(file => {
const FileIcon = getFileIcon(file.mime_type)
const linkedPlace = places?.find(p => p.id === file.place_id)
const linkedReservation = file.reservation_id
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
: null
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
return ( {/* File list */}
<div key={file.id} style={{ <div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12, {filteredFiles.length === 0 ? (
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10, <div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
transition: 'border-color 0.12s', <FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
}} <p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'} <p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'} </div>
className="group" ) : (
> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* Icon or thumbnail */} {filteredFiles.map(file => renderFileRow(file))}
<div </div>
onClick={() => openFile({ ...file, url: fileUrl })} )}
style={{
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', overflow: 'hidden',
}}
>
{isImage(file.mime_type)
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: (() => {
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
const isPdf = file.mime_type === 'application/pdf'
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
<span style={{ fontSize: 9, fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div>
)
})()
}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
onClick={() => openFile({ ...file, url: fileUrl })}
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}
>
{file.original_name}
</div>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{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')}`}
/>
)}
{file.note_id && (
<SourceBadge
icon={StickyNote}
label={t('files.sourceCollab') || 'Collab Notes'}
/>
)}
</div>
{file.description && !linkedReservation && (
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
)}
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 2, flexShrink: 0, opacity: 0, transition: 'opacity 0.12s' }} className="file-actions">
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ExternalLink size={14} />
</button>
<button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
<Trash2 size={14} />
</button>
</div>
</div>
)
})}
</div> </div>
)} </>
</div> )}
<style>{` <style>{`
div:hover > .file-actions { opacity: 1 !important; } @media (max-width: 767px) {
.file-actions button { padding: 8px !important; }
.file-actions svg { width: 18px !important; height: 18px !important; }
}
`}</style> `}</style>
</div> </div>
) )
+40 -8
View File
@@ -25,7 +25,7 @@ const texts: Record<string, DemoTexts> = {
de: { de: {
titleBefore: 'Willkommen bei ', titleBefore: 'Willkommen bei ',
titleAfter: '', titleAfter: '',
title: 'Willkommen zur NOMAD Demo', title: 'Willkommen zur TREK Demo',
description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.', description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
resetIn: 'Naechster Reset in', resetIn: 'Naechster Reset in',
minutes: 'Minuten', minutes: 'Minuten',
@@ -48,7 +48,7 @@ const texts: Record<string, DemoTexts> = {
['Dokumente', 'Dateien an Reisen anhaengen'], ['Dokumente', 'Dateien an Reisen anhaengen'],
['Widgets', 'Waehrungsrechner & Zeitzonen'], ['Widgets', 'Waehrungsrechner & Zeitzonen'],
], ],
whatIs: 'Was ist NOMAD?', whatIs: 'Was ist TREK?',
whatIsDesc: 'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.', whatIsDesc: 'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
selfHost: 'Open Source — ', selfHost: 'Open Source — ',
selfHostLink: 'selbst hosten', selfHostLink: 'selbst hosten',
@@ -57,7 +57,7 @@ const texts: Record<string, DemoTexts> = {
en: { en: {
titleBefore: 'Welcome to ', titleBefore: 'Welcome to ',
titleAfter: '', titleAfter: '',
title: 'Welcome to the NOMAD Demo', title: 'Welcome to the TREK Demo',
description: 'You can view, edit and create trips. All changes are automatically reset every hour.', description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
resetIn: 'Next reset in', resetIn: 'Next reset in',
minutes: 'minutes', minutes: 'minutes',
@@ -80,12 +80,44 @@ const texts: Record<string, DemoTexts> = {
['Documents', 'Attach files to trips'], ['Documents', 'Attach files to trips'],
['Widgets', 'Currency converter & timezones'], ['Widgets', 'Currency converter & timezones'],
], ],
whatIs: 'What is NOMAD?', whatIs: 'What is TREK?',
whatIsDesc: 'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.', whatIsDesc: 'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
selfHost: 'Open source — ', selfHost: 'Open source — ',
selfHostLink: 'self-host it', selfHostLink: 'self-host it',
close: 'Got it', close: 'Got it',
}, },
es: {
titleBefore: 'Bienvenido a ',
titleAfter: '',
title: 'Bienvenido a la demo de TREK',
description: 'Puedes ver, editar y crear viajes. Todos los cambios se restablecen automáticamente cada hora.',
resetIn: 'Próximo reinicio en',
minutes: 'minutos',
uploadNote: 'Las subidas de archivos (fotos, documentos, portadas) están desactivadas en el modo demo.',
fullVersionTitle: 'Además, en la versión completa:',
features: [
'Subida de archivos (fotos, documentos, portadas)',
'Gestión de claves API (Google Maps, tiempo)',
'Gestión de usuarios y permisos',
'Copias de seguridad automáticas',
'Gestión de addons (activar/desactivar)',
'Inicio de sesión único OIDC / SSO',
],
addonsTitle: 'Complementos modulares (se pueden desactivar en la versión completa)',
addons: [
['Vacaciones', 'Planificador de vacaciones con calendario, festivos y fusión de usuarios'],
['Atlas', 'Mapa del mundo con países visitados y estadísticas de viaje'],
['Equipaje', 'Listas de comprobación para cada viaje'],
['Presupuesto', 'Control de gastos con reparto'],
['Documentos', 'Adjunta archivos a los viajes'],
['Widgets', 'Conversor de divisas y zonas horarias'],
],
whatIs: '¿Qué es TREK?',
whatIsDesc: 'Un planificador de viajes autohospedado con colaboración en tiempo real, mapas interactivos, inicio de sesión OIDC y modo oscuro.',
selfHost: 'Código abierto — ',
selfHostLink: 'alójalo tú mismo',
close: 'Entendido',
},
} }
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield] const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
@@ -123,7 +155,7 @@ export default function DemoBanner(): React.ReactElement | null {
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} /> <img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}> <h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}>
{t.titleBefore}<img src="/text-dark.svg" alt="NOMAD" style={{ height: 18 }} />{t.titleAfter} {t.titleBefore}<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />{t.titleAfter}
</h2> </h2>
</div> </div>
@@ -151,7 +183,7 @@ export default function DemoBanner(): React.ReactElement | null {
</div> </div>
</div> </div>
{/* What is NOMAD */} {/* What is TREK */}
<div style={{ <div style={{
background: '#f8fafc', borderRadius: 12, padding: '12px 14px', marginBottom: 16, background: '#f8fafc', borderRadius: 12, padding: '12px 14px', marginBottom: 16,
border: '1px solid #e2e8f0', border: '1px solid #e2e8f0',
@@ -159,7 +191,7 @@ export default function DemoBanner(): React.ReactElement | null {
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Map size={14} style={{ color: '#111827' }} /> <Map size={14} style={{ color: '#111827' }} />
<span style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}> <span style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
{language === 'de' ? 'Was ist ' : 'What is '}<img src="/text-dark.svg" alt="NOMAD" style={{ height: 13, marginRight: -2 }} />? {t.whatIs}
</span> </span>
</div> </div>
<p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p> <p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
@@ -213,7 +245,7 @@ export default function DemoBanner(): React.ReactElement | null {
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
<Github size={13} /> <Github size={13} />
<span>{t.selfHost}</span> <span>{t.selfHost}</span>
<a href="https://github.com/mauriceboe/NOMAD" target="_blank" rel="noopener noreferrer" <a href="https://github.com/mauriceboe/TREK" target="_blank" rel="noopener noreferrer"
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}> style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}>
{t.selfHostLink} {t.selfHostLink}
</a> </a>
+10 -4
View File
@@ -67,6 +67,12 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {}) updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
} }
const getAddonName = (addon: Addon): string => {
const key = `admin.addons.catalog.${addon.id}.name`
const translated = t(key)
return translated !== key ? translated : addon.name
}
return ( return (
<nav style={{ <nav style={{
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)', background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
@@ -91,8 +97,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
)} )}
<Link to="/dashboard" className="flex items-center transition-colors flex-shrink-0"> <Link to="/dashboard" className="flex items-center transition-colors flex-shrink-0">
<img src={dark ? '/icons/icon-white.svg' : '/icons/icon-dark.svg'} alt="NOMAD" className="sm:hidden" style={{ height: 22, width: 22 }} /> <img src={dark ? '/icons/icon-white.svg' : '/icons/icon-dark.svg'} alt="TREK" className="sm:hidden" style={{ height: 22, width: 22 }} />
<img src={dark ? '/logo-light.svg' : '/logo-dark.svg'} alt="NOMAD" className="hidden sm:block" style={{ height: 28 }} /> <img src={dark ? '/logo-light.svg' : '/logo-dark.svg'} alt="TREK" className="hidden sm:block" style={{ height: 28 }} />
</Link> </Link>
{/* Global addon nav items */} {/* Global addon nav items */}
@@ -124,7 +130,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent' }}> onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent' }}>
<Icon className="w-3.5 h-3.5" /> <Icon className="w-3.5 h-3.5" />
<span className="hidden md:inline">{addon.name}</span> <span className="hidden md:inline">{getAddonName(addon)}</span>
</Link> </Link>
) )
})} })}
@@ -231,7 +237,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{appVersion && ( {appVersion && (
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}> <div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}> <div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="NOMAD" style={{ height: 10, opacity: 0.5 }} /> <img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span> <span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
</div> </div>
</div> </div>
+33 -10
View File
@@ -182,6 +182,16 @@ function MapClickHandler({ onClick }: MapClickHandlerProps) {
return null return null
} }
function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
const map = useMap()
useEffect(() => {
if (!onContextMenu) return
map.on('contextmenu', onContextMenu)
return () => map.off('contextmenu', onContextMenu)
}, [map, onContextMenu])
return null
}
// ── Route travel time label ── // ── Route travel time label ──
interface RouteLabelProps { interface RouteLabelProps {
midpoint: [number, number] midpoint: [number, number]
@@ -234,6 +244,7 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
// Module-level photo cache shared with PlaceAvatar // Module-level photo cache shared with PlaceAvatar
const mapPhotoCache = new Map() const mapPhotoCache = new Map()
const mapPhotoInFlight = new Set()
export function MapView({ export function MapView({
places = [], places = [],
@@ -243,6 +254,7 @@ export function MapView({
selectedPlaceId = null, selectedPlaceId = null,
onMarkerClick, onMarkerClick,
onMapClick, onMapClick,
onMapContextMenu = null,
center = [48.8566, 2.3522], center = [48.8566, 2.3522],
zoom = 10, zoom = 10,
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
@@ -264,23 +276,32 @@ export function MapView({
}, [leftWidth, rightWidth, hasInspector]) }, [leftWidth, rightWidth, hasInspector])
const [photoUrls, setPhotoUrls] = useState({}) const [photoUrls, setPhotoUrls] = useState({})
// Fetch Google photos for places that have google_place_id but no image_url // Fetch photos for places (Google or Wikimedia Commons fallback)
useEffect(() => { useEffect(() => {
places.forEach(place => { places.forEach(place => {
if (place.image_url || !place.google_place_id) return if (place.image_url) return
if (mapPhotoCache.has(place.google_place_id)) { const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const cached = mapPhotoCache.get(place.google_place_id) if (!cacheKey) return
if (cached) setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: cached })) if (mapPhotoCache.has(cacheKey)) {
const cached = mapPhotoCache.get(cacheKey)
if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached }))
return return
} }
mapsApi.placePhoto(place.google_place_id) if (mapPhotoInFlight.has(cacheKey)) return
const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) return
mapPhotoInFlight.add(cacheKey)
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
.then(data => { .then(data => {
if (data.photoUrl) { if (data.photoUrl) {
mapPhotoCache.set(place.google_place_id, data.photoUrl) mapPhotoCache.set(cacheKey, data.photoUrl)
setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: data.photoUrl })) setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl }))
} else {
mapPhotoCache.set(cacheKey, null)
} }
mapPhotoInFlight.delete(cacheKey)
}) })
.catch(() => { mapPhotoCache.set(place.google_place_id, null) }) .catch(() => { mapPhotoCache.set(cacheKey, null); mapPhotoInFlight.delete(cacheKey) })
}) })
}, [places]) }, [places])
@@ -302,6 +323,7 @@ export function MapView({
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} /> <BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} /> <SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} /> <MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<MarkerClusterGroup <MarkerClusterGroup
chunkedLoading chunkedLoading
@@ -326,7 +348,8 @@ export function MapView({
> >
{places.map((place) => { {places.map((place) => {
const isSelected = place.id === selectedPlaceId const isSelected = place.id === selectedPlaceId
const resolvedPhotoUrl = place.image_url || (place.google_place_id && photoUrls[place.google_place_id]) || null const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const resolvedPhotoUrl = place.image_url || (cacheKey && photoUrls[cacheKey]) || null
const orderNumbers = dayOrderMap[place.id] ?? null const orderNumbers = dayOrderMap[place.id] ?? null
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected) const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected)
+4 -4
View File
@@ -165,7 +165,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const chips = [ const chips = [
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '', place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString('de-DE')} EUR</span>` : '', place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString(loc)} EUR</span>` : '',
].filter(Boolean).join('') ].filter(Boolean).join('')
return ` return `
@@ -190,7 +190,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
<div class="day-section${di > 0 ? ' page-break' : ''}"> <div class="day-section${di > 0 ? ' page-break' : ''}">
<div class="day-header"> <div class="day-header">
<span class="day-tag">${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()}</span> <span class="day-tag">${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()}</span>
<span class="day-title">${escHtml(day.title || `Tag ${day.day_number}`)}</span> <span class="day-title">${escHtml(day.title || tr('dayplan.dayN', { n: day.day_number }))}</span>
${day.date ? `<span class="day-date">${shortDate(day.date, loc)}</span>` : ''} ${day.date ? `<span class="day-date">${shortDate(day.date, loc)}</span>` : ''}
${cost ? `<span class="day-cost">${cost}</span>` : ''} ${cost ? `<span class="day-cost">${cost}</span>` : ''}
</div> </div>
@@ -199,7 +199,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
}).join('') }).join('')
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="de"> <html lang="${loc.split('-')[0]}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<base href="${window.location.origin}/"> <base href="${window.location.origin}/">
@@ -377,7 +377,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div> <div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
</div> </div>
${totalCost > 0 ? `<div> ${totalCost > 0 ? `<div>
<div class="cover-stat-num">${totalCost.toLocaleString('de-DE')}</div> <div class="cover-stat-num">${totalCost.toLocaleString(loc)}</div>
<div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div> <div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div>
</div>` : ''} </div>` : ''}
</div> </div>
@@ -3,7 +3,7 @@ import { PhotoLightbox } from './PhotoLightbox'
import { PhotoUpload } from './PhotoUpload' import { PhotoUpload } from './PhotoUpload'
import { Upload, Camera } from 'lucide-react' import { Upload, Camera } from 'lucide-react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import { useTranslation } from '../../i18n' import { getLocaleForLanguage, useTranslation } from '../../i18n'
import type { Photo, Place, Day } from '../../types' import type { Photo, Place, Day } from '../../types'
interface PhotoGalleryProps { interface PhotoGalleryProps {
@@ -17,7 +17,7 @@ interface PhotoGalleryProps {
} }
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }: PhotoGalleryProps) { export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }: PhotoGalleryProps) {
const { t } = useTranslation() const { t, language } = useTranslation()
const [lightboxIndex, setLightboxIndex] = useState(null) const [lightboxIndex, setLightboxIndex] = useState(null)
const [showUpload, setShowUpload] = useState(false) const [showUpload, setShowUpload] = useState(false)
const [filterDayId, setFilterDayId] = useState('') const [filterDayId, setFilterDayId] = useState('')
@@ -53,7 +53,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
<div style={{ marginRight: 'auto' }}> <div style={{ marginRight: 'auto' }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>Fotos</h2> <h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>Fotos</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: '#9ca3af' }}> <p style={{ margin: '2px 0 0', fontSize: 12.5, color: '#9ca3af' }}>
{photos.length} Foto{photos.length !== 1 ? 's' : ''} {photos.length} {photos.length !== 1 ? 'Fotos' : 'Foto'}
</p> </p>
</div> </div>
@@ -65,7 +65,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
<option value="">{t('photos.allDays')}</option> <option value="">{t('photos.allDays')}</option>
{(days || []).map(day => ( {(days || []).map(day => (
<option key={day.id} value={day.id}> <option key={day.id} value={day.id}>
Tag {day.day_number}{day.date ? ` · ${formatDate(day.date)}` : ''} {t('planner.dayN', { n: day.day_number })}{day.date ? ` · ${formatDate(day.date, getLocaleForLanguage(language))}` : ''}
</option> </option>
))} ))}
</select> </select>
@@ -84,7 +84,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium whitespace-nowrap" className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium whitespace-nowrap"
> >
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />
Fotos hochladen {t('common.upload')}
</button> </button>
</div> </div>
@@ -101,7 +101,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
style={{ display: 'inline-flex', margin: '0 auto' }} style={{ display: 'inline-flex', margin: '0 auto' }}
> >
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />
Fotos hochladen {t('common.upload')}
</button> </button>
</div> </div>
) : ( ) : (
@@ -146,7 +146,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
<Modal <Modal
isOpen={showUpload} isOpen={showUpload}
onClose={() => setShowUpload(false)} onClose={() => setShowUpload(false)}
title="Fotos hochladen" title={t('common.upload')}
size="lg" size="lg"
> >
<PhotoUpload <PhotoUpload
@@ -211,7 +211,7 @@ function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
) )
} }
function formatDate(dateStr) { function formatDate(dateStr, locale) {
if (!dateStr) return '' if (!dateStr) return ''
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'short' }) return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
} }
@@ -227,10 +227,10 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
) )
} }
function formatDate(dateStr) { function formatDate(dateStr, locale = 'en-US') {
if (!dateStr) return '' if (!dateStr) return ''
try { try {
return new Date(dateStr).toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }) return new Date(dateStr).toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })
} catch { return '' } } catch { return '' }
} }
+102 -56
View File
@@ -8,7 +8,7 @@ import { weatherApi, accommodationsApi } from '../../api/client'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n' import { getLocaleForLanguage, useTranslation } from '../../i18n'
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types' import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
const WEATHER_ICON_MAP = { const WEATHER_ICON_MAP = {
@@ -53,7 +53,7 @@ interface DayDetailPanelProps {
} }
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }: DayDetailPanelProps) { export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }: DayDetailPanelProps) {
const { t, language } = useTranslation() const { t, language, locale } = useTranslation()
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit' const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const fmtTime = (v) => formatTime12(v, is12h) const fmtTime = (v) => formatTime12(v, is12h)
@@ -61,6 +61,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const [weather, setWeather] = useState(null) const [weather, setWeather] = useState(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [accommodation, setAccommodation] = useState(null) const [accommodation, setAccommodation] = useState(null)
const [dayAccommodations, setDayAccommodations] = useState<any[]>([])
const [accommodations, setAccommodations] = useState([]) const [accommodations, setAccommodations] = useState([])
const [showHotelPicker, setShowHotelPicker] = useState(false) const [showHotelPicker, setShowHotelPicker] = useState(false)
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id }) const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
@@ -81,10 +82,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
accommodationsApi.list(tripId) accommodationsApi.list(tripId)
.then(data => { .then(data => {
setAccommodations(data.accommodations || []) setAccommodations(data.accommodations || [])
const acc = (data.accommodations || []).find(a => const allForDay = (data.accommodations || []).filter(a =>
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id) days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
) )
setAccommodation(acc || null) setDayAccommodations(allForDay)
setAccommodation(allForDay[0] || null)
}) })
.catch(() => {}) .catch(() => {})
}, [tripId, day?.id]) }, [tripId, day?.id])
@@ -136,7 +138,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
if (!day) return null if (!day) return null
const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString( const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString(
language === 'de' ? 'de-DE' : 'en-US', getLocaleForLanguage(language),
{ weekday: 'long', day: 'numeric', month: 'long' } { weekday: 'long', day: 'numeric', month: 'long' }
) : null ) : null
@@ -268,7 +270,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
</div> </div>
{r.reservation_time?.includes('T') && ( {r.reservation_time?.includes('T') && (
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}> <span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{new Date(r.reservation_time).toLocaleTimeString(language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit', hour12: is12h })} {new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })}
{r.reservation_end_time && ` ${fmtTime(r.reservation_end_time)}`} {r.reservation_end_time && ` ${fmtTime(r.reservation_end_time)}`}
</span> </span>
)} )}
@@ -287,57 +289,101 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div> <div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>{t('day.accommodation')}</div> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>{t('day.accommodation')}</div>
{accommodation ? ( {dayAccommodations.length > 0 ? (
<div style={{ borderRadius: 12, background: 'var(--bg-secondary)', overflow: 'hidden' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* Hotel header */} {dayAccommodations.map(acc => {
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px' }}> const isCheckInDay = acc.start_day_id === day.id
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> const isCheckOutDay = acc.end_day_id === day.id
{accommodation.place_image ? ( const isMiddleDay = !isCheckInDay && !isCheckOutDay
<img src={accommodation.place_image} style={{ width: '100%', height: '100%', borderRadius: 10, objectFit: 'cover' }} /> const dayLabel = isCheckInDay && isCheckOutDay ? t('day.checkIn') + ' & ' + t('day.checkOut')
) : ( : isCheckInDay ? t('day.checkIn')
<Hotel size={16} style={{ color: 'var(--text-muted)' }} /> : isCheckOutDay ? t('day.checkOut')
)} : null
</div> const linked = reservations.find(r => r.accommodation_id === acc.id)
<div style={{ flex: 1, minWidth: 0 }}> const confirmed = linked?.status === 'confirmed'
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{accommodation.place_name}</div>
{accommodation.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{accommodation.place_address}</div>} return (
</div> <div key={acc.id} style={{ borderRadius: 12, background: 'var(--bg-secondary)', overflow: 'hidden' }}>
<button onClick={handleRemoveAccommodation} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}> {/* Day label */}
<X size={12} style={{ color: 'var(--text-faint)' }} /> {dayLabel && (
</button> <div style={{ padding: '4px 12px 0', display: 'flex', alignItems: 'center', gap: 4 }}>
</div> {isCheckInDay && <LogIn size={9} style={{ color: '#22c55e' }} />}
{/* Details row */} {isCheckOutDay && !isCheckInDay && <LogOut size={9} style={{ color: '#ef4444' }} />}
{/* Details grid */} <span style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: isCheckOutDay && !isCheckInDay ? '#ef4444' : '#22c55e' }}>{dayLabel}</span>
<div style={{ display: 'flex', gap: 0, margin: '0 12px 10px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}> </div>
{accommodation.check_in && ( )}
<div style={{ flex: 1, padding: '8px 10px', borderRight: '1px solid var(--border-faint)', textAlign: 'center' }}> {/* Hotel header */}
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(accommodation.check_in)}</div> <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px' }}>
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}> <div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<LogIn size={8} /> {t('day.checkIn')} {acc.place_image ? (
<img src={acc.place_image} style={{ width: '100%', height: '100%', borderRadius: 10, objectFit: 'cover' }} />
) : (
<Hotel size={16} style={{ color: 'var(--text-muted)' }} />
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</div>
{acc.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_address}</div>}
</div>
<button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
<Pencil size={12} style={{ color: 'var(--text-faint)' }} />
</button>
<button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
<X size={12} style={{ color: 'var(--text-faint)' }} />
</button>
</div> </div>
</div> {/* Details grid */}
)} <div style={{ display: 'flex', gap: 0, margin: '0 12px 8px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
{accommodation.check_out && ( {acc.check_in && (
<div style={{ flex: 1, padding: '8px 10px', borderRight: accommodation.confirmation ? '1px solid var(--border-faint)' : 'none', textAlign: 'center' }}> <div style={{ flex: 1, padding: '8px 10px', borderRight: '1px solid var(--border-faint)', textAlign: 'center' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(accommodation.check_out)}</div> <div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(acc.check_in)}</div>
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}> <div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
<LogOut size={8} /> {t('day.checkOut')} <LogIn size={8} /> {t('day.checkIn')}
</div>
</div>
)}
{acc.check_out && (
<div style={{ flex: 1, padding: '8px 10px', borderRight: acc.confirmation ? '1px solid var(--border-faint)' : 'none', textAlign: 'center' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(acc.check_out)}</div>
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
<LogOut size={8} /> {t('day.checkOut')}
</div>
</div>
)}
{acc.confirmation && (
<div style={{ flex: 1, padding: '8px 10px', textAlign: 'center' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{acc.confirmation}</div>
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
<Hash size={8} /> {t('day.confirmation')}
</div>
</div>
)}
</div> </div>
{/* Linked booking */}
{linked && (
<div style={{ margin: '0 12px 8px', padding: '6px 10px', borderRadius: 8, background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.15)' : 'rgba(217,119,6,0.15)'}`, display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: confirmed ? '#16a34a' : '#d97706', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<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>}
</div>
</div>
</div>
)}
</div> </div>
)} )
{accommodation.confirmation && ( })}
<div style={{ flex: 1, padding: '8px 10px', textAlign: 'center' }}> {/* Add another hotel */}
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{accommodation.confirmation}</div> <button onClick={() => setShowHotelPicker(true)} style={{
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}> width: '100%', padding: 8, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
<Hash size={8} /> {t('day.confirmation')} background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
</div> fontSize: 10, color: 'var(--text-faint)', fontFamily: 'inherit',
</div> }}>
)} <Hotel size={10} /> {t('day.addAccommodation')}
<button onClick={() => { setHotelForm({ check_in: accommodation.check_in || '', check_out: accommodation.check_out || '', confirmation: accommodation.confirmation || '', place_id: accommodation.place_id }); setHotelDayRange({ start: accommodation.start_day_id, end: accommodation.end_day_id }); setShowHotelPicker('edit') }} </button>
style={{ padding: '0 8px', background: 'none', border: 'none', borderLeft: '1px solid var(--border-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Pencil size={10} style={{ color: 'var(--text-faint)' }} />
</button>
</div>
</div> </div>
) : ( ) : (
<button onClick={() => setShowHotelPicker(true)} style={{ <button onClick={() => setShowHotelPicker(true)} style={{
@@ -377,7 +423,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))} onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
options={days.map((d, i) => ({ options={days.map((d, i) => ({
value: d.id, value: d.id,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00').toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short' })}` : ''}`, label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
}))} }))}
size="sm" size="sm"
/> />
@@ -389,7 +435,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))} onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
options={days.map((d, i) => ({ options={days.map((d, i) => ({
value: d.id, value: d.id,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00').toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short' })}` : ''}`, label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
}))} }))}
size="sm" size="sm"
/> />
@@ -17,7 +17,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { formatDate, formatTime, dayTotalCost } from '../../utils/formatters' import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes' import { useDayNotes } from '../../hooks/useDayNotes'
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types' import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
@@ -491,13 +491,21 @@ export default function DayPlanSidebar({
<Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" /> <Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" />
</button> </button>
{(() => { {(() => {
const acc = accommodations.find(a => day.id >= a.start_day_id && day.id <= a.end_day_id) const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
return acc ? ( if (dayAccs.length === 0) return null
<span onClick={e => { e.stopPropagation(); onPlaceClick(acc.place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}> return dayAccs.map(acc => {
<Hotel size={8} style={{ color: 'var(--text-muted)', flexShrink: 0 }} /> const isCheckIn = acc.start_day_id === day.id
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</span> const isCheckOut = acc.end_day_id === day.id
</span> const bg = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.08)' : isCheckIn ? 'rgba(34,197,94,0.08)' : 'var(--bg-secondary)'
) : null const border = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.2)' : isCheckIn ? 'rgba(34,197,94,0.2)' : 'var(--border-primary)'
const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-muted)'
return (
<span key={acc.id} onClick={e => { e.stopPropagation(); onPlaceClick(acc.place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}>
<Hotel size={8} style={{ color: iconColor, flexShrink: 0 }} />
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</span>
</span>
)
})
})()} })()}
</div> </div>
)} )}
@@ -735,6 +743,14 @@ export default function DayPlanSidebar({
{res.reservation_end_time && ` ${res.reservation_end_time}`} {res.reservation_end_time && ` ${res.reservation_end_time}`}
</span> </span>
)} )}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta) return null
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
return null
})()}
</div> </div>
) )
})()} })()}
@@ -979,7 +995,7 @@ export default function DayPlanSidebar({
{totalCost > 0 && ( {totalCost > 0 && (
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{t('dayplan.totalCost')}</span> <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{t('dayplan.totalCost')}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{totalCost.toFixed(2)} {currency}</span> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{totalCost.toFixed(currencyDecimals(currency))} {currency}</span>
</div> </div>
)} )}
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} /> <ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
@@ -42,6 +42,7 @@ interface PlaceFormModalProps {
onClose: () => void onClose: () => void
onSave: (data: PlaceFormData, files?: File[]) => Promise<void> | void onSave: (data: PlaceFormData, files?: File[]) => Promise<void> | void
place: Place | null place: Place | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string } | null
tripId: number tripId: number
categories: Category[] categories: Category[]
onCategoryCreated: (category: Category) => void onCategoryCreated: (category: Category) => void
@@ -50,7 +51,7 @@ interface PlaceFormModalProps {
} }
export default function PlaceFormModal({ export default function PlaceFormModal({
isOpen, onClose, onSave, place, tripId, categories, isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
onCategoryCreated, assignmentId, dayAssignments = [], onCategoryCreated, assignmentId, dayAssignments = [],
}: PlaceFormModalProps) { }: PlaceFormModalProps) {
const [form, setForm] = useState(DEFAULT_FORM) const [form, setForm] = useState(DEFAULT_FORM)
@@ -81,11 +82,19 @@ export default function PlaceFormModal({
transport_mode: place.transport_mode || 'walking', transport_mode: place.transport_mode || 'walking',
website: place.website || '', website: place.website || '',
}) })
} else if (prefillCoords) {
setForm({
...DEFAULT_FORM,
lat: String(prefillCoords.lat),
lng: String(prefillCoords.lng),
name: prefillCoords.name || '',
address: prefillCoords.address || '',
})
} else { } else {
setForm(DEFAULT_FORM) setForm(DEFAULT_FORM)
} }
setPendingFiles([]) setPendingFiles([])
}, [place, isOpen]) }, [place, prefillCoords, isOpen])
const handleChange = (field, value) => { const handleChange = (field, value) => {
setForm(prev => ({ ...prev, [field]: value })) setForm(prev => ({ ...prev, [field]: value }))
@@ -112,6 +121,9 @@ export default function PlaceFormModal({
lat: result.lat || prev.lat, lat: result.lat || prev.lat,
lng: result.lng || prev.lng, lng: result.lng || prev.lng,
google_place_id: result.google_place_id || prev.google_place_id, google_place_id: result.google_place_id || prev.google_place_id,
osm_id: result.osm_id || prev.osm_id,
website: result.website || prev.website,
phone: result.phone || prev.phone,
})) }))
setMapsResults([]) setMapsResults([])
setMapsSearch('') setMapsSearch('')
@@ -20,23 +20,21 @@ function setSessionCache(key, value) {
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {} try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
} }
function useGoogleDetails(googlePlaceId, language) { function usePlaceDetails(googlePlaceId, osmId, language) {
const [details, setDetails] = useState(null) const [details, setDetails] = useState(null)
const cacheKey = `gdetails_${googlePlaceId}_${language}` const detailId = googlePlaceId || osmId
const cacheKey = `gdetails_${detailId}_${language}`
useEffect(() => { useEffect(() => {
if (!googlePlaceId) { setDetails(null); return } if (!detailId) { setDetails(null); return }
// In-memory cache (fastest)
if (detailsCache.has(cacheKey)) { setDetails(detailsCache.get(cacheKey)); return } if (detailsCache.has(cacheKey)) { setDetails(detailsCache.get(cacheKey)); return }
// sessionStorage cache (survives reload)
const cached = getSessionCache(cacheKey) const cached = getSessionCache(cacheKey)
if (cached) { detailsCache.set(cacheKey, cached); setDetails(cached); return } if (cached) { detailsCache.set(cacheKey, cached); setDetails(cached); return }
// Fetch from API mapsApi.details(detailId, language).then(data => {
mapsApi.details(googlePlaceId, language).then(data => {
detailsCache.set(cacheKey, data.place) detailsCache.set(cacheKey, data.place)
setSessionCache(cacheKey, data.place) setSessionCache(cacheKey, data.place)
setDetails(data.place) setDetails(data.place)
}).catch(() => {}) }).catch(() => {})
}, [googlePlaceId, language]) }, [detailId, language])
return details return details
} }
@@ -138,7 +136,7 @@ export default function PlaceInspector({
const [nameValue, setNameValue] = useState('') const [nameValue, setNameValue] = useState('')
const nameInputRef = useRef(null) const nameInputRef = useRef(null)
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const googleDetails = useGoogleDetails(place?.google_place_id, language) const googleDetails = usePlaceDetails(place?.google_place_id, place?.osm_id, language)
const startNameEdit = () => { const startNameEdit = () => {
if (!onUpdatePlace) return if (!onUpdatePlace) return
@@ -314,7 +312,7 @@ export default function PlaceInspector({
icon={<Star size={12} fill="#facc15" color="#facc15" />} icon={<Star size={12} fill="#facc15" color="#facc15" />}
text={<> text={<>
{googleDetails.rating.toFixed(1)} {googleDetails.rating.toFixed(1)}
{googleDetails.rating_count ? <span style={{ opacity: 0.5 }}> ({googleDetails.rating_count.toLocaleString('de-DE')})</span> : ''} {googleDetails.rating_count ? <span style={{ opacity: 0.5 }}> ({googleDetails.rating_count.toLocaleString(locale)})</span> : ''}
{shortReview && <span className="hidden md:inline" style={{ opacity: 0.6, fontWeight: 400, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> · {shortReview.text}"</span>} {shortReview && <span className="hidden md:inline" style={{ opacity: 0.6, fontWeight: 400, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> · {shortReview.text}"</span>}
</>} </>}
color="var(--text-secondary)" bg="var(--bg-hover)" color="var(--text-secondary)" bg="var(--bg-hover)"
@@ -327,20 +325,20 @@ export default function PlaceInspector({
</div> </div>
{/* Telefon */} {/* Telefon */}
{place.phone && ( {(place.phone || googleDetails?.phone) && (
<div style={{ display: 'flex', gap: 12 }}> <div style={{ display: 'flex', gap: 12 }}>
<a href={`tel:${place.phone}`} <a href={`tel:${place.phone || googleDetails.phone}`}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', textDecoration: 'none' }}> style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', textDecoration: 'none' }}>
<Phone size={12} /> {place.phone} <Phone size={12} /> {place.phone || googleDetails.phone}
</a> </a>
</div> </div>
)} )}
{/* Description */} {/* Description / Summary */}
{(place.description || place.notes) && ( {(place.description || place.notes || googleDetails?.summary) && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}> <div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px' }}> <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px' }}>
{place.description || place.notes} {place.description || place.notes || googleDetails?.summary}
</p> </p>
</div> </div>
)} )}
@@ -391,6 +389,20 @@ export default function PlaceInspector({
)} )}
</div> </div>
{res.notes && <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}>{res.notes}</div>} {res.notes && <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}>{res.notes}</div>}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
const parts: string[] = []
if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`)
else if (meta.flight_number) parts.push(meta.flight_number)
if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport}${meta.arrival_airport}`)
if (meta.train_number) parts.push(meta.train_number)
if (meta.platform) parts.push(`Gl. ${meta.platform}`)
if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`)
if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`)
if (parts.length === 0) return null
return <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-muted)', fontWeight: 500 }}>{parts.join(' · ')}</div>
})()}
</div> </div>
) )
})()} })()}
@@ -502,8 +514,12 @@ export default function PlaceInspector({
<ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={<Navigation size={13} />} <ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} /> label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
)} )}
{place.website && ( {!googleDetails?.google_maps_url && place.lat && place.lng && (
<ActionButton onClick={() => window.open(place.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />} <ActionButton onClick={() => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">Google Maps</span>} />
)}
{(place.website || googleDetails?.website) && (
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} /> label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
)} )}
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
@@ -6,7 +6,7 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
import type { Day, Place, Reservation, TripFile, AssignmentsMap } from '../../types' import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
const TYPE_OPTIONS = [ const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane }, { value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
@@ -58,17 +58,22 @@ interface ReservationModalProps {
files?: TripFile[] files?: TripFile[]
onFileUpload: (fd: FormData) => Promise<void> onFileUpload: (fd: FormData) => Promise<void>
onFileDelete: (fileId: number) => Promise<void> onFileDelete: (fileId: number) => Promise<void>
accommodations?: Accommodation[]
} }
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete }: ReservationModalProps) { export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) {
const toast = useToast() const toast = useToast()
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const [form, setForm] = useState({ const [form, setForm] = useState({
title: '', type: 'other', status: 'pending', title: '', type: 'other', status: 'pending',
reservation_time: '', location: '', confirmation_number: '', reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
notes: '', assignment_id: '', notes: '', assignment_id: '', accommodation_id: '',
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
meta_train_number: '', meta_platform: '', meta_seat: '',
meta_check_in_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
}) })
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false) const [uploadingFile, setUploadingFile] = useState(false)
@@ -81,6 +86,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
useEffect(() => { useEffect(() => {
if (reservation) { if (reservation) {
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
setForm({ setForm({
title: reservation.title || '', title: reservation.title || '',
type: reservation.type || 'other', type: reservation.type || 'other',
@@ -91,12 +97,28 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
confirmation_number: reservation.confirmation_number || '', confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '', notes: reservation.notes || '',
assignment_id: reservation.assignment_id || '', assignment_id: reservation.assignment_id || '',
accommodation_id: reservation.accommodation_id || '',
meta_airline: meta.airline || '',
meta_flight_number: meta.flight_number || '',
meta_departure_airport: meta.departure_airport || '',
meta_arrival_airport: meta.arrival_airport || '',
meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '',
meta_seat: meta.seat || '',
meta_check_in_time: meta.check_in_time || '',
meta_check_out_time: meta.check_out_time || '',
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
}) })
} else { } else {
setForm({ setForm({
title: '', type: 'other', status: 'pending', title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '', reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
notes: '', assignment_id: '', notes: '', assignment_id: '', accommodation_id: '',
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
meta_train_number: '', meta_platform: '', meta_seat: '',
meta_check_in_time: '', meta_check_out_time: '',
}) })
setPendingFiles([]) setPendingFiles([])
} }
@@ -109,10 +131,41 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (!form.title.trim()) return if (!form.title.trim()) return
setIsSaving(true) setIsSaving(true)
try { try {
const saved = await onSave({ const metadata: Record<string, string> = {}
...form, if (form.type === 'flight') {
if (form.meta_airline) metadata.airline = form.meta_airline
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
if (form.meta_departure_airport) metadata.departure_airport = form.meta_departure_airport
if (form.meta_arrival_airport) metadata.arrival_airport = form.meta_arrival_airport
} else if (form.type === 'hotel') {
if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time
if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time
} else if (form.type === 'train') {
if (form.meta_train_number) metadata.train_number = form.meta_train_number
if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat
}
const saveData: Record<string, any> = {
title: form.title, type: form.type, status: form.status,
reservation_time: form.reservation_time, reservation_end_time: form.reservation_end_time,
location: form.location, confirmation_number: form.confirmation_number,
notes: form.notes,
assignment_id: form.assignment_id || null, assignment_id: form.assignment_id || null,
}) accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
metadata: Object.keys(metadata).length > 0 ? metadata : null,
}
// If hotel with place + days, pass hotel data for auto-creation or update
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
saveData.create_accommodation = {
place_id: form.hotel_place_id,
start_day_id: form.hotel_start_day,
end_day_id: form.hotel_end_day,
check_in: form.meta_check_in_time || null,
check_out: form.meta_check_out_time || null,
confirmation: form.confirmation_number || null,
}
}
const saved = await onSave(saveData)
if (!reservation?.id && saved?.id && pendingFiles.length > 0) { if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
for (const file of pendingFiles) { for (const file of pendingFiles) {
const fd = new FormData() const fd = new FormData()
@@ -190,7 +243,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} /> placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
</div> </div>
{/* Assignment Picker + Date */} {/* Assignment Picker + Date (hidden for hotels) */}
{form.type !== 'hotel' && (
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
{assignmentOptions.length > 0 && ( {assignmentOptions.length > 0 && (
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
@@ -231,24 +285,29 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
/> />
</div> </div>
</div> </div>
)}
{/* Start Time + End Time + Status */} {/* Start Time + End Time + Status */}
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}> {form.type !== 'hotel' && (
<label style={labelStyle}>{t('reservations.startTime')}</label> <>
<CustomTimePicker <div style={{ flex: 1, minWidth: 0 }}>
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()} <label style={labelStyle}>{t('reservations.startTime')}</label>
onChange={t => { <CustomTimePicker
const [d] = (form.reservation_time || '').split('T') value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
const date = d || new Date().toISOString().split('T')[0] onChange={t => {
set('reservation_time', t ? `${date}T${t}` : date) const [d] = (form.reservation_time || '').split('T')
}} const date = d || new Date().toISOString().split('T')[0]
/> set('reservation_time', t ? `${date}T${t}` : date)
</div> }}
<div style={{ flex: 1, minWidth: 0 }}> />
<label style={labelStyle}>{t('reservations.endTime')}</label> </div>
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} /> <div style={{ flex: 1, minWidth: 0 }}>
</div> <label style={labelStyle}>{t('reservations.endTime')}</label>
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
</div>
</>
)}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.status')}</label> <label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect <CustomSelect
@@ -277,6 +336,112 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div> </div>
</div> </div>
{/* Type-specific fields */}
{form.type === 'flight' && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.airline') || 'Airline'}</label>
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
placeholder="Lufthansa" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.flightNumber') || 'Flight No.'}</label>
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
placeholder="LH 123" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.from') || 'From'}</label>
<input type="text" value={form.meta_departure_airport} onChange={e => set('meta_departure_airport', e.target.value)}
placeholder="FRA" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.to') || 'To'}</label>
<input type="text" value={form.meta_arrival_airport} onChange={e => set('meta_arrival_airport', e.target.value)}
placeholder="NRT" style={inputStyle} />
</div>
</div>
)}
{form.type === 'hotel' && (
<>
{/* Hotel place + day range */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.hotelPlace')}</label>
<CustomSelect
value={form.hotel_place_id}
onChange={value => {
set('hotel_place_id', value)
const p = places.find(pl => pl.id === value)
if (p) {
if (!form.title) set('title', p.name)
if (!form.location && p.address) set('location', p.address)
}
}}
placeholder={t('reservations.meta.pickHotel')}
options={[
{ value: '', label: '—' },
...places.map(p => ({ value: p.id, label: p.name })),
]}
searchable
size="sm"
/>
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.fromDay')}</label>
<CustomSelect
value={form.hotel_start_day}
onChange={value => set('hotel_start_day', value)}
placeholder={t('reservations.meta.selectDay')}
options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
size="sm"
/>
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.toDay')}</label>
<CustomSelect
value={form.hotel_end_day}
onChange={value => set('hotel_end_day', value)}
placeholder={t('reservations.meta.selectDay')}
options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
size="sm"
/>
</div>
</div>
{/* Check-in/out times */}
<div className="grid grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.checkIn')}</label>
<CustomTimePicker value={form.meta_check_in_time} onChange={v => set('meta_check_in_time', v)} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.checkOut')}</label>
<CustomTimePicker value={form.meta_check_out_time} onChange={v => set('meta_check_out_time', v)} />
</div>
</div>
</>
)}
{form.type === 'train' && (
<div className="grid grid-cols-3 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.trainNumber') || 'Train No.'}</label>
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
placeholder="ICE 123" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.platform') || 'Platform'}</label>
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
placeholder="12" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.seat') || 'Seat'}</label>
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
placeholder="42A" style={inputStyle} />
</div>
</div>
)}
{/* Notes */} {/* Notes */}
<div> <div>
<label style={labelStyle}>{t('reservations.notes')}</label> <label style={labelStyle}>{t('reservations.notes')}</label>
@@ -112,7 +112,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
</div> </div>
{/* Details */} {/* Details */}
{(r.reservation_time || r.confirmation_number || r.location || linked) && ( {(r.reservation_time || r.confirmation_number || r.location || linked || r.metadata) && (
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{/* Row 1: Date, Time, Code */} {/* Row 1: Date, Time, Code */}
{(r.reservation_time || r.confirmation_number) && ( {(r.reservation_time || r.confirmation_number) && (
@@ -139,8 +139,34 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
)} )}
</div> </div>
)} )}
{/* Row 1b: Type-specific metadata */}
{(() => {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
const cells: { label: string; value: string }[] = []
if (meta.airline) cells.push({ label: t('reservations.meta.airline'), value: meta.airline })
if (meta.flight_number) cells.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
if (meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
if (meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: meta.check_in_time })
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: meta.check_out_time })
if (cells.length === 0) return null
return (
<div style={{ display: 'flex', gap: 0, borderRadius: 8, overflow: 'hidden', background: 'var(--bg-secondary)', boxShadow: '0 1px 6px rgba(0,0,0,0.08)' }}>
{cells.map((c, i) => (
<div key={i} style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: i < cells.length - 1 ? '1px solid var(--border-faint)' : 'none' }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{c.label}</div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{c.value}</div>
</div>
))}
</div>
)
})()}
{/* Row 2: Location + Assignment */} {/* Row 2: Location + Assignment */}
{(r.location || linked) && ( {(r.location || linked || r.accommodation_name) && (
<div className={`grid grid-cols-1 ${r.location && linked ? 'sm:grid-cols-2' : ''} gap-2`} style={{ paddingTop: 6, borderTop: '1px solid var(--border-faint)' }}> <div className={`grid grid-cols-1 ${r.location && linked ? 'sm:grid-cols-2' : ''} gap-2`} style={{ paddingTop: 6, borderTop: '1px solid var(--border-faint)' }}>
{r.location && ( {r.location && (
<div> <div>
@@ -151,6 +177,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
</div> </div>
</div> </div>
)} )}
{r.accommodation_name && (
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.meta.linkAccommodation')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
<Hotel size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.accommodation_name}</span>
</div>
</div>
)}
{linked && ( {linked && (
<div> <div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.linkAssignment')}</div> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.linkAssignment')}</div>
@@ -5,8 +5,10 @@ import type { HolidaysMap, VacayEntry } from '../../types'
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const WEEKDAYS_ES = ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sa', 'Do']
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
const MONTHS_ES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']
interface VacayMonthCardProps { interface VacayMonthCardProps {
year: number year: number
@@ -25,8 +27,8 @@ export default function VacayMonthCard({
onCellClick, companyMode, blockWeekends onCellClick, companyMode, blockWeekends
}: VacayMonthCardProps) { }: VacayMonthCardProps) {
const { language } = useTranslation() const { language } = useTranslation()
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN const weekdays = language === 'de' ? WEEKDAYS_DE : language === 'es' ? WEEKDAYS_ES : WEEKDAYS_EN
const monthNames = language === 'de' ? MONTHS_DE : MONTHS_EN const monthNames = language === 'de' ? MONTHS_DE : language === 'es' ? MONTHS_ES : MONTHS_EN
const weeks = useMemo(() => { const weeks = useMemo(() => {
const firstDay = new Date(year, month, 1) const firstDay = new Date(year, month, 1)
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react' import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore' import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n' import { getIntlLanguage, useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client' import apiClient from '../../api/client'
@@ -24,7 +24,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
useEffect(() => { useEffect(() => {
apiClient.get('/addons/vacay/holidays/countries').then(r => { apiClient.get('/addons/vacay/holidays/countries').then(r => {
let displayNames let displayNames
try { displayNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ } try { displayNames = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }) } catch { /* */ }
const list = r.data.map(c => ({ const list = r.data.map(c => ({
value: c.countryCode, value: c.countryCode,
label: displayNames ? (displayNames.of(c.countryCode) || c.name) : c.name, label: displayNames ? (displayNames.of(c.countryCode) || c.name) : c.name,
@@ -49,7 +49,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
}) })
if (allCounties.size > 0) { if (allCounties.size > 0) {
let subdivisionNames let subdivisionNames
try { subdivisionNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ } try { subdivisionNames = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }) } catch { /* */ }
const regionList = [...allCounties].sort().map(c => { const regionList = [...allCounties].sort().map(c => {
let label = c.split('-')[1] || c let label = c.split('-')[1] || c
// Try Intl for full subdivision name (not all browsers support subdivision codes) // Try Intl for full subdivision name (not all browsers support subdivision codes)
+28 -9
View File
@@ -9,34 +9,53 @@ interface Category {
} }
interface PlaceAvatarProps { interface PlaceAvatarProps {
place: Pick<Place, 'id' | 'name' | 'image_url' | 'google_place_id'> place: Pick<Place, 'id' | 'name' | 'image_url' | 'google_place_id' | 'osm_id' | 'lat' | 'lng'>
size?: number size?: number
category?: Category | null category?: Category | null
} }
const googlePhotoCache = new Map<string, string>() const photoCache = new Map<string, string | null>()
const photoInFlight = new Set<string>()
export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) { export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null) const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
useEffect(() => { useEffect(() => {
if (place.image_url) { setPhotoSrc(place.image_url); return } if (place.image_url) { setPhotoSrc(place.image_url); return }
if (!place.google_place_id) { setPhotoSrc(null); return } const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
if (googlePhotoCache.has(place.google_place_id)) { const cacheKey = photoId || `${place.lat},${place.lng}`
setPhotoSrc(googlePhotoCache.get(place.google_place_id)!) if (photoCache.has(cacheKey)) {
const cached = photoCache.get(cacheKey)
if (cached) setPhotoSrc(cached)
return return
} }
mapsApi.placePhoto(place.google_place_id) if (photoInFlight.has(cacheKey)) {
// Another instance is already fetching, wait for it
const check = setInterval(() => {
if (photoCache.has(cacheKey)) {
clearInterval(check)
const cached = photoCache.get(cacheKey)
if (cached) setPhotoSrc(cached)
}
}, 200)
return () => clearInterval(check)
}
photoInFlight.add(cacheKey)
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
.then((data: { photoUrl?: string }) => { .then((data: { photoUrl?: string }) => {
if (data.photoUrl) { if (data.photoUrl) {
googlePhotoCache.set(place.google_place_id!, data.photoUrl) photoCache.set(cacheKey, data.photoUrl)
setPhotoSrc(data.photoUrl) setPhotoSrc(data.photoUrl)
} else {
photoCache.set(cacheKey, null)
} }
photoInFlight.delete(cacheKey)
}) })
.catch(() => {}) .catch(() => { photoCache.set(cacheKey, null); photoInFlight.delete(cacheKey) })
}, [place.id, place.image_url, place.google_place_id]) }, [place.id, place.image_url, place.google_place_id, place.osm_id])
const bgColor = category?.color || '#6366f1' const bgColor = category?.color || '#6366f1'
const IconComp = getCategoryIcon(category?.icon) const IconComp = getCategoryIcon(category?.icon)
+82 -34
View File
@@ -19,6 +19,13 @@ declare global {
let toastIdCounter = 0 let toastIdCounter = 0
const ICON_COLORS: Record<ToastType, string> = {
success: '#22c55e',
error: '#ef4444',
warning: '#f59e0b',
info: '#6366f1',
}
export function ToastContainer() { export function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([]) const [toasts, setToasts] = useState<Toast[]>([])
@@ -31,7 +38,7 @@ export function ToastContainer() {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t)) setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => { setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id)) setToasts(prev => prev.filter(t => t.id !== id))
}, 300) }, 400)
}, duration) }, duration)
} }
@@ -42,7 +49,7 @@ export function ToastContainer() {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t)) setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => { setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id)) setToasts(prev => prev.filter(t => t.id !== id))
}, 300) }, 400)
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -51,42 +58,83 @@ export function ToastContainer() {
}, [addToast]) }, [addToast])
const icons: Record<ToastType, React.ReactNode> = { const icons: Record<ToastType, React.ReactNode> = {
success: <CheckCircle className="w-5 h-5 text-emerald-500 flex-shrink-0" />, success: <CheckCircle size={18} style={{ color: ICON_COLORS.success, flexShrink: 0 }} />,
error: <XCircle className="w-5 h-5 text-red-500 flex-shrink-0" />, error: <XCircle size={18} style={{ color: ICON_COLORS.error, flexShrink: 0 }} />,
warning: <AlertCircle className="w-5 h-5 text-amber-500 flex-shrink-0" />, warning: <AlertCircle size={18} style={{ color: ICON_COLORS.warning, flexShrink: 0 }} />,
info: <Info className="w-5 h-5 text-blue-500 flex-shrink-0" />, info: <Info size={18} style={{ color: ICON_COLORS.info, flexShrink: 0 }} />,
}
const bgColors: Record<ToastType, string> = {
success: 'bg-white border-l-4 border-emerald-500',
error: 'bg-white border-l-4 border-red-500',
warning: 'bg-white border-l-4 border-amber-500',
info: 'bg-white border-l-4 border-blue-500',
} }
return ( return (
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm w-full pointer-events-none"> <>
{toasts.map(toast => ( <style>{`
<div @keyframes toast-in {
key={toast.id} from { opacity: 0; transform: translateY(16px) scale(0.95); }
className={` to { opacity: 1; transform: translateY(0) scale(1); }
${bgColors[toast.type] || bgColors.info} }
${toast.removing ? 'toast-exit' : 'toast-enter'} @keyframes toast-out {
flex items-start gap-3 p-4 rounded-lg shadow-lg pointer-events-auto from { opacity: 1; transform: translateY(0) scale(1); }
min-w-0 to { opacity: 0; transform: translateY(8px) scale(0.95); }
`} }
> .nomad-toast {
{icons[toast.type] || icons.info} background: rgba(255, 255, 255, 0.65);
<p className="text-sm text-slate-700 flex-1 leading-relaxed">{toast.message}</p> border: 1px solid rgba(0, 0, 0, 0.06);
<button box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), inset 0 0.5px 0 rgba(255,255,255,0.5);
onClick={() => removeToast(toast.id)} }
className="text-slate-400 hover:text-slate-600 transition-colors flex-shrink-0" .nomad-toast span { color: rgba(0, 0, 0, 0.8) !important; }
.dark .nomad-toast {
background: rgba(30, 30, 40, 0.55);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25), inset 0 0.5px 0 rgba(255,255,255,0.08);
}
.dark .nomad-toast span { color: rgba(255, 255, 255, 0.9) !important; }
.nomad-toast-close { color: rgba(0, 0, 0, 0.4); }
.dark .nomad-toast-close { color: rgba(255, 255, 255, 0.4); }
`}</style>
<div style={{
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
zIndex: 9999, display: 'flex', flexDirection: 'column-reverse', gap: 8,
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
}}>
{toasts.map(toast => (
<div
key={toast.id}
className="nomad-toast"
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 14px',
borderRadius: 14,
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
pointerEvents: 'auto',
animation: toast.removing ? 'toast-out 0.35s ease forwards' : 'toast-in 0.35s cubic-bezier(0.16,1,0.3,1) forwards',
}}
> >
<X className="w-4 h-4" /> {icons[toast.type] || icons.info}
</button> <span style={{
</div> flex: 1, fontSize: 13, fontWeight: 500, color: 'rgba(255, 255, 255, 0.9)',
))} lineHeight: 1.4,
</div> fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}}>
{toast.message}
</span>
<button
onClick={() => removeToast(toast.id)}
className="nomad-toast-close"
style={{
background: 'none', border: 'none', cursor: 'pointer',
display: 'flex', padding: 2,
flexShrink: 0, borderRadius: 6, transition: 'opacity 0.15s',
opacity: 0.35,
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.7'}
onMouseLeave={e => e.currentTarget.style.opacity = '0.35'}
>
<X size={14} />
</button>
</div>
))}
</div>
</>
) )
} }
+16 -6
View File
@@ -2,10 +2,20 @@ import React, { createContext, useContext, useMemo, ReactNode } from 'react'
import { useSettingsStore } from '../store/settingsStore' import { useSettingsStore } from '../store/settingsStore'
import de from './translations/de' import de from './translations/de'
import en from './translations/en' import en from './translations/en'
import es from './translations/es'
type TranslationStrings = Record<string, string> type TranslationStrings = Record<string, string>
const translations: Record<string, TranslationStrings> = { de, en } const translations: Record<string, TranslationStrings> = { de, en, es }
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES' }
export function getLocaleForLanguage(language: string): string {
return LOCALES[language] || LOCALES.en
}
export function getIntlLanguage(language: string): string {
return language === 'de' || language === 'es' ? language : 'en'
}
interface TranslationContextValue { interface TranslationContextValue {
t: (key: string, params?: Record<string, string | number>) => string t: (key: string, params?: Record<string, string | number>) => string
@@ -13,18 +23,18 @@ interface TranslationContextValue {
locale: string locale: string
} }
const TranslationContext = createContext<TranslationContextValue>({ t: (k: string) => k, language: 'de', locale: 'de-DE' }) const TranslationContext = createContext<TranslationContextValue>({ t: (k: string) => k, language: 'en', locale: 'en-US' })
interface TranslationProviderProps { interface TranslationProviderProps {
children: ReactNode children: ReactNode
} }
export function TranslationProvider({ children }: TranslationProviderProps) { export function TranslationProvider({ children }: TranslationProviderProps) {
const language = useSettingsStore((s) => s.settings.language) || 'de' const language = useSettingsStore((s) => s.settings.language) || 'en'
const value = useMemo((): TranslationContextValue => { const value = useMemo((): TranslationContextValue => {
const strings = translations[language] || translations.de const strings = translations[language] || translations.en
const fallback = translations.de const fallback = translations.en
function t(key: string, params?: Record<string, string | number>): string { function t(key: string, params?: Record<string, string | number>): string {
let val: string = strings[key] ?? fallback[key] ?? key let val: string = strings[key] ?? fallback[key] ?? key
@@ -36,7 +46,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
return val return val
} }
return { t, language, locale: language === 'en' ? 'en-US' : 'de-DE' } return { t, language, locale: getLocaleForLanguage(language) }
}, [language]) }, [language])
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider> return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
+1 -1
View File
@@ -1 +1 @@
export { TranslationProvider, useTranslation } from './TranslationContext' export { TranslationProvider, useTranslation, getLocaleForLanguage, getIntlLanguage } from './TranslationContext'
+51 -10
View File
@@ -207,7 +207,7 @@ const de: Record<string, string> = {
'login.signingIn': 'Anmelden…', 'login.signingIn': 'Anmelden…',
'login.signIn': 'Anmelden', 'login.signIn': 'Anmelden',
'login.createAdmin': 'Admin-Konto erstellen', 'login.createAdmin': 'Admin-Konto erstellen',
'login.createAdminHint': 'Erstelle das erste Admin-Konto für NOMAD.', 'login.createAdminHint': 'Erstelle das erste Admin-Konto für TREK.',
'login.createAccount': 'Konto erstellen', 'login.createAccount': 'Konto erstellen',
'login.createAccountHint': 'Neues Konto registrieren.', 'login.createAccountHint': 'Neues Konto registrieren.',
'login.creating': 'Erstelle…', 'login.creating': 'Erstelle…',
@@ -222,6 +222,7 @@ const de: Record<string, string> = {
'login.oidc.invalidState': 'Ungültige Sitzung. Bitte erneut versuchen.', 'login.oidc.invalidState': 'Ungültige Sitzung. Bitte erneut versuchen.',
'login.demoFailed': 'Demo-Login fehlgeschlagen', 'login.demoFailed': 'Demo-Login fehlgeschlagen',
'login.oidcSignIn': 'Anmelden mit {name}', 'login.oidcSignIn': 'Anmelden mit {name}',
'login.oidcOnly': 'Passwort-Authentifizierung ist deaktiviert. Bitte melde dich über deinen SSO-Anbieter an.',
'login.demoHint': 'Demo ausprobieren — ohne Registrierung', 'login.demoHint': 'Demo ausprobieren — ohne Registrierung',
'login.mfaTitle': 'Zwei-Faktor-Authentifizierung', 'login.mfaTitle': 'Zwei-Faktor-Authentifizierung',
'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.', 'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.',
@@ -308,6 +309,8 @@ const de: Record<string, string> = {
'admin.oidcIssuer': 'Issuer URL', 'admin.oidcIssuer': 'Issuer URL',
'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com', 'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com',
'admin.oidcSaved': 'OIDC-Konfiguration gespeichert', 'admin.oidcSaved': 'OIDC-Konfiguration gespeichert',
'admin.oidcOnlyMode': 'Passwort-Authentifizierung deaktivieren',
'admin.oidcOnlyModeHint': 'Wenn aktiviert, ist nur SSO-Login erlaubt. Passwort-Login und Registrierung werden blockiert.',
// File Types // File Types
'admin.fileTypes': 'Erlaubte Dateitypen', 'admin.fileTypes': 'Erlaubte Dateitypen',
@@ -318,7 +321,7 @@ const de: Record<string, string> = {
// Addons // Addons
'admin.tabs.addons': 'Addons', 'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons', 'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um NOMAD nach deinen Wünschen anzupassen.', 'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.',
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ', 'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.', 'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
'admin.addons.enabled': 'Aktiviert', 'admin.addons.enabled': 'Aktiviert',
@@ -333,7 +336,7 @@ const de: Record<string, string> = {
// Weather info // Weather info
'admin.weather.title': 'Wetterdaten', 'admin.weather.title': 'Wetterdaten',
'admin.weather.badge': 'Seit 24. März 2026', 'admin.weather.badge': 'Seit 24. März 2026',
'admin.weather.description': 'NOMAD nutzt Open-Meteo als Wetterdatenquelle. Open-Meteo ist ein kostenloser, quelloffener Wetterdienst — es wird kein API-Schlüssel benötigt.', 'admin.weather.description': 'TREK nutzt Open-Meteo als Wetterdatenquelle. Open-Meteo ist ein kostenloser, quelloffener Wetterdienst — es wird kein API-Schlüssel benötigt.',
'admin.weather.forecast': '16-Tage-Vorhersage', 'admin.weather.forecast': '16-Tage-Vorhersage',
'admin.weather.forecastDesc': 'Statt bisher 5 Tage (OpenWeatherMap)', 'admin.weather.forecastDesc': 'Statt bisher 5 Tage (OpenWeatherMap)',
'admin.weather.climate': 'Historische Klimadaten', 'admin.weather.climate': 'Historische Klimadaten',
@@ -356,11 +359,11 @@ const de: Record<string, string> = {
'admin.github.by': 'von', 'admin.github.by': 'von',
'admin.update.available': 'Update verfügbar', 'admin.update.available': 'Update verfügbar',
'admin.update.text': 'NOMAD {version} ist verfügbar. Du verwendest {current}.', 'admin.update.text': 'TREK {version} ist verfügbar. Du verwendest {current}.',
'admin.update.button': 'Auf GitHub ansehen', 'admin.update.button': 'Auf GitHub ansehen',
'admin.update.install': 'Update installieren', 'admin.update.install': 'Update installieren',
'admin.update.confirmTitle': 'Update installieren?', 'admin.update.confirmTitle': 'Update installieren?',
'admin.update.confirmText': 'NOMAD wird von {current} auf {version} aktualisiert. Der Server startet danach automatisch neu.', 'admin.update.confirmText': 'TREK wird von {current} auf {version} aktualisiert. Der Server startet danach automatisch neu.',
'admin.update.dataInfo': 'Alle Daten (Reisen, Benutzer, API-Schlüssel, Uploads, Vacay, Atlas, Budgets) bleiben erhalten.', 'admin.update.dataInfo': 'Alle Daten (Reisen, Benutzer, API-Schlüssel, Uploads, Vacay, Atlas, Budgets) bleiben erhalten.',
'admin.update.warning': 'Die App ist während des Neustarts kurz nicht erreichbar.', 'admin.update.warning': 'Die App ist während des Neustarts kurz nicht erreichbar.',
'admin.update.confirm': 'Jetzt aktualisieren', 'admin.update.confirm': 'Jetzt aktualisieren',
@@ -370,7 +373,7 @@ const de: Record<string, string> = {
'admin.update.backupHint': 'Wir empfehlen, vor dem Update ein Backup zu erstellen und herunterzuladen.', 'admin.update.backupHint': 'Wir empfehlen, vor dem Update ein Backup zu erstellen und herunterzuladen.',
'admin.update.backupLink': 'Zum Backup', 'admin.update.backupLink': 'Zum Backup',
'admin.update.howTo': 'Update-Anleitung', 'admin.update.howTo': 'Update-Anleitung',
'admin.update.dockerText': 'Deine NOMAD-Instanz läuft in Docker. Um auf {version} zu aktualisieren, führe folgende Befehle auf deinem Server aus:', 'admin.update.dockerText': 'Deine TREK-Instanz läuft in Docker. Um auf {version} zu aktualisieren, führe folgende Befehle auf deinem Server aus:',
'admin.update.reloadHint': 'Bitte lade die Seite in wenigen Sekunden neu.', 'admin.update.reloadHint': 'Bitte lade die Seite in wenigen Sekunden neu.',
// Vacay addon // Vacay addon
@@ -416,9 +419,9 @@ const de: Record<string, string> = {
'vacay.carryOver': 'Urlaubsmitnahme', 'vacay.carryOver': 'Urlaubsmitnahme',
'vacay.carryOverHint': 'Resturlaub automatisch ins Folgejahr übertragen', 'vacay.carryOverHint': 'Resturlaub automatisch ins Folgejahr übertragen',
'vacay.sharing': 'Teilen', 'vacay.sharing': 'Teilen',
'vacay.sharingHint': 'Teile deinen Urlaubsplan mit anderen NOMAD-Benutzern', 'vacay.sharingHint': 'Teile deinen Urlaubsplan mit anderen TREK-Benutzern',
'vacay.owner': 'Besitzer', 'vacay.owner': 'Besitzer',
'vacay.shareEmailPlaceholder': 'E-Mail des NOMAD-Benutzers', 'vacay.shareEmailPlaceholder': 'E-Mail des TREK-Benutzers',
'vacay.shareSuccess': 'Plan erfolgreich geteilt', 'vacay.shareSuccess': 'Plan erfolgreich geteilt',
'vacay.shareError': 'Plan konnte nicht geteilt werden', 'vacay.shareError': 'Plan konnte nicht geteilt werden',
'vacay.dissolve': 'Fusion auflösen', 'vacay.dissolve': 'Fusion auflösen',
@@ -430,7 +433,7 @@ const de: Record<string, string> = {
'vacay.noData': 'Keine Daten', 'vacay.noData': 'Keine Daten',
'vacay.changeColor': 'Farbe ändern', 'vacay.changeColor': 'Farbe ändern',
'vacay.inviteUser': 'Benutzer einladen', 'vacay.inviteUser': 'Benutzer einladen',
'vacay.inviteHint': 'Lade einen anderen NOMAD-Benutzer ein, um einen gemeinsamen Urlaubskalender zu teilen.', 'vacay.inviteHint': 'Lade einen anderen TREK-Benutzer ein, um einen gemeinsamen Urlaubskalender zu teilen.',
'vacay.selectUser': 'Benutzer wählen', 'vacay.selectUser': 'Benutzer wählen',
'vacay.sendInvite': 'Einladung senden', 'vacay.sendInvite': 'Einladung senden',
'vacay.inviteSent': 'Einladung gesendet', 'vacay.inviteSent': 'Einladung gesendet',
@@ -609,6 +612,23 @@ const de: Record<string, string> = {
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)', 'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',
'reservations.notes': 'Notizen', 'reservations.notes': 'Notizen',
'reservations.notesPlaceholder': 'Zusätzliche Notizen...', 'reservations.notesPlaceholder': 'Zusätzliche Notizen...',
'reservations.meta.airline': 'Airline',
'reservations.meta.flightNumber': 'Flugnr.',
'reservations.meta.from': 'Von',
'reservations.meta.to': 'Nach',
'reservations.meta.trainNumber': 'Zugnr.',
'reservations.meta.platform': 'Gleis',
'reservations.meta.seat': 'Sitzplatz',
'reservations.meta.checkIn': 'Check-in',
'reservations.meta.checkOut': 'Check-out',
'reservations.meta.linkAccommodation': 'Unterkunft',
'reservations.meta.pickAccommodation': 'Mit Unterkunft verknüpfen',
'reservations.meta.noAccommodation': 'Keine',
'reservations.meta.hotelPlace': 'Hotel',
'reservations.meta.pickHotel': 'Hotel auswählen',
'reservations.meta.fromDay': 'Von',
'reservations.meta.toDay': 'Bis',
'reservations.meta.selectDay': 'Tag wählen',
'reservations.type.flight': 'Flug', 'reservations.type.flight': 'Flug',
'reservations.type.hotel': 'Hotel', 'reservations.type.hotel': 'Hotel',
'reservations.type.restaurant': 'Restaurant', 'reservations.type.restaurant': 'Restaurant',
@@ -702,6 +722,28 @@ const de: Record<string, string> = {
'files.sourceBooking': 'Buchung', 'files.sourceBooking': 'Buchung',
'files.attach': 'Anhängen', 'files.attach': 'Anhängen',
'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)', 'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)',
'files.trash': 'Papierkorb',
'files.trashEmpty': 'Papierkorb ist leer',
'files.emptyTrash': 'Papierkorb leeren',
'files.restore': 'Wiederherstellen',
'files.star': 'Markieren',
'files.unstar': 'Markierung entfernen',
'files.assign': 'Zuweisen',
'files.assignTitle': 'Datei zuweisen',
'files.assignPlace': 'Ort',
'files.assignBooking': 'Buchung',
'files.unassigned': 'Nicht zugewiesen',
'files.unlink': 'Verknüpfung entfernen',
'files.toast.trashed': 'In den Papierkorb verschoben',
'files.toast.restored': 'Datei wiederhergestellt',
'files.toast.trashEmptied': 'Papierkorb geleert',
'files.toast.assigned': 'Datei zugewiesen',
'files.toast.assignError': 'Zuweisung fehlgeschlagen',
'files.toast.restoreError': 'Wiederherstellung fehlgeschlagen',
'files.confirm.permanentDelete': 'Diese Datei endgültig löschen? Das kann nicht rückgängig gemacht werden.',
'files.confirm.emptyTrash': 'Alle Dateien im Papierkorb endgültig löschen? Das kann nicht rückgängig gemacht werden.',
'files.noteLabel': 'Notiz',
'files.notePlaceholder': 'Notiz hinzufügen...',
// Packing // Packing
'packing.title': 'Packliste', 'packing.title': 'Packliste',
@@ -991,7 +1033,6 @@ const de: Record<string, string> = {
'collab.chat.justNow': 'gerade eben', 'collab.chat.justNow': 'gerade eben',
'collab.chat.minutesAgo': 'vor {n} Min.', 'collab.chat.minutesAgo': 'vor {n} Min.',
'collab.chat.hoursAgo': 'vor {n} Std.', 'collab.chat.hoursAgo': 'vor {n} Std.',
'collab.chat.yesterday': 'gestern',
'collab.notes.title': 'Notizen', 'collab.notes.title': 'Notizen',
'collab.notes.new': 'Neue Notiz', 'collab.notes.new': 'Neue Notiz',
'collab.notes.empty': 'Noch keine Notizen', 'collab.notes.empty': 'Noch keine Notizen',
+51 -10
View File
@@ -207,7 +207,7 @@ const en: Record<string, string> = {
'login.signingIn': 'Signing in…', 'login.signingIn': 'Signing in…',
'login.signIn': 'Sign In', 'login.signIn': 'Sign In',
'login.createAdmin': 'Create Admin Account', 'login.createAdmin': 'Create Admin Account',
'login.createAdminHint': 'Set up the first admin account for NOMAD.', 'login.createAdminHint': 'Set up the first admin account for TREK.',
'login.createAccount': 'Create Account', 'login.createAccount': 'Create Account',
'login.createAccountHint': 'Register a new account.', 'login.createAccountHint': 'Register a new account.',
'login.creating': 'Creating…', 'login.creating': 'Creating…',
@@ -222,6 +222,7 @@ const en: Record<string, string> = {
'login.oidc.invalidState': 'Invalid session. Please try again.', 'login.oidc.invalidState': 'Invalid session. Please try again.',
'login.demoFailed': 'Demo login failed', 'login.demoFailed': 'Demo login failed',
'login.oidcSignIn': 'Sign in with {name}', 'login.oidcSignIn': 'Sign in with {name}',
'login.oidcOnly': 'Password authentication is disabled. Please sign in using your SSO provider.',
'login.demoHint': 'Try the demo — no registration needed', 'login.demoHint': 'Try the demo — no registration needed',
'login.mfaTitle': 'Two-factor authentication', 'login.mfaTitle': 'Two-factor authentication',
'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.', 'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.',
@@ -308,6 +309,8 @@ const en: Record<string, string> = {
'admin.oidcIssuer': 'Issuer URL', 'admin.oidcIssuer': 'Issuer URL',
'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com', 'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com',
'admin.oidcSaved': 'OIDC configuration saved', 'admin.oidcSaved': 'OIDC configuration saved',
'admin.oidcOnlyMode': 'Disable password authentication',
'admin.oidcOnlyModeHint': 'When enabled, only SSO login is permitted. Password-based login and registration are blocked.',
// File Types // File Types
'admin.fileTypes': 'Allowed File Types', 'admin.fileTypes': 'Allowed File Types',
@@ -318,7 +321,7 @@ const en: Record<string, string> = {
// Addons // Addons
'admin.tabs.addons': 'Addons', 'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons', 'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Enable or disable features to customize your NOMAD experience.', 'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.',
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ', 'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
'admin.addons.subtitleAfter': ' experience.', 'admin.addons.subtitleAfter': ' experience.',
'admin.addons.enabled': 'Enabled', 'admin.addons.enabled': 'Enabled',
@@ -333,7 +336,7 @@ const en: Record<string, string> = {
// Weather info // Weather info
'admin.weather.title': 'Weather Data', 'admin.weather.title': 'Weather Data',
'admin.weather.badge': 'Since March 24, 2026', 'admin.weather.badge': 'Since March 24, 2026',
'admin.weather.description': 'NOMAD uses Open-Meteo as its weather data source. Open-Meteo is a free, open-source weather service — no API key required.', 'admin.weather.description': 'TREK uses Open-Meteo as its weather data source. Open-Meteo is a free, open-source weather service — no API key required.',
'admin.weather.forecast': '16-day forecast', 'admin.weather.forecast': '16-day forecast',
'admin.weather.forecastDesc': 'Previously 5 days (OpenWeatherMap)', 'admin.weather.forecastDesc': 'Previously 5 days (OpenWeatherMap)',
'admin.weather.climate': 'Historical climate data', 'admin.weather.climate': 'Historical climate data',
@@ -356,11 +359,11 @@ const en: Record<string, string> = {
'admin.github.by': 'by', 'admin.github.by': 'by',
'admin.update.available': 'Update available', 'admin.update.available': 'Update available',
'admin.update.text': 'NOMAD {version} is available. You are running {current}.', 'admin.update.text': 'TREK {version} is available. You are running {current}.',
'admin.update.button': 'View on GitHub', 'admin.update.button': 'View on GitHub',
'admin.update.install': 'Install Update', 'admin.update.install': 'Install Update',
'admin.update.confirmTitle': 'Install Update?', 'admin.update.confirmTitle': 'Install Update?',
'admin.update.confirmText': 'NOMAD will be updated from {current} to {version}. The server will restart automatically afterwards.', 'admin.update.confirmText': 'TREK will be updated from {current} to {version}. The server will restart automatically afterwards.',
'admin.update.dataInfo': 'All your data (trips, users, API keys, uploads, Vacay, Atlas, budgets) will be preserved.', 'admin.update.dataInfo': 'All your data (trips, users, API keys, uploads, Vacay, Atlas, budgets) will be preserved.',
'admin.update.warning': 'The app will be briefly unavailable during the restart.', 'admin.update.warning': 'The app will be briefly unavailable during the restart.',
'admin.update.confirm': 'Update Now', 'admin.update.confirm': 'Update Now',
@@ -370,7 +373,7 @@ const en: Record<string, string> = {
'admin.update.backupHint': 'We recommend creating a backup before updating.', 'admin.update.backupHint': 'We recommend creating a backup before updating.',
'admin.update.backupLink': 'Go to Backup', 'admin.update.backupLink': 'Go to Backup',
'admin.update.howTo': 'How to Update', 'admin.update.howTo': 'How to Update',
'admin.update.dockerText': 'Your NOMAD instance runs in Docker. To update to {version}, run the following commands on your server:', 'admin.update.dockerText': 'Your TREK instance runs in Docker. To update to {version}, run the following commands on your server:',
'admin.update.reloadHint': 'Please reload the page in a few seconds.', 'admin.update.reloadHint': 'Please reload the page in a few seconds.',
// Vacay addon // Vacay addon
@@ -416,9 +419,9 @@ const en: Record<string, string> = {
'vacay.carryOver': 'Carry Over', 'vacay.carryOver': 'Carry Over',
'vacay.carryOverHint': 'Automatically carry remaining vacation days into the next year', 'vacay.carryOverHint': 'Automatically carry remaining vacation days into the next year',
'vacay.sharing': 'Sharing', 'vacay.sharing': 'Sharing',
'vacay.sharingHint': 'Share your vacation plan with other NOMAD users', 'vacay.sharingHint': 'Share your vacation plan with other TREK users',
'vacay.owner': 'Owner', 'vacay.owner': 'Owner',
'vacay.shareEmailPlaceholder': 'Email of NOMAD user', 'vacay.shareEmailPlaceholder': 'Email of TREK user',
'vacay.shareSuccess': 'Plan shared successfully', 'vacay.shareSuccess': 'Plan shared successfully',
'vacay.shareError': 'Could not share plan', 'vacay.shareError': 'Could not share plan',
'vacay.dissolve': 'Dissolve Fusion', 'vacay.dissolve': 'Dissolve Fusion',
@@ -430,7 +433,7 @@ const en: Record<string, string> = {
'vacay.noData': 'No data', 'vacay.noData': 'No data',
'vacay.changeColor': 'Change color', 'vacay.changeColor': 'Change color',
'vacay.inviteUser': 'Invite User', 'vacay.inviteUser': 'Invite User',
'vacay.inviteHint': 'Invite another NOMAD user to share a combined vacation calendar.', 'vacay.inviteHint': 'Invite another TREK user to share a combined vacation calendar.',
'vacay.selectUser': 'Select user', 'vacay.selectUser': 'Select user',
'vacay.sendInvite': 'Send Invite', 'vacay.sendInvite': 'Send Invite',
'vacay.inviteSent': 'Invite sent', 'vacay.inviteSent': 'Invite sent',
@@ -609,6 +612,23 @@ const en: Record<string, string> = {
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)', 'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
'reservations.notes': 'Notes', 'reservations.notes': 'Notes',
'reservations.notesPlaceholder': 'Additional notes...', 'reservations.notesPlaceholder': 'Additional notes...',
'reservations.meta.airline': 'Airline',
'reservations.meta.flightNumber': 'Flight No.',
'reservations.meta.from': 'From',
'reservations.meta.to': 'To',
'reservations.meta.trainNumber': 'Train No.',
'reservations.meta.platform': 'Platform',
'reservations.meta.seat': 'Seat',
'reservations.meta.checkIn': 'Check-in',
'reservations.meta.checkOut': 'Check-out',
'reservations.meta.linkAccommodation': 'Accommodation',
'reservations.meta.pickAccommodation': 'Link to accommodation',
'reservations.meta.noAccommodation': 'None',
'reservations.meta.hotelPlace': 'Hotel',
'reservations.meta.pickHotel': 'Select hotel',
'reservations.meta.fromDay': 'From',
'reservations.meta.toDay': 'To',
'reservations.meta.selectDay': 'Select day',
'reservations.type.flight': 'Flight', 'reservations.type.flight': 'Flight',
'reservations.type.hotel': 'Hotel', 'reservations.type.hotel': 'Hotel',
'reservations.type.restaurant': 'Restaurant', 'reservations.type.restaurant': 'Restaurant',
@@ -702,6 +722,28 @@ const en: Record<string, string> = {
'files.sourceBooking': 'Booking', 'files.sourceBooking': 'Booking',
'files.attach': 'Attach', 'files.attach': 'Attach',
'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)', 'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)',
'files.trash': 'Trash',
'files.trashEmpty': 'Trash is empty',
'files.emptyTrash': 'Empty Trash',
'files.restore': 'Restore',
'files.star': 'Star',
'files.unstar': 'Unstar',
'files.assign': 'Assign',
'files.assignTitle': 'Assign File',
'files.assignPlace': 'Place',
'files.assignBooking': 'Booking',
'files.unassigned': 'Unassigned',
'files.unlink': 'Remove link',
'files.toast.trashed': 'Moved to trash',
'files.toast.restored': 'File restored',
'files.toast.trashEmptied': 'Trash emptied',
'files.toast.assigned': 'File assigned',
'files.toast.assignError': 'Assignment failed',
'files.toast.restoreError': 'Restore failed',
'files.confirm.permanentDelete': 'Permanently delete this file? This cannot be undone.',
'files.confirm.emptyTrash': 'Permanently delete all trashed files? This cannot be undone.',
'files.noteLabel': 'Note',
'files.notePlaceholder': 'Add a note...',
// Packing // Packing
'packing.title': 'Packing List', 'packing.title': 'Packing List',
@@ -991,7 +1033,6 @@ const en: Record<string, string> = {
'collab.chat.justNow': 'just now', 'collab.chat.justNow': 'just now',
'collab.chat.minutesAgo': '{n}m ago', 'collab.chat.minutesAgo': '{n}m ago',
'collab.chat.hoursAgo': '{n}h ago', 'collab.chat.hoursAgo': '{n}h ago',
'collab.chat.yesterday': 'yesterday',
'collab.notes.title': 'Notes', 'collab.notes.title': 'Notes',
'collab.notes.new': 'New Note', 'collab.notes.new': 'New Note',
'collab.notes.empty': 'No notes yet', 'collab.notes.empty': 'No notes yet',
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
export { default } from './es.js'
+21 -1
View File
@@ -337,7 +337,7 @@ body {
} }
/* Brand images: no save/copy/drag */ /* Brand images: no save/copy/drag */
img[alt="NOMAD"] { img[alt="TREK"] {
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
@@ -460,3 +460,23 @@ img[alt="NOMAD"] {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
/* Markdown in Collab Notes */
.collab-note-md strong, .collab-note-md-full strong { font-weight: 700 !important; }
.collab-note-md em, .collab-note-md-full em { font-style: italic !important; }
.collab-note-md h1, .collab-note-md h2, .collab-note-md h3 { font-weight: 700 !important; margin: 0; }
.collab-note-md-full h1 { font-size: 1.3em !important; font-weight: 700 !important; margin: 0.8em 0 0.3em; }
.collab-note-md-full h2 { font-size: 1.15em !important; font-weight: 700 !important; margin: 0.8em 0 0.3em; }
.collab-note-md-full h3 { font-size: 1em !important; font-weight: 700 !important; margin: 0.8em 0 0.3em; }
.collab-note-md p, .collab-note-md-full p { margin: 0 0 0.3em; }
.collab-note-md ul, .collab-note-md-full ul { list-style-type: disc !important; padding-left: 1.4em !important; margin: 0.2em 0; }
.collab-note-md ol, .collab-note-md-full ol { list-style-type: decimal !important; padding-left: 1.4em !important; margin: 0.2em 0; }
.collab-note-md li, .collab-note-md-full li { display: list-item !important; margin: 0.1em 0; }
.collab-note-md code, .collab-note-md-full code { font-size: 0.9em; padding: 1px 5px; border-radius: 4px; background: var(--bg-secondary); }
.collab-note-md-full pre { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); overflow-x: auto; margin: 0.5em 0; }
.collab-note-md-full pre code { padding: 0; background: none; }
.collab-note-md a, .collab-note-md-full a { color: #3b82f6; text-decoration: underline; }
.collab-note-md blockquote, .collab-note-md-full blockquote { border-left: 3px solid var(--border-primary); padding-left: 12px; margin: 0.5em 0; color: var(--text-muted); }
.collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
.collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; }
+24 -3
View File
@@ -39,6 +39,7 @@ interface OidcConfig {
client_secret: string client_secret: string
client_secret_set: boolean client_secret_set: boolean
display_name: string display_name: string
oidc_only: boolean
} }
interface UpdateInfo { interface UpdateInfo {
@@ -72,7 +73,7 @@ export default function AdminPage(): React.ReactElement {
const [createForm, setCreateForm] = useState<{ username: string; email: string; password: string; role: string }>({ username: '', email: '', password: '', role: 'user' }) const [createForm, setCreateForm] = useState<{ username: string; email: string; password: string; role: string }>({ username: '', email: '', password: '', role: 'user' })
// OIDC config // OIDC config
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '' }) const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false })
const [savingOidc, setSavingOidc] = useState<boolean>(false) const [savingOidc, setSavingOidc] = useState<boolean>(false)
// Registration toggle // Registration toggle
@@ -246,7 +247,7 @@ export default function AdminPage(): React.ReactElement {
const handleSaveUser = async () => { const handleSaveUser = async () => {
try { try {
const payload = { const payload: { username?: string; email?: string; role: string; password?: string } = {
username: editForm.username.trim() || undefined, username: editForm.username.trim() || undefined,
email: editForm.email.trim() || undefined, email: editForm.email.trim() || undefined,
role: editForm.role, role: editForm.role,
@@ -715,11 +716,31 @@ export default function AdminPage(): React.ReactElement {
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/> />
</div> </div>
{/* OIDC-only mode toggle */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.oidcOnlyMode')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.oidcOnlyModeHint')}</p>
</div>
<button
onClick={() => setOidcConfig(c => ({ ...c, oidc_only: !c.oidc_only }))}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ml-4 ${
oidcConfig.oidc_only ? 'bg-slate-900' : 'bg-slate-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
oidcConfig.oidc_only ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<button <button
onClick={async () => { onClick={async () => {
setSavingOidc(true) setSavingOidc(true)
try { try {
const payload = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name } const payload: Record<string, unknown> = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only }
if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret
await adminApi.updateOidc(payload) await adminApi.updateOidc(payload)
toast.success(t('admin.oidcSaved')) toast.success(t('admin.oidcSaved'))
+3 -4
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef } from 'react' import React, { useEffect, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useTranslation } from '../i18n' import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n'
import { useSettingsStore } from '../store/settingsStore' import { useSettingsStore } from '../store/settingsStore'
import Navbar from '../components/Layout/Navbar' import Navbar from '../components/Layout/Navbar'
import apiClient from '../api/client' import apiClient from '../api/client'
@@ -100,7 +100,7 @@ function useCountryNames(language: string): (code: string) => string {
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code) const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
useEffect(() => { useEffect(() => {
try { try {
const dn = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) const dn = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' })
setResolver(() => (code: string) => { try { return dn.of(code) || code } catch { return code } }) setResolver(() => (code: string) => { try { return dn.of(code) || code } catch { return code } })
} catch { /* */ } } catch { /* */ }
}, [language]) }, [language])
@@ -255,7 +255,7 @@ export default function AtlasPage(): React.ReactElement {
const c = countryMap[a3] const c = countryMap[a3]
if (c) { if (c) {
const name = resolveName(c.code) const name = resolveName(c.code)
const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { month: 'short', year: 'numeric' }) } const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(getLocaleForLanguage(language), { month: 'short', year: 'numeric' }) }
const tooltipHtml = ` const tooltipHtml = `
<div style="display:flex;flex-direction:column;gap:8px;min-width:160px"> <div style="display:flex;flex-direction:column;gap:8px;min-width:160px">
<div style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;padding-bottom:6px;border-bottom:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}">${name}</div> <div style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;padding-bottom:6px;border-bottom:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}">${name}</div>
@@ -515,4 +515,3 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
</div> </div>
) )
} }
+2 -2
View File
@@ -53,12 +53,12 @@ function getTripStatus(trip: DashboardTrip): string | null {
return 'past' return 'past'
} }
function formatDate(dateStr: string | null | undefined, locale: string = 'de-DE'): string | null { function formatDate(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
if (!dateStr) return null if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }) return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
} }
function formatDateShort(dateStr: string | null | undefined, locale: string = 'de-DE'): string | null { function formatDateShort(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
if (!dateStr) return null if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' }) return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
} }
+47 -9
View File
@@ -12,6 +12,7 @@ interface AppConfig {
demo_mode: boolean demo_mode: boolean
oidc_configured: boolean oidc_configured: boolean
oidc_display_name?: string oidc_display_name?: string
oidc_only_mode: boolean
} }
export default function LoginPage(): React.ReactElement { export default function LoginPage(): React.ReactElement {
@@ -125,7 +126,10 @@ export default function LoginPage(): React.ReactElement {
} }
} }
const showRegisterOption = appConfig?.allow_registration || !appConfig?.has_users const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users) && !appConfig?.oidc_only_mode
// In OIDC-only mode, show a minimal page that redirects directly to the IdP
const oidcOnly = appConfig?.oidc_only_mode && appConfig?.oidc_configured
const inputBase: React.CSSProperties = { const inputBase: React.CSSProperties = {
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb', width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
@@ -207,7 +211,7 @@ export default function LoginPage(): React.ReactElement {
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
}}> }}>
<img src="/logo-light.svg" alt="NOMAD" style={{ height: 72 }} /> <img src="/logo-light.svg" alt="TREK" style={{ height: 72 }} />
<p style={{ margin: 0, fontSize: 20, color: 'rgba(255,255,255,0.6)', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase', whiteSpace: 'nowrap' }}>{t('login.tagline')}</p> <p style={{ margin: 0, fontSize: 20, color: 'rgba(255,255,255,0.6)', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase', whiteSpace: 'nowrap' }}>{t('login.tagline')}</p>
</div> </div>
@@ -287,9 +291,14 @@ export default function LoginPage(): React.ReactElement {
return ( return (
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}> <div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
{/* Sprach-Toggle oben rechts */} {/* Language toggle */}
<button <button
onClick={() => setLanguageLocal(language === 'en' ? 'de' : 'en')} onClick={() => {
const languages = ['en', 'es', 'de']
const currentIndex = languages.indexOf(language)
const nextLanguage = languages[(currentIndex + 1) % languages.length]
setLanguageLocal(nextLanguage)
}}
style={{ style={{
position: 'absolute', top: 16, right: 16, zIndex: 10, position: 'absolute', top: 16, right: 16, zIndex: 10,
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 6,
@@ -303,7 +312,7 @@ export default function LoginPage(): React.ReactElement {
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'} onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
> >
<Globe size={14} /> <Globe size={14} />
{language === 'en' ? 'EN' : 'DE'} {language.toUpperCase()}
</button> </button>
{/* Left — branding */} {/* Left — branding */}
@@ -405,7 +414,7 @@ export default function LoginPage(): React.ReactElement {
<div style={{ position: 'relative', zIndex: 1, maxWidth: 560, textAlign: 'center' }}> <div style={{ position: 'relative', zIndex: 1, maxWidth: 560, textAlign: 'center' }}>
{/* Logo */} {/* Logo */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 48 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 48 }}>
<img src="/logo-light.svg" alt="NOMAD" style={{ height: 64 }} /> <img src="/logo-light.svg" alt="TREK" style={{ height: 64 }} />
</div> </div>
<h2 style={{ margin: '0 0 12px', fontSize: 36, fontWeight: 700, color: 'white', lineHeight: 1.15, letterSpacing: '-0.02em', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}> <h2 style={{ margin: '0 0 12px', fontSize: 36, fontWeight: 700, color: 'white', lineHeight: 1.15, letterSpacing: '-0.02em', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>
@@ -450,11 +459,39 @@ export default function LoginPage(): React.ReactElement {
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, marginBottom: 36 }} <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, marginBottom: 36 }}
className="mobile-logo"> className="mobile-logo">
<style>{`@media(min-width:1024px){.mobile-logo{display:none!important}}`}</style> <style>{`@media(min-width:1024px){.mobile-logo{display:none!important}}`}</style>
<img src="/logo-dark.svg" alt="NOMAD" style={{ height: 48 }} /> <img src="/logo-dark.svg" alt="TREK" style={{ height: 48 }} />
<p style={{ margin: 0, fontSize: 16, color: '#9ca3af', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase', whiteSpace: 'nowrap' }}>{t('login.tagline')}</p> <p style={{ margin: 0, fontSize: 16, color: '#9ca3af', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase', whiteSpace: 'nowrap' }}>{t('login.tagline')}</p>
</div> </div>
<div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}> <div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}>
{oidcOnly ? (
<>
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>{t('login.title')}</h2>
<p style={{ margin: '0 0 24px', fontSize: 13.5, color: '#9ca3af' }}>{t('login.oidcOnly')}</p>
{error && (
<div style={{ padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10, fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
{error}
</div>
)}
<a href="/api/auth/oidc/login"
style={{
width: '100%', padding: '12px',
background: '#111827', color: 'white',
border: 'none', borderRadius: 12,
fontSize: 14, fontWeight: 700, cursor: 'pointer',
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
textDecoration: 'none', transition: 'all 0.15s',
boxSizing: 'border-box',
}}
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#1f2937' }}
onMouseLeave={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#111827' }}
>
<Shield size={16} />
{t('login.oidcSignIn', { name: appConfig?.oidc_display_name || 'SSO' })}
</a>
</>
) : (
<>
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}> <h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
{mode === 'login' && mfaStep {mode === 'login' && mfaStep
? t('login.mfaTitle') ? t('login.mfaTitle')
@@ -586,10 +623,11 @@ export default function LoginPage(): React.ReactElement {
</button> </button>
</p> </p>
)} )}
</>)}
</div> </div>
{/* OIDC / SSO login button */} {/* OIDC / SSO login button (only when OIDC is configured but not in oidc-only mode) */}
{appConfig?.oidc_configured && ( {appConfig?.oidc_configured && !oidcOnly && (
<> <>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} /> <div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
+1 -1
View File
@@ -75,7 +75,7 @@ export default function RegisterPage(): React.ReactElement {
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="lg:hidden flex items-center gap-2 mb-8 justify-center"> <div className="lg:hidden flex items-center gap-2 mb-8 justify-center">
<Map className="w-8 h-8 text-slate-900" /> <Map className="w-8 h-8 text-slate-900" />
<span className="text-2xl font-bold text-slate-900">NOMAD</span> <span className="text-2xl font-bold text-slate-900">TREK</span>
</div> </div>
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8"> <div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
+10
View File
@@ -71,6 +71,13 @@ export default function SettingsPage(): React.ReactElement {
const [currentPassword, setCurrentPassword] = useState<string>('') const [currentPassword, setCurrentPassword] = useState<string>('')
const [newPassword, setNewPassword] = useState<string>('') const [newPassword, setNewPassword] = useState<string>('')
const [confirmPassword, setConfirmPassword] = useState<string>('') const [confirmPassword, setConfirmPassword] = useState<string>('')
const [oidcOnlyMode, setOidcOnlyMode] = useState<boolean>(false)
useEffect(() => {
authApi.getAppConfig?.().then((config) => {
if (config?.oidc_only_mode) setOidcOnlyMode(true)
}).catch(() => {})
}, [])
const [mfaQr, setMfaQr] = useState<string | null>(null) const [mfaQr, setMfaQr] = useState<string | null>(null)
const [mfaSecret, setMfaSecret] = useState<string | null>(null) const [mfaSecret, setMfaSecret] = useState<string | null>(null)
@@ -269,6 +276,7 @@ export default function SettingsPage(): React.ReactElement {
{[ {[
{ value: 'de', label: 'Deutsch' }, { value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' }, { value: 'en', label: 'English' },
{ value: 'es', label: 'Español' },
].map(opt => ( ].map(opt => (
<button <button
key={opt.value} key={opt.value}
@@ -405,6 +413,7 @@ export default function SettingsPage(): React.ReactElement {
</div> </div>
{/* Change Password */} {/* Change Password */}
{!oidcOnlyMode && (
<div style={{ paddingTop: 8, marginTop: 8, borderTop: '1px solid var(--border-secondary)' }}> <div style={{ paddingTop: 8, marginTop: 8, borderTop: '1px solid var(--border-secondary)' }}>
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label> <label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
<div className="space-y-3"> <div className="space-y-3">
@@ -453,6 +462,7 @@ export default function SettingsPage(): React.ReactElement {
</button> </button>
</div> </div>
</div> </div>
)}
{/* MFA */} {/* MFA */}
<div style={{ paddingTop: 8, marginTop: 8, borderTop: '1px solid var(--border-secondary)' }}> <div style={{ paddingTop: 8, marginTop: 8, borderTop: '1px solid var(--border-secondary)' }}>
+43 -8
View File
@@ -33,7 +33,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const { id: tripId } = useParams<{ id: string }>() const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast() const toast = useToast()
const { t } = useTranslation() const { t, language } = useTranslation()
const { settings } = useSettingsStore() const { settings } = useSettingsStore()
const tripStore = useTripStore() const tripStore = useTripStore()
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
@@ -44,7 +44,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [tripMembers, setTripMembers] = useState<TripMember[]>([]) const [tripMembers, setTripMembers] = useState<TripMember[]>([])
const loadAccommodations = useCallback(() => { const loadAccommodations = useCallback(() => {
if (tripId) accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) if (tripId) {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
tripStore.loadReservations(tripId)
}
}, [tripId]) }, [tripId])
useEffect(() => { useEffect(() => {
@@ -64,7 +67,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []), ...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []), ...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []), ...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: 'Collab' }] : []), ...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name') }] : []),
] ]
const [activeTab, setActiveTab] = useState<string>(() => { const [activeTab, setActiveTab] = useState<string>(() => {
@@ -83,6 +86,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [showDayDetail, setShowDayDetail] = useState<Day | null>(null) const [showDayDetail, setShowDayDetail] = useState<Day | null>(null)
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false) const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
const [editingPlace, setEditingPlace] = useState<Place | null>(null) const [editingPlace, setEditingPlace] = useState<Place | null>(null)
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string } | null>(null)
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null) const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
const [showTripForm, setShowTripForm] = useState<boolean>(false) const [showTripForm, setShowTripForm] = useState<boolean>(false)
const [showMembersModal, setShowMembersModal] = useState<boolean>(false) const [showMembersModal, setShowMembersModal] = useState<boolean>(false)
@@ -145,6 +149,22 @@ export default function TripPlannerPage(): React.ReactElement | null {
setSelectedPlaceId(null) setSelectedPlaceId(null)
}, []) }, [])
const handleMapContextMenu = useCallback(async (e) => {
e.originalEvent?.preventDefault()
const { lat, lng } = e.latlng
setPrefillCoords({ lat, lng })
setEditingPlace(null)
setEditingAssignmentId(null)
setShowPlaceForm(true)
try {
const { mapsApi } = await import('../api/client')
const data = await mapsApi.reverse(lat, lng, language)
if (data.name || data.address) {
setPrefillCoords(prev => prev ? { ...prev, name: data.name || '', address: data.address || '' } : prev)
}
} catch { /* best effort */ }
}, [language])
const handleSavePlace = useCallback(async (data) => { const handleSavePlace = useCallback(async (data) => {
const pendingFiles = data._pendingFiles const pendingFiles = data._pendingFiles
delete data._pendingFiles delete data._pendingFiles
@@ -236,18 +256,30 @@ export default function TripPlannerPage(): React.ReactElement | null {
const r = await tripStore.updateReservation(tripId, editingReservation.id, data) const r = await tripStore.updateReservation(tripId, editingReservation.id, data)
toast.success(t('trip.toast.reservationUpdated')) toast.success(t('trip.toast.reservationUpdated'))
setShowReservationModal(false) setShowReservationModal(false)
if (data.type === 'hotel') {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
return r return r
} else { } else {
const r = await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null }) const r = await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationAdded')) toast.success(t('trip.toast.reservationAdded'))
setShowReservationModal(false) setShowReservationModal(false)
// Refresh accommodations if hotel was created
if (data.type === 'hotel') {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
return r return r
} }
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
} }
const handleDeleteReservation = async (id) => { const handleDeleteReservation = async (id) => {
try { await tripStore.deleteReservation(tripId, id); toast.success(t('trip.toast.deleted')) } try {
await tripStore.deleteReservation(tripId, id)
toast.success(t('trip.toast.deleted'))
// Refresh accommodations in case a hotel booking was deleted
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
} }
@@ -345,6 +377,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
selectedPlaceId={selectedPlaceId} selectedPlaceId={selectedPlaceId}
onMarkerClick={handleMarkerClick} onMarkerClick={handleMarkerClick}
onMapClick={handleMapClick} onMapClick={handleMapClick}
onMapContextMenu={handleMapContextMenu}
center={defaultCenter} center={defaultCenter}
zoom={defaultZoom} zoom={defaultZoom}
tileUrl={mapTileUrl} tileUrl={mapTileUrl}
@@ -400,7 +433,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }}
reservations={reservations} reservations={reservations}
onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }}
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
onRemoveAssignment={handleRemoveAssignment} onRemoveAssignment={handleRemoveAssignment}
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)} onDeletePlace={(placeId) => handleDeletePlace(placeId)}
@@ -605,8 +638,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
files={files || []} files={files || []}
onUpload={(fd) => tripStore.addFile(tripId, fd)} onUpload={(fd) => tripStore.addFile(tripId, fd)}
onDelete={(id) => tripStore.deleteFile(tripId, id)} onDelete={(id) => tripStore.deleteFile(tripId, id)}
onUpdate={null} onUpdate={(id, data) => tripStore.loadFiles(tripId)}
places={places} places={places}
days={days}
assignments={assignments}
reservations={reservations} reservations={reservations}
tripId={tripId} tripId={tripId}
allowedFileTypes={allowedFileTypes} allowedFileTypes={allowedFileTypes}
@@ -621,10 +656,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
)} )}
</div> </div>
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null) }} onSave={handleSavePlace} place={editingPlace} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} /> <PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} /> <TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} /> <TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} /> <ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} accommodations={tripAccommodations} />
<ConfirmDialog <ConfirmDialog
isOpen={!!deletePlaceId} isOpen={!!deletePlaceId}
onClose={() => setDeletePlaceId(null)} onClose={() => setDeletePlaceId(null)}
+1 -1
View File
@@ -128,7 +128,7 @@ export default function VacayPage(): React.ReactElement {
<CalendarDays size={18} style={{ color: 'var(--text-primary)' }} /> <CalendarDays size={18} style={{ color: 'var(--text-primary)' }} />
</div> </div>
<div> <div>
<h1 className="text-lg sm:text-xl font-bold" style={{ color: 'var(--text-primary)' }}>Vacay</h1> <h1 className="text-lg sm:text-xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.catalog.vacay.name')}</h1>
<p className="text-xs hidden sm:block" style={{ color: 'var(--text-muted)' }}>{t('vacay.subtitle')}</p> <p className="text-xs hidden sm:block" style={{ color: 'var(--text-muted)' }}>{t('vacay.subtitle')}</p>
</div> </div>
</div> </div>
+17 -2
View File
@@ -1,4 +1,4 @@
// Shared types for the NOMAD travel planner // Shared types for the TREK travel planner
export interface User { export interface User {
id: number id: number
@@ -48,6 +48,7 @@ export interface Place {
price: string | null price: string | null
image_url: string | null image_url: string | null
google_place_id: string | null google_place_id: string | null
osm_id: string | null
place_time: string | null place_time: string | null
end_time: string | null end_time: string | null
created_at: string created_at: string
@@ -116,6 +117,7 @@ export interface Reservation {
id: number id: number
trip_id: number trip_id: number
name: string name: string
title?: string
type: string | null type: string | null
status: 'pending' | 'confirmed' status: 'pending' | 'confirmed'
date: string | null date: string | null
@@ -123,17 +125,30 @@ export interface Reservation {
confirmation_number: string | null confirmation_number: string | null
notes: string | null notes: string | null
url: string | null url: string | null
accommodation_id?: number | null
metadata?: Record<string, string> | null
created_at: string created_at: string
} }
export interface TripFile { export interface TripFile {
id: number id: number
trip_id: number trip_id: number
place_id?: number | null
reservation_id?: number | null
note_id?: number | null
uploaded_by?: number | null
uploaded_by_name?: string | null
uploaded_by_avatar?: string | null
filename: string filename: string
original_name: string original_name: string
file_size?: number | null
mime_type: string mime_type: string
size: number description?: string | null
starred?: number
deleted_at?: string | null
created_at: string created_at: string
reservation_title?: string
url?: string
} }
export interface Settings { export interface Settings {
+6
View File
@@ -1,5 +1,11 @@
import type { AssignmentsMap } from '../types' import type { AssignmentsMap } from '../types'
const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF'])
export function currencyDecimals(currency: string): number {
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
}
export function formatDate(dateStr: string | null | undefined, locale: string): string | null { export function formatDate(dateStr: string | null | undefined, locale: string): string | null {
if (!dateStr) return null if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
+3 -3
View File
@@ -66,9 +66,9 @@ export default defineConfig({
], ],
}, },
manifest: { manifest: {
name: 'NOMAD \u2014 Travel Planner', name: 'TREK \u2014 Travel Planner',
short_name: 'NOMAD', short_name: 'TREK',
description: 'Navigation Organizer for Maps, Activities & Destinations', description: 'Travel Resource & Exploration Kit',
theme_color: '#111827', theme_color: '#111827',
background_color: '#0f172a', background_color: '#0f172a',
display: 'standalone', display: 'standalone',
+2 -2
View File
@@ -1,7 +1,7 @@
services: services:
app: app:
image: mauriceboe/nomad:2.5.5 image: mauriceboe/trek:latest
container_name: nomad container_name: trek
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
+36 -16
View File
@@ -1,12 +1,12 @@
{ {
"name": "nomad-server", "name": "trek-server",
"version": "2.6.1", "version": "2.6.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nomad-server", "name": "trek-server",
"version": "2.6.1", "version": "2.6.2",
"dependencies": { "dependencies": {
"archiver": "^6.0.1", "archiver": "^6.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
@@ -32,7 +32,7 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^4.17.25",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0", "@types/multer": "^2.1.0",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
@@ -569,21 +569,22 @@
} }
}, },
"node_modules/@types/express": { "node_modules/@types/express": {
"version": "5.0.6", "version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/body-parser": "*", "@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0", "@types/express-serve-static-core": "^4.17.33",
"@types/serve-static": "^2" "@types/qs": "*",
"@types/serve-static": "^1"
} }
}, },
"node_modules/@types/express-serve-static-core": { "node_modules/@types/express-serve-static-core": {
"version": "5.1.1", "version": "4.19.8",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -611,6 +612,13 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -690,13 +698,25 @@
} }
}, },
"node_modules/@types/serve-static": { "node_modules/@types/serve-static": {
"version": "2.2.0", "version": "1.15.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/http-errors": "*", "@types/http-errors": "*",
"@types/node": "*",
"@types/send": "<1"
}
},
"node_modules/@types/serve-static/node_modules/@types/send": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
"@types/node": "*" "@types/node": "*"
} }
}, },
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "nomad-server", "name": "trek-server",
"version": "2.6.1", "version": "2.6.2",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"start": "node --import tsx src/index.ts", "start": "node --import tsx src/index.ts",
@@ -31,7 +31,7 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^4.17.25",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0", "@types/multer": "^2.1.0",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
+10
View File
@@ -196,6 +196,16 @@ function runMigrations(db: Database.Database): void {
() => { () => {
try { db.exec('ALTER TABLE users ADD COLUMN mfa_enabled INTEGER DEFAULT 0'); } catch {} try { db.exec('ALTER TABLE users ADD COLUMN mfa_enabled INTEGER DEFAULT 0'); } catch {}
try { db.exec('ALTER TABLE users ADD COLUMN mfa_secret TEXT'); } catch {} try { db.exec('ALTER TABLE users ADD COLUMN mfa_secret TEXT'); } catch {}
try { db.exec('ALTER TABLE places ADD COLUMN osm_id TEXT'); } catch {}
},
() => {
try { db.exec('ALTER TABLE trip_files ADD COLUMN uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
try { db.exec('ALTER TABLE trip_files ADD COLUMN starred INTEGER DEFAULT 0'); } catch {}
try { db.exec('ALTER TABLE trip_files ADD COLUMN deleted_at TEXT'); } catch {}
},
() => {
try { db.exec('ALTER TABLE reservations ADD COLUMN accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL'); } catch {}
try { db.exec('ALTER TABLE reservations ADD COLUMN metadata TEXT'); } catch {}
}, },
]; ];
+2 -2
View File
@@ -3,9 +3,9 @@ import Database from 'better-sqlite3';
function seedDemoData(db: Database.Database): { adminId: number; demoId: number } { function seedDemoData(db: Database.Database): { adminId: number; demoId: number } {
const ADMIN_USER = process.env.DEMO_ADMIN_USER || 'admin'; const ADMIN_USER = process.env.DEMO_ADMIN_USER || 'admin';
const ADMIN_EMAIL = process.env.DEMO_ADMIN_EMAIL || 'admin@nomad.app'; const ADMIN_EMAIL = process.env.DEMO_ADMIN_EMAIL || 'admin@trek.app';
const ADMIN_PASS = process.env.DEMO_ADMIN_PASS || 'admin12345'; const ADMIN_PASS = process.env.DEMO_ADMIN_PASS || 'admin12345';
const DEMO_EMAIL = 'demo@nomad.app'; const DEMO_EMAIL = 'demo@trek.app';
const DEMO_PASS = 'demo12345'; const DEMO_PASS = 'demo12345';
// Create admin user if not exists // Create admin user if not exists
+4 -3
View File
@@ -63,9 +63,10 @@ app.use(helmet({
} }
}, },
crossOriginEmbedderPolicy: false, crossOriginEmbedderPolicy: false,
hsts: process.env.FORCE_HTTPS === 'true' ? { maxAge: 31536000, includeSubDomains: false } : false,
})); }));
// Redirect HTTP to HTTPS in production // Redirect HTTP to HTTPS (opt-in via FORCE_HTTPS=true)
if (process.env.NODE_ENV === 'production' && process.env.FORCE_HTTPS !== 'false') { if (process.env.FORCE_HTTPS === 'true') {
app.use((req: Request, res: Response, next: NextFunction) => { app.use((req: Request, res: Response, next: NextFunction) => {
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next(); if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
res.redirect(301, 'https://' + req.headers.host + req.url); res.redirect(301, 'https://' + req.headers.host + req.url);
@@ -172,7 +173,7 @@ import * as scheduler from './scheduler';
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const server = app.listen(PORT, () => { const server = app.listen(PORT, () => {
console.log(`NOMAD API running on port ${PORT}`); console.log(`TREK API running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED'); if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED');
if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') { if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') {
+5 -3
View File
@@ -94,7 +94,7 @@ router.put('/users/:id', (req: Request, res: Response) => {
router.delete('/users/:id', (req: Request, res: Response) => { router.delete('/users/:id', (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
if (parseInt(req.params.id) === authReq.user.id) { if (parseInt(req.params.id as string) === authReq.user.id) {
return res.status(400).json({ error: 'Cannot delete own account' }); return res.status(400).json({ error: 'Cannot delete own account' });
} }
@@ -122,16 +122,18 @@ router.get('/oidc', (_req: Request, res: Response) => {
client_id: get('oidc_client_id'), client_id: get('oidc_client_id'),
client_secret_set: !!secret, client_secret_set: !!secret,
display_name: get('oidc_display_name'), display_name: get('oidc_display_name'),
oidc_only: get('oidc_only') === 'true',
}); });
}); });
router.put('/oidc', (req: Request, res: Response) => { router.put('/oidc', (req: Request, res: Response) => {
const { issuer, client_id, client_secret, display_name } = req.body; const { issuer, client_id, client_secret, display_name, oidc_only } = req.body;
const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || ''); const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
set('oidc_issuer', issuer); set('oidc_issuer', issuer);
set('oidc_client_id', client_id); set('oidc_client_id', client_id);
if (client_secret !== undefined) set('oidc_client_secret', client_secret); if (client_secret !== undefined) set('oidc_client_secret', client_secret);
set('oidc_display_name', display_name); set('oidc_display_name', display_name);
set('oidc_only', oidc_only ? 'true' : 'false');
res.json({ success: true }); res.json({ success: true });
}); });
@@ -171,7 +173,7 @@ router.get('/version-check', async (_req: Request, res: Response) => {
try { try {
const resp = await fetch( const resp = await fetch(
'https://api.github.com/repos/mauriceboe/NOMAD/releases/latest', 'https://api.github.com/repos/mauriceboe/NOMAD/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'NOMAD-Server' } } { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
); );
if (!resp.ok) return res.json({ current: currentVersion, latest: currentVersion, update_available: false }); if (!resp.ok) return res.json({ current: currentVersion, latest: currentVersion, update_available: false });
const data = await resp.json() as { tag_name?: string; html_url?: string }; const data = await resp.json() as { tag_name?: string; html_url?: string };
+8 -2
View File
@@ -24,12 +24,18 @@ const COUNTRY_BOXES: Record<string, [number, number, number, number]> = {
}; };
function getCountryFromCoords(lat: number, lng: number): string | null { function getCountryFromCoords(lat: number, lng: number): string | null {
let bestCode: string | null = null;
let bestArea = Infinity;
for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) { for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) {
if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) { if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) {
return code; const area = (maxLng - minLng) * (maxLat - minLat);
if (area < bestArea) {
bestArea = area;
bestCode = code;
}
} }
} }
return null; return bestCode;
} }
const NAME_TO_CODE: Record<string, string> = { const NAME_TO_CODE: Record<string, string> = {
+31 -7
View File
@@ -91,6 +91,17 @@ function rateLimiter(maxAttempts: number, windowMs: number) {
} }
const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW); const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW);
function isOidcOnlyMode(): boolean {
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
const enabled = get('oidc_only') === 'true';
if (!enabled) return false;
const oidcConfigured = !!(
(process.env.OIDC_ISSUER || get('oidc_issuer')) &&
(process.env.OIDC_CLIENT_ID || get('oidc_client_id'))
);
return oidcConfigured;
}
function maskKey(key: string | null | undefined): string | null { function maskKey(key: string | null | undefined): string | null {
if (!key) return null; if (!key) return null;
if (key.length <= 8) return '--------'; if (key.length <= 8) return '--------';
@@ -116,11 +127,13 @@ router.get('/app-config', (_req: Request, res: Response) => {
const isDemo = process.env.DEMO_MODE === 'true'; const isDemo = process.env.DEMO_MODE === 'true';
const { version } = require('../../package.json'); const { version } = require('../../package.json');
const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get(); const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get();
const oidcDisplayName = (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get() as { value: string } | undefined)?.value || null; const oidcDisplayName = process.env.OIDC_DISPLAY_NAME || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get() as { value: string } | undefined)?.value || null;
const oidcConfigured = !!( const oidcConfigured = !!(
(db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value && (process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) &&
(db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value (process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value)
); );
const oidcOnlySetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value;
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
res.json({ res.json({
allow_registration: isDemo ? false : allowRegistration, allow_registration: isDemo ? false : allowRegistration,
has_users: userCount > 0, has_users: userCount > 0,
@@ -128,9 +141,10 @@ router.get('/app-config', (_req: Request, res: Response) => {
has_maps_key: hasGoogleKey, has_maps_key: hasGoogleKey,
oidc_configured: oidcConfigured, oidc_configured: oidcConfigured,
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined, oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
oidc_only_mode: oidcOnlyMode,
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv', allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
demo_mode: isDemo, demo_mode: isDemo,
demo_email: isDemo ? 'demo@nomad.app' : undefined, demo_email: isDemo ? 'demo@trek.app' : undefined,
demo_password: isDemo ? 'demo12345' : undefined, demo_password: isDemo ? 'demo12345' : undefined,
}); });
}); });
@@ -139,7 +153,7 @@ router.post('/demo-login', (_req: Request, res: Response) => {
if (process.env.DEMO_MODE !== 'true') { if (process.env.DEMO_MODE !== 'true') {
return res.status(404).json({ error: 'Not found' }); return res.status(404).json({ error: 'Not found' });
} }
const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@nomad.app') as User | undefined; const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@trek.app') as User | undefined;
if (!user) return res.status(500).json({ error: 'Demo user not found' }); if (!user) return res.status(500).json({ error: 'Demo user not found' });
const token = generateToken(user); const token = generateToken(user);
const safe = stripUserForClient(user) as Record<string, unknown>; const safe = stripUserForClient(user) as Record<string, unknown>;
@@ -150,6 +164,9 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
const { username, email, password } = req.body; const { username, email, password } = req.body;
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
if (userCount > 0 && isOidcOnlyMode()) {
return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' });
}
if (userCount > 0) { if (userCount > 0) {
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined; const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
if (setting?.value === 'false') { if (setting?.value === 'false') {
@@ -199,6 +216,10 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
}); });
router.post('/login', authLimiter, (req: Request, res: Response) => { router.post('/login', authLimiter, (req: Request, res: Response) => {
if (isOidcOnlyMode()) {
return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' });
}
const { email, password } = req.body; const { email, password } = req.body;
if (!email || !password) { if (!email || !password) {
@@ -247,7 +268,10 @@ router.get('/me', authenticate, (req: Request, res: Response) => {
router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => { router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') { if (isOidcOnlyMode()) {
return res.status(403).json({ error: 'Password authentication is disabled.' });
}
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@trek.app') {
return res.status(403).json({ error: 'Password change is disabled in demo mode.' }); return res.status(403).json({ error: 'Password change is disabled in demo mode.' });
} }
const { current_password, new_password } = req.body; const { current_password, new_password } = req.body;
@@ -271,7 +295,7 @@ router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req
router.delete('/me', authenticate, (req: Request, res: Response) => { router.delete('/me', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') { if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@trek.app') {
return res.status(403).json({ error: 'Account deletion is disabled in demo mode.' }); return res.status(403).json({ error: 'Account deletion is disabled in demo mode.' });
} }
if (authReq.user.role === 'admin') { if (authReq.user.role === 'admin') {
+44 -11
View File
@@ -5,7 +5,7 @@ import multer from 'multer';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { authenticate, adminOnly } from '../middleware/auth'; import { authenticate, adminOnly } from '../middleware/auth';
import scheduler from '../scheduler'; import * as scheduler from '../scheduler';
import { db, closeDb, reinitialize } from '../db/database'; import { db, closeDb, reinitialize } from '../db/database';
const router = express.Router(); const router = express.Router();
@@ -211,19 +211,52 @@ router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request,
}); });
router.get('/auto-settings', (_req: Request, res: Response) => { router.get('/auto-settings', (_req: Request, res: Response) => {
res.json({ settings: scheduler.loadSettings() }); try {
res.json({ settings: scheduler.loadSettings() });
} catch (err: unknown) {
console.error('[backup] GET auto-settings:', err);
res.status(500).json({ error: 'Could not load backup settings' });
}
}); });
function parseAutoBackupBody(body: Record<string, unknown>): {
enabled: boolean;
interval: string;
keep_days: number;
} {
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
const rawInterval = body.interval;
const interval =
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval)
? rawInterval
: 'daily';
const rawKeep = body.keep_days;
let keepNum: number;
if (typeof rawKeep === 'number' && Number.isFinite(rawKeep)) {
keepNum = Math.floor(rawKeep);
} else if (typeof rawKeep === 'string' && rawKeep.trim() !== '') {
keepNum = parseInt(rawKeep, 10);
} else {
keepNum = NaN;
}
const keep_days = Number.isFinite(keepNum) && keepNum >= 0 ? keepNum : 7;
return { enabled, interval, keep_days };
}
router.put('/auto-settings', (req: Request, res: Response) => { router.put('/auto-settings', (req: Request, res: Response) => {
const { enabled, interval, keep_days } = req.body; try {
const settings = { const settings = parseAutoBackupBody((req.body || {}) as Record<string, unknown>);
enabled: !!enabled, scheduler.saveSettings(settings);
interval: scheduler.VALID_INTERVALS.includes(interval) ? interval : 'daily', scheduler.start();
keep_days: Number.isInteger(keep_days) && keep_days >= 0 ? keep_days : 7, res.json({ settings });
}; } catch (err: unknown) {
scheduler.saveSettings(settings); console.error('[backup] PUT auto-settings:', err);
scheduler.start(); const msg = err instanceof Error ? err.message : String(err);
res.json({ settings }); res.status(500).json({
error: 'Could not save auto-backup settings',
detail: process.env.NODE_ENV !== 'production' ? msg : undefined,
});
}
}); });
router.delete('/:filename', (req: Request, res: Response) => { router.delete('/:filename', (req: Request, res: Response) => {
+36 -1
View File
@@ -219,9 +219,27 @@ accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, r
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, confirmation || null, notes || null); ).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, confirmation || null, notes || null);
const accommodation = getAccommodationWithPlace(result.lastInsertRowid); const accommodationId = result.lastInsertRowid;
// Auto-create linked reservation for this accommodation
const placeName = (db.prepare('SELECT name FROM places WHERE id = ?').get(place_id) as { name: string } | undefined)?.name || 'Hotel';
const startDayDate = (db.prepare('SELECT date FROM days WHERE id = ?').get(start_day_id) as { date: string } | undefined)?.date || null;
const meta: Record<string, string> = {};
if (check_in) meta.check_in_time = check_in;
if (check_out) meta.check_out_time = check_out;
db.prepare(`
INSERT INTO reservations (trip_id, day_id, title, reservation_time, location, confirmation_number, notes, status, type, accommodation_id, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, 'confirmed', 'hotel', ?, ?)
`).run(
tripId, start_day_id, placeName, startDayDate || null, null,
confirmation || null, notes || null, accommodationId,
Object.keys(meta).length > 0 ? JSON.stringify(meta) : null
);
const accommodation = getAccommodationWithPlace(accommodationId);
res.status(201).json({ accommodation }); res.status(201).json({ accommodation });
broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id'] as string); broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id'] as string);
broadcast(tripId, 'reservation:created', {}, req.headers['x-socket-id'] as string);
}); });
accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
@@ -260,6 +278,16 @@ accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request,
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?' 'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?'
).run(newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckOut, newConfirmation, newNotes, id); ).run(newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckOut, newConfirmation, newNotes, id);
// Sync check-in/out/confirmation to linked reservation
const linkedRes = db.prepare('SELECT id, metadata FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number; metadata: string | null } | undefined;
if (linkedRes) {
const meta = linkedRes.metadata ? JSON.parse(linkedRes.metadata) : {};
if (newCheckIn) meta.check_in_time = newCheckIn;
if (newCheckOut) meta.check_out_time = newCheckOut;
db.prepare('UPDATE reservations SET metadata = ?, confirmation_number = COALESCE(?, confirmation_number) WHERE id = ?')
.run(JSON.stringify(meta), newConfirmation || null, linkedRes.id);
}
const accommodation = getAccommodationWithPlace(Number(id)); const accommodation = getAccommodationWithPlace(Number(id));
res.json({ accommodation }); res.json({ accommodation });
broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id'] as string); broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id'] as string);
@@ -271,6 +299,13 @@ accommodationsRouter.delete('/:id', authenticate, requireTripAccess, (req: Reque
const existing = db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId); const existing = db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) return res.status(404).json({ error: 'Accommodation not found' }); if (!existing) return res.status(404).json({ error: 'Accommodation not found' });
// Delete linked reservation
const linkedRes = db.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number } | undefined;
if (linkedRes) {
db.prepare('DELETE FROM reservations WHERE id = ?').run(linkedRes.id);
broadcast(tripId, 'reservation:deleted', { reservationId: linkedRes.id }, req.headers['x-socket-id'] as string);
}
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(id); db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(id);
res.json({ success: true }); res.json({ success: true });
broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, req.headers['x-socket-id'] as string); broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, req.headers['x-socket-id'] as string);
+95 -23
View File
@@ -57,6 +57,13 @@ function verifyTripOwnership(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId); return canAccessTrip(tripId, userId);
} }
const FILE_SELECT = `
SELECT f.*, r.title as reservation_title, u.username as uploaded_by_name, u.avatar as uploaded_by_avatar
FROM trip_files f
LEFT JOIN reservations r ON f.reservation_id = r.id
LEFT JOIN users u ON f.uploaded_by = u.id
`;
function formatFile(file: TripFile) { function formatFile(file: TripFile) {
return { return {
...file, ...file,
@@ -64,24 +71,23 @@ function formatFile(file: TripFile) {
}; };
} }
// List files (excludes soft-deleted by default)
router.get('/', authenticate, (req: Request, res: Response) => { router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { tripId } = req.params; const { tripId } = req.params;
const showTrash = req.query.trash === 'true';
const trip = verifyTripOwnership(tripId, authReq.user.id); const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const files = db.prepare(` const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL';
SELECT f.*, r.title as reservation_title const files = db.prepare(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`).all(tripId) as TripFile[];
FROM trip_files f
LEFT JOIN reservations r ON f.reservation_id = r.id
WHERE f.trip_id = ?
ORDER BY f.created_at DESC
`).all(tripId) as TripFile[];
res.json({ files: files.map(formatFile) }); res.json({ files: files.map(formatFile) });
}); });
// Upload file
router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single('file'), (req: Request, res: Response) => { router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single('file'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params; const { tripId } = req.params;
const { place_id, description, reservation_id } = req.body; const { place_id, description, reservation_id } = req.body;
@@ -90,8 +96,8 @@ router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single
} }
const result = db.prepare(` const result = db.prepare(`
INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description) INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
tripId, tripId,
place_id || null, place_id || null,
@@ -100,19 +106,16 @@ router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single
req.file.originalname, req.file.originalname,
req.file.size, req.file.size,
req.file.mimetype, req.file.mimetype,
description || null description || null,
authReq.user.id
); );
const file = db.prepare(` const file = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(result.lastInsertRowid) as TripFile;
SELECT f.*, r.title as reservation_title
FROM trip_files f
LEFT JOIN reservations r ON f.reservation_id = r.id
WHERE f.id = ?
`).get(result.lastInsertRowid) as TripFile;
res.status(201).json({ file: formatFile(file) }); res.status(201).json({ file: formatFile(file) });
broadcast(tripId, 'file:created', { file: formatFile(file) }, req.headers['x-socket-id'] as string); broadcast(tripId, 'file:created', { file: formatFile(file) }, req.headers['x-socket-id'] as string);
}); });
// Update file metadata
router.put('/:id', authenticate, (req: Request, res: Response) => { router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { tripId, id } = req.params; const { tripId, id } = req.params;
@@ -126,7 +129,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
db.prepare(` db.prepare(`
UPDATE trip_files SET UPDATE trip_files SET
description = COALESCE(?, description), description = ?,
place_id = ?, place_id = ?,
reservation_id = ? reservation_id = ?
WHERE id = ? WHERE id = ?
@@ -137,16 +140,31 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
id id
); );
const updated = db.prepare(` const updated = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile;
SELECT f.*, r.title as reservation_title
FROM trip_files f
LEFT JOIN reservations r ON f.reservation_id = r.id
WHERE f.id = ?
`).get(id) as TripFile;
res.json({ file: formatFile(updated) }); res.json({ file: formatFile(updated) });
broadcast(tripId, 'file:updated', { file: formatFile(updated) }, req.headers['x-socket-id'] as string); broadcast(tripId, 'file:updated', { file: formatFile(updated) }, req.headers['x-socket-id'] as string);
}); });
// Toggle starred
router.patch('/:id/star', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found' });
const newStarred = file.starred ? 0 : 1;
db.prepare('UPDATE trip_files SET starred = ? WHERE id = ?').run(newStarred, id);
const updated = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile;
res.json({ file: formatFile(updated) });
broadcast(tripId, 'file:updated', { file: formatFile(updated) }, req.headers['x-socket-id'] as string);
});
// Soft-delete (move to trash)
router.delete('/:id', authenticate, (req: Request, res: Response) => { router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { tripId, id } = req.params; const { tripId, id } = req.params;
@@ -157,6 +175,40 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined; const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found' }); if (!file) return res.status(404).json({ error: 'File not found' });
db.prepare('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string);
});
// Restore from trash
router.post('/:id/restore', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL').get(id, tripId) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found in trash' });
db.prepare('UPDATE trip_files SET deleted_at = NULL WHERE id = ?').run(id);
const restored = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile;
res.json({ file: formatFile(restored) });
broadcast(tripId, 'file:created', { file: formatFile(restored) }, req.headers['x-socket-id'] as string);
});
// Permanently delete from trash
router.delete('/:id/permanent', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL').get(id, tripId) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found in trash' });
const filePath = path.join(filesDir, file.filename); const filePath = path.join(filesDir, file.filename);
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
try { fs.unlinkSync(filePath); } catch (e) { console.error('Error deleting file:', e); } try { fs.unlinkSync(filePath); } catch (e) { console.error('Error deleting file:', e); }
@@ -167,4 +219,24 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string); broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string);
}); });
// Empty entire trash
router.delete('/trash/empty', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const trashed = db.prepare('SELECT * FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').all(tripId) as TripFile[];
for (const file of trashed) {
const filePath = path.join(filesDir, file.filename);
if (fs.existsSync(filePath)) {
try { fs.unlinkSync(filePath); } catch (e) { console.error('Error deleting file:', e); }
}
}
db.prepare('DELETE FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').run(tripId);
res.json({ success: true, deleted: trashed.length });
});
export default router; export default router;
+219 -4
View File
@@ -13,6 +13,166 @@ interface NominatimResult {
lon: string; lon: string;
} }
interface OverpassElement {
tags?: Record<string, string>;
}
interface WikiCommonsPage {
imageinfo?: { url?: string; extmetadata?: { Artist?: { value?: string } } }[];
}
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/NOMAD)';
// ── OSM Enrichment: Overpass API for details ──────────────────────────────────
async function fetchOverpassDetails(osmType: string, osmId: string): Promise<OverpassElement | null> {
const typeMap: Record<string, string> = { node: 'node', way: 'way', relation: 'rel' };
const oType = typeMap[osmType];
if (!oType) return null;
const query = `[out:json][timeout:5];${oType}(${osmId});out tags;`;
try {
const res = await fetch('https://overpass-api.de/api/interpreter', {
method: 'POST',
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
body: `data=${encodeURIComponent(query)}`,
});
if (!res.ok) return null;
const data = await res.json() as { elements?: OverpassElement[] };
return data.elements?.[0] || null;
} catch { return null; }
}
function parseOpeningHours(ohString: string): { weekdayDescriptions: string[]; openNow: boolean | null } {
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const result: string[] = LONG.map(d => `${d}: ?`);
// Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00"
for (const segment of ohString.split(';')) {
const trimmed = segment.trim();
if (!trimmed) continue;
const match = trimmed.match(/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i);
if (!match) continue;
const [, daysPart, timePart] = match;
const dayIndices = new Set<number>();
for (const range of daysPart.split(',')) {
const parts = range.trim().split('-').map(d => DAYS.indexOf(d.trim()));
if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) {
for (let i = parts[0]; i !== (parts[1] + 1) % 7; i = (i + 1) % 7) dayIndices.add(i);
dayIndices.add(parts[1]);
} else if (parts[0] >= 0) {
dayIndices.add(parts[0]);
}
}
for (const idx of dayIndices) {
result[idx] = `${LONG[idx]}: ${timePart.trim()}`;
}
}
// Compute openNow
let openNow: boolean | null = null;
try {
const now = new Date();
const jsDay = now.getDay();
const dayIdx = jsDay === 0 ? 6 : jsDay - 1;
const todayLine = result[dayIdx];
const timeRanges = [...todayLine.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)];
if (timeRanges.length > 0) {
const nowMins = now.getHours() * 60 + now.getMinutes();
openNow = timeRanges.some(m => {
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
const end = parseInt(m[3]) * 60 + parseInt(m[4]);
return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end;
});
}
} catch { /* best effort */ }
return { weekdayDescriptions: result, openNow };
}
function buildOsmDetails(tags: Record<string, string>, osmType: string, osmId: string) {
let opening_hours: string[] | null = null;
let open_now: boolean | null = null;
if (tags.opening_hours) {
const parsed = parseOpeningHours(tags.opening_hours);
const hasData = parsed.weekdayDescriptions.some(line => !line.endsWith('?'));
if (hasData) {
opening_hours = parsed.weekdayDescriptions;
open_now = parsed.openNow;
}
}
return {
website: tags['contact:website'] || tags.website || null,
phone: tags['contact:phone'] || tags.phone || null,
opening_hours,
open_now,
osm_url: `https://www.openstreetmap.org/${osmType}/${osmId}`,
summary: tags.description || null,
source: 'openstreetmap' as const,
};
}
// ── Wikimedia Commons: Free place photos ──────────────────────────────────────
async function fetchWikimediaPhoto(lat: number, lng: number, name?: string): Promise<{ photoUrl: string; attribution: string | null } | null> {
// Strategy 1: Search Wikipedia for the place name → get the article image
if (name) {
try {
const searchParams = new URLSearchParams({
action: 'query', format: 'json',
titles: name,
prop: 'pageimages',
piprop: 'original',
pilimit: '1',
redirects: '1',
});
const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } });
if (res.ok) {
const data = await res.json() as { query?: { pages?: Record<string, { original?: { source?: string } }> } };
const pages = data.query?.pages;
if (pages) {
for (const page of Object.values(pages)) {
if (page.original?.source) {
return { photoUrl: page.original.source, attribution: 'Wikipedia' };
}
}
}
}
} catch { /* fall through to geosearch */ }
}
// Strategy 2: Wikimedia Commons geosearch by coordinates
const params = new URLSearchParams({
action: 'query', format: 'json',
generator: 'geosearch',
ggsprimary: 'all',
ggsnamespace: '6',
ggsradius: '300',
ggscoord: `${lat}|${lng}`,
ggslimit: '5',
prop: 'imageinfo',
iiprop: 'url|extmetadata|mime',
iiurlwidth: '600',
});
try {
const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { 'User-Agent': UA } });
if (!res.ok) return null;
const data = await res.json() as { query?: { pages?: Record<string, WikiCommonsPage & { imageinfo?: { mime?: string }[] }> } };
const pages = data.query?.pages;
if (!pages) return null;
for (const page of Object.values(pages)) {
const info = page.imageinfo?.[0];
// Only use actual photos (JPEG/PNG), skip SVGs and PDFs
const mime = (info as { mime?: string })?.mime || '';
if (info?.url && (mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) {
const attribution = info.extmetadata?.Artist?.value?.replace(/<[^>]+>/g, '').trim() || null;
return { photoUrl: info.url, attribution };
}
}
return null;
} catch { return null; }
}
interface GooglePlaceResult { interface GooglePlaceResult {
id: string; id: string;
displayName?: { text: string }; displayName?: { text: string };
@@ -69,13 +229,13 @@ async function searchNominatim(query: string, lang?: string) {
'accept-language': lang || 'en', 'accept-language': lang || 'en',
}); });
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, { const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
headers: { 'User-Agent': 'NOMAD Travel Planner (https://github.com/mauriceboe/NOMAD)' }, headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/NOMAD)' },
}); });
if (!response.ok) throw new Error('Nominatim API error'); if (!response.ok) throw new Error('Nominatim API error');
const data = await response.json() as NominatimResult[]; const data = await response.json() as NominatimResult[];
return data.map(item => ({ return data.map(item => ({
google_place_id: null, google_place_id: null,
osm_id: `${item.osm_type}/${item.osm_id}`, osm_id: `${item.osm_type}:${item.osm_id}`,
name: item.name || item.display_name?.split(',')[0] || '', name: item.name || item.display_name?.split(',')[0] || '',
address: item.display_name || '', address: item.display_name || '',
lat: parseFloat(item.lat) || null, lat: parseFloat(item.lat) || null,
@@ -145,6 +305,21 @@ router.get('/details/:placeId', authenticate, async (req: Request, res: Response
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { placeId } = req.params; const { placeId } = req.params;
// OSM details: placeId is "node:123456" or "way:123456" etc.
if (placeId.includes(':')) {
const [osmType, osmId] = placeId.split(':');
try {
const element = await fetchOverpassDetails(osmType, osmId);
if (!element?.tags) return res.json({ place: buildOsmDetails({}, osmType, osmId) });
res.json({ place: buildOsmDetails(element.tags, osmType, osmId) });
} catch (err: unknown) {
console.error('OSM details error:', err);
res.status(500).json({ error: 'Error fetching OSM details' });
}
return;
}
// Google details
const apiKey = getMapsKey(authReq.user.id); const apiKey = getMapsKey(authReq.user.id);
if (!apiKey) { if (!apiKey) {
return res.status(400).json({ error: 'Google Maps API key not configured' }); return res.status(400).json({ error: 'Google Maps API key not configured' });
@@ -187,6 +362,7 @@ router.get('/details/:placeId', authenticate, async (req: Request, res: Response
time: r.relativePublishTimeDescription || null, time: r.relativePublishTimeDescription || null,
photo: r.authorAttribution?.photoUri || null, photo: r.authorAttribution?.photoUri || null,
})), })),
source: 'google' as const,
}; };
res.json({ place }); res.json({ place });
@@ -205,11 +381,28 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution }); return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution });
} }
// Wikimedia Commons fallback for OSM places (using lat/lng query params)
const lat = parseFloat(req.query.lat as string);
const lng = parseFloat(req.query.lng as string);
const apiKey = getMapsKey(authReq.user.id); const apiKey = getMapsKey(authReq.user.id);
if (!apiKey) { const isCoordLookup = placeId.startsWith('coords:');
return res.status(400).json({ error: 'Google Maps API key not configured' });
// No Google key or coordinate-only lookup → try Wikimedia
if (!apiKey || isCoordLookup) {
if (!isNaN(lat) && !isNaN(lng)) {
try {
const wiki = await fetchWikimediaPhoto(lat, lng, req.query.name as string);
if (wiki) {
photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() });
return res.json(wiki);
}
} catch { /* fall through */ }
}
return res.status(404).json({ error: 'No photo available' });
} }
// Google Photos
try { try {
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, { const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
headers: { headers: {
@@ -259,4 +452,26 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
} }
}); });
// Reverse geocoding via Nominatim
router.get('/reverse', authenticate, async (req: Request, res: Response) => {
const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string };
if (!lat || !lng) return res.status(400).json({ error: 'lat and lng required' });
try {
const params = new URLSearchParams({
lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18',
'accept-language': lang || 'en',
});
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
headers: { 'User-Agent': UA },
});
if (!response.ok) return res.json({ name: null, address: null });
const data = await response.json() as { name?: string; display_name?: string; address?: Record<string, string> };
const addr = data.address || {};
const name = data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null;
res.json({ name, address: data.display_name || null });
} catch {
res.json({ name: null, address: null });
}
});
export default router; export default router;
+4 -4
View File
@@ -52,10 +52,10 @@ setInterval(() => {
function getOidcConfig() { function getOidcConfig() {
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
const issuer = get('oidc_issuer'); const issuer = process.env.OIDC_ISSUER || get('oidc_issuer');
const clientId = get('oidc_client_id'); const clientId = process.env.OIDC_CLIENT_ID || get('oidc_client_id');
const clientSecret = get('oidc_client_secret'); const clientSecret = process.env.OIDC_CLIENT_SECRET || get('oidc_client_secret');
const displayName = get('oidc_display_name') || 'SSO'; const displayName = process.env.OIDC_DISPLAY_NAME || get('oidc_display_name') || 'SSO';
if (!issuer || !clientId || !clientSecret) return null; if (!issuer || !clientId || !clientSecret) return null;
return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName }; return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName };
} }
+13 -13
View File
@@ -22,7 +22,7 @@ interface UnsplashSearchResponse {
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => { router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId } = req.params; const { tripId } = req.params
const { search, category, tag } = req.query; const { search, category, tag } = req.query;
let query = ` let query = `
@@ -41,12 +41,12 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) =
if (category) { if (category) {
query += ' AND p.category_id = ?'; query += ' AND p.category_id = ?';
params.push(category); params.push(category as string);
} }
if (tag) { if (tag) {
query += ' AND p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)'; query += ' AND p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)';
params.push(tag); params.push(tag as string);
} }
query += ' ORDER BY p.created_at DESC'; query += ' ORDER BY p.created_at DESC';
@@ -73,12 +73,12 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) =
}); });
router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => { router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
const { tripId } = req.params; const { tripId } = req.params
const { const {
name, description, lat, lng, address, category_id, price, currency, name, description, lat, lng, address, category_id, price, currency,
place_time, end_time, place_time, end_time,
duration_minutes, notes, image_url, google_place_id, website, phone, duration_minutes, notes, image_url, google_place_id, osm_id, website, phone,
transport_mode, tags = [] transport_mode, tags = []
} = req.body; } = req.body;
@@ -89,13 +89,13 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
const result = db.prepare(` const result = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
place_time, end_time, place_time, end_time,
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode) duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, transport_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
tripId, name, description || null, lat || null, lng || null, address || null, tripId, name, description || null, lat || null, lng || null, address || null,
category_id || null, price || null, currency || null, category_id || null, price || null, currency || null,
place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null, place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null,
google_place_id || null, website || null, phone || null, transport_mode || 'walking' google_place_id || null, osm_id || null, website || null, phone || null, transport_mode || 'walking'
); );
const placeId = result.lastInsertRowid; const placeId = result.lastInsertRowid;
@@ -107,13 +107,13 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
} }
} }
const place = getPlaceWithTags(placeId); const place = getPlaceWithTags(Number(placeId));
res.status(201).json({ place }); res.status(201).json({ place });
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
}); });
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params; const { tripId, id } = req.params
const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId); const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!placeCheck) { if (!placeCheck) {
@@ -126,7 +126,7 @@ router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response
router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, res: Response) => { router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { tripId, id } = req.params; const { tripId, id } = req.params
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined; const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined;
if (!place) { if (!place) {
@@ -166,7 +166,7 @@ router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, r
}); });
router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => { router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
const { tripId, id } = req.params; const { tripId, id } = req.params
const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined; const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined;
if (!existingPlace) { if (!existingPlace) {
@@ -238,7 +238,7 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name
}); });
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params; const { tripId, id } = req.params
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId); const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!place) { if (!place) {
+90 -10
View File
@@ -18,10 +18,13 @@ router.get('/', authenticate, (req: Request, res: Response) => {
if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const reservations = db.prepare(` const reservations = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
FROM reservations r FROM reservations r
LEFT JOIN days d ON r.day_id = d.id LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id LEFT JOIN places p ON r.place_id = p.id
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
WHERE r.trip_id = ? WHERE r.trip_id = ?
ORDER BY r.reservation_time ASC, r.created_at ASC ORDER BY r.reservation_time ASC, r.created_at ASC
`).all(tripId); `).all(tripId);
@@ -32,16 +35,29 @@ router.get('/', authenticate, (req: Request, res: Response) => {
router.post('/', authenticate, (req: Request, res: Response) => { router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { tripId } = req.params; const { tripId } = req.params;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body; const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation } = req.body;
const trip = verifyTripOwnership(tripId, authReq.user.id); const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!title) return res.status(400).json({ error: 'Title is required' }); if (!title) return res.status(400).json({ error: 'Title is required' });
// Auto-create accommodation for hotel reservations
let resolvedAccommodationId = accommodation_id || null;
if (type === 'hotel' && !resolvedAccommodationId && create_accommodation) {
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
if (accPlaceId && start_day_id && end_day_id) {
const accResult = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
resolvedAccommodationId = accResult.lastInsertRowid;
broadcast(tripId, 'accommodation:created', {}, req.headers['x-socket-id'] as string);
}
}
const result = db.prepare(` const result = db.prepare(`
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type) INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
tripId, tripId,
day_id || null, day_id || null,
@@ -54,14 +70,32 @@ router.post('/', authenticate, (req: Request, res: Response) => {
confirmation_number || null, confirmation_number || null,
notes || null, notes || null,
status || 'pending', status || 'pending',
type || 'other' type || 'other',
resolvedAccommodationId,
metadata ? JSON.stringify(metadata) : null
); );
// Sync check-in/out to accommodation if linked
if (accommodation_id && metadata) {
const meta = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
if (meta.check_in_time || meta.check_out_time) {
db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?')
.run(meta.check_in_time || null, meta.check_out_time || null, accommodation_id);
}
if (confirmation_number) {
db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?')
.run(confirmation_number, accommodation_id);
}
}
const reservation = db.prepare(` const reservation = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
FROM reservations r FROM reservations r
LEFT JOIN days d ON r.day_id = d.id LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id LEFT JOIN places p ON r.place_id = p.id
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
WHERE r.id = ? WHERE r.id = ?
`).get(result.lastInsertRowid); `).get(result.lastInsertRowid);
@@ -72,7 +106,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
router.put('/:id', authenticate, (req: Request, res: Response) => { router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { tripId, id } = req.params; const { tripId, id } = req.params;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body; const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation } = req.body;
const trip = verifyTripOwnership(tripId, authReq.user.id); const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
@@ -80,6 +114,24 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as Reservation | undefined; const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as Reservation | undefined;
if (!reservation) return res.status(404).json({ error: 'Reservation not found' }); if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
// Update or create accommodation for hotel reservations
let resolvedAccId = accommodation_id !== undefined ? (accommodation_id || null) : reservation.accommodation_id;
if (type === 'hotel' && create_accommodation) {
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
if (accPlaceId && start_day_id && end_day_id) {
if (resolvedAccId) {
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
.run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
} else {
const accResult = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
resolvedAccId = accResult.lastInsertRowid;
}
broadcast(tripId, 'accommodation:updated', {}, req.headers['x-socket-id'] as string);
}
}
db.prepare(` db.prepare(`
UPDATE reservations SET UPDATE reservations SET
title = COALESCE(?, title), title = COALESCE(?, title),
@@ -92,7 +144,9 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
place_id = ?, place_id = ?,
assignment_id = ?, assignment_id = ?,
status = COALESCE(?, status), status = COALESCE(?, status),
type = COALESCE(?, type) type = COALESCE(?, type),
accommodation_id = ?,
metadata = ?
WHERE id = ? WHERE id = ?
`).run( `).run(
title || null, title || null,
@@ -106,14 +160,34 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
assignment_id !== undefined ? (assignment_id || null) : reservation.assignment_id, assignment_id !== undefined ? (assignment_id || null) : reservation.assignment_id,
status || null, status || null,
type || null, type || null,
resolvedAccId,
metadata !== undefined ? (metadata ? JSON.stringify(metadata) : null) : reservation.metadata,
id id
); );
// Sync check-in/out to accommodation if linked
const resolvedMeta = metadata !== undefined ? metadata : (reservation.metadata ? JSON.parse(reservation.metadata as string) : null);
if (resolvedAccId && resolvedMeta) {
const meta = typeof resolvedMeta === 'string' ? JSON.parse(resolvedMeta) : resolvedMeta;
if (meta.check_in_time || meta.check_out_time) {
db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?')
.run(meta.check_in_time || null, meta.check_out_time || null, resolvedAccId);
}
const resolvedConf = confirmation_number !== undefined ? confirmation_number : reservation.confirmation_number;
if (resolvedConf) {
db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?')
.run(resolvedConf, resolvedAccId);
}
}
const updated = db.prepare(` const updated = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
FROM reservations r FROM reservations r
LEFT JOIN days d ON r.day_id = d.id LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id LEFT JOIN places p ON r.place_id = p.id
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
WHERE r.id = ? WHERE r.id = ?
`).get(id); `).get(id);
@@ -128,9 +202,15 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id); const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const reservation = db.prepare('SELECT id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId); const reservation = db.prepare('SELECT id, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as { id: number; accommodation_id: number | null } | undefined;
if (!reservation) return res.status(404).json({ error: 'Reservation not found' }); if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
// Delete linked accommodation if exists
if (reservation.accommodation_id) {
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(reservation.accommodation_id);
broadcast(tripId, 'accommodation:deleted', { accommodationId: reservation.accommodation_id }, req.headers['x-socket-id'] as string);
}
db.prepare('DELETE FROM reservations WHERE id = ?').run(id); db.prepare('DELETE FROM reservations WHERE id = ?').run(id);
res.json({ success: true }); res.json({ success: true });
broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string); broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string);
+7 -2
View File
@@ -69,6 +69,7 @@ function getOwnPlan(userId: number) {
const yr = new Date().getFullYear(); const yr = new Date().getFullYear();
db.prepare('INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)').run(plan.id, yr); db.prepare('INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)').run(plan.id, yr);
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)').run(userId, plan.id, yr); db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)').run(userId, plan.id, yr);
db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(userId, plan.id, '#6366f1');
} }
return plan; return plan;
} }
@@ -296,11 +297,15 @@ router.post('/invite/accept', (req: Request, res: Response) => {
const COLORS = ['#6366f1','#ec4899','#14b8a6','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#64748b','#be185d','#0d9488']; const COLORS = ['#6366f1','#ec4899','#14b8a6','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#64748b','#be185d','#0d9488'];
const existingColors = (db.prepare('SELECT color FROM vacay_user_colors WHERE plan_id = ? AND user_id != ?').all(plan_id, authReq.user.id) as { color: string }[]).map(r => r.color); const existingColors = (db.prepare('SELECT color FROM vacay_user_colors WHERE plan_id = ? AND user_id != ?').all(plan_id, authReq.user.id) as { color: string }[]).map(r => r.color);
const myColor = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(authReq.user.id, plan_id) as { color: string } | undefined; const myColor = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(authReq.user.id, plan_id) as { color: string } | undefined;
if (myColor && existingColors.includes(myColor.color)) { const effectiveColor = myColor?.color || '#6366f1';
if (existingColors.includes(effectiveColor)) {
const available = COLORS.find(c => !existingColors.includes(c)); const available = COLORS.find(c => !existingColors.includes(c));
if (available) { if (available) {
db.prepare('UPDATE vacay_user_colors SET color = ? WHERE user_id = ? AND plan_id = ?').run(available, authReq.user.id, plan_id); db.prepare(`INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)
ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color`).run(authReq.user.id, plan_id, available);
} }
} else if (!myColor) {
db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(authReq.user.id, plan_id, effectiveColor);
} }
const targetYears = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(plan_id) as { year: number }[]; const targetYears = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(plan_id) as { year: number }[];
+3 -3
View File
@@ -1,4 +1,4 @@
import cron from 'node-cron'; import cron, { type ScheduledTask } from 'node-cron';
import archiver from 'archiver'; import archiver from 'archiver';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
@@ -23,7 +23,7 @@ interface BackupSettings {
keep_days: number; keep_days: number;
} }
let currentTask: cron.ScheduledTask | null = null; let currentTask: ScheduledTask | null = null;
function loadSettings(): BackupSettings { function loadSettings(): BackupSettings {
try { try {
@@ -110,7 +110,7 @@ function start(): void {
} }
// Demo mode: hourly reset of demo user data // Demo mode: hourly reset of demo user data
let demoTask: cron.ScheduledTask | null = null; let demoTask: ScheduledTask | null = null;
function startDemoReset(): void { function startDemoReset(): void {
if (demoTask) { demoTask.stop(); demoTask = null; } if (demoTask) { demoTask.stop(); demoTask = null; }
+7
View File
@@ -62,6 +62,7 @@ export interface Place {
notes?: string | null; notes?: string | null;
image_url?: string | null; image_url?: string | null;
google_place_id?: string | null; google_place_id?: string | null;
osm_id?: string | null;
website?: string | null; website?: string | null;
phone?: string | null; phone?: string | null;
transport_mode?: string; transport_mode?: string;
@@ -147,6 +148,8 @@ export interface Reservation {
notes?: string | null; notes?: string | null;
status: string; status: string;
type: string; type: string;
accommodation_id?: number | null;
metadata?: string | null;
created_at?: string; created_at?: string;
day_number?: number; day_number?: number;
place_name?: string; place_name?: string;
@@ -158,11 +161,15 @@ export interface TripFile {
place_id?: number | null; place_id?: number | null;
reservation_id?: number | null; reservation_id?: number | null;
note_id?: number | null; note_id?: number | null;
uploaded_by?: number | null;
uploaded_by_name?: string | null;
filename: string; filename: string;
original_name: string; original_name: string;
file_size?: number | null; file_size?: number | null;
mime_type?: string | null; mime_type?: string | null;
description?: string | null; description?: string | null;
starred?: number;
deleted_at?: string | null;
created_at?: string; created_at?: string;
reservation_title?: string; reservation_title?: string;
url?: string; url?: string;
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0"?>
<Container version="2">
<Name>TREK</Name>
<Repository>mauriceboe/trek</Repository>
<Registry>https://hub.docker.com/r/mauriceboe/trek</Registry>
<Branch>
<Tag>latest</Tag>
<TagDescription>Latest stable release</TagDescription>
</Branch>
<Network>bridge</Network>
<Privileged>false</Privileged>
<Support>https://github.com/mauriceboe/TREK/issues</Support>
<Project>https://github.com/mauriceboe/TREK</Project>
<Overview>TREK is a self-hosted, real-time collaborative travel planner with interactive maps, budgets, bookings, packing lists, file management, and more. Plan trips together with your group — changes sync instantly across all connected users. Includes OIDC/SSO support, dark mode, PWA, and a modular addon system (Vacay, Atlas, Collab, Budget, Packing).</Overview>
<Category>Productivity: Tools:</Category>
<WebUI>http://[IP]:[PORT:3000]</WebUI>
<TemplateURL>https://raw.githubusercontent.com/mauriceboe/TREK/main/unraid-template.xml</TemplateURL>
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/client/public/icons/icon-dark.svg</Icon>
<ExtraParams/>
<PostArgs/>
<DonateText>Support TREK development</DonateText>
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
<Requires/>
<Config Name="Web UI Port" Target="3000" Default="3000" Mode="tcp" Description="Port for the web interface" Type="Port" Display="always" Required="true" Mask="false">3000</Config>
<Config Name="Data" Target="/app/data" Default="/mnt/user/appdata/trek/data" Mode="rw" Description="Database and app data" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/trek/data</Config>
<Config Name="Uploads" Target="/app/uploads" Default="/mnt/user/appdata/trek/uploads" Mode="rw" Description="Uploaded files (photos, documents)" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/trek/uploads</Config>
<Config Name="NODE_ENV" Target="NODE_ENV" Default="production" Mode="" Description="Node environment" Type="Variable" Display="advanced" Required="false" Mask="false">production</Config>
<Config Name="JWT_SECRET" Target="JWT_SECRET" Default="" Mode="" Description="JWT secret key (auto-generated if empty)" Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="PORT" Target="PORT" Default="3000" Mode="" Description="Internal port" Type="Variable" Display="advanced" Required="false" Mask="false">3000</Config>
</Container>