v2.6.2 — TREK Rebrand, OSM Enrichment, File Management, Hotel Bookings & Bug Fixes

Rebrand:
- NOMAD → TREK branding across all UI, translations, server, PWA manifest
- New TREK logos (dark/light, with/without icon)
- Liquid glass toast notifications

Bugs Fixed:
- HTTPS redirect now opt-in only (FORCE_HTTPS=true), fixes #33 #43 #52 #54 #55
- PDF export "Tag" fallback uses i18n, fixes #15
- Vacay sharing color collision detection, fixes #25
- Backup settings import fix (PR #47)
- Atlas country detection uses smallest bounding box, fixes #31
- JPY and zero-decimal currencies formatted correctly, fixes #32
- HTML lang="en" instead of hardcoded "de", fixes #34
- Duplicate translation keys removed
- setSelectedAssignmentId crash fixed

New Features:
- OSM enrichment: Overpass API for opening hours, Wikimedia Commons for photos
- Reverse geocoding on map right-click to add places
- OIDC config via environment variables (OIDC_ISSUER, OIDC_CLIENT_ID, etc.), fixes #48
- Multi-arch Docker build (ARM64 + AMD64), fixes #11
- File management: star, trash/restore, upload owner, assign to places/bookings, notes
- Markdown rendering in Collab Notes with expand modal, fixes #17
- Type-specific booking fields (flight: airline/number/airports, hotel: check-in/out/days, train: number/platform/seat), fixes #35
- Hotel bookings auto-create accommodations, bidirectional sync
- Multiple hotels per day with check-in/check-out color coding
- Ko-fi and Buy Me a Coffee support cards
- GitHub releases proxy with server-side caching
This commit is contained in:
Maurice
2026-03-28 16:38:08 +01:00
parent 5940b7f24e
commit e78c2a97bd
51 changed files with 3460 additions and 510 deletions

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,52 @@ 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/nomad,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:
- 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/nomad@sha256:%s\n' *)
docker buildx imagetools create -t mauriceboe/nomad:latest "${digests[@]}"
- name: Inspect manifest
run: docker buildx imagetools inspect mauriceboe/nomad:latest

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 -->

1483
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

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

View File

@@ -143,8 +143,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 = {
@@ -158,12 +159,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 = {

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>

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 { 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) {
@@ -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>
) )
} }

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) && (

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)}

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>
) )

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,7 +80,7 @@ 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',
@@ -123,7 +123,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 +151,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 +159,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 }} />? {language === 'de' ? 'Was ist ' : 'What is '}<img src="/text-dark.svg" alt="TREK" style={{ height: 13, marginRight: -2 }} />?
</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 +213,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>

View File

@@ -91,8 +91,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 */}
@@ -231,7 +231,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>

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)

View File

@@ -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}/">

View File

@@ -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])
@@ -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={{

View File

@@ -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} />

View File

@@ -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('')

View File

@@ -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
@@ -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 }} />

View File

@@ -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>

View File

@@ -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>

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)

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>
</>
) )
} }

View File

@@ -191,7 +191,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…',
@@ -295,7 +295,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',
@@ -310,7 +310,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',
@@ -333,11 +333,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',
@@ -347,7 +347,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
@@ -393,9 +393,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',
@@ -407,7 +407,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',
@@ -586,6 +586,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',
@@ -679,6 +696,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',
@@ -968,7 +1007,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',

View File

@@ -191,7 +191,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…',
@@ -295,7 +295,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',
@@ -310,7 +310,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',
@@ -333,11 +333,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',
@@ -347,7 +347,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
@@ -393,9 +393,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',
@@ -407,7 +407,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',
@@ -586,6 +586,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',
@@ -679,6 +696,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',
@@ -968,7 +1007,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',

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; }

View File

@@ -186,7 +186,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>
@@ -384,7 +384,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' }}>
@@ -429,7 +429,7 @@ 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>

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">

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(() => {
@@ -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)}

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
@@ -46,6 +46,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
@@ -114,6 +115,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
@@ -121,17 +123,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 {

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, {

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',

View File

@@ -1,12 +1,12 @@
{ {
"name": "nomad-server", "name": "nomad-server",
"version": "2.6.0", "version": "2.6.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nomad-server", "name": "nomad-server",
"version": "2.6.0", "version": "2.6.1",
"dependencies": { "dependencies": {
"archiver": "^6.0.1", "archiver": "^6.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.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",

View File

@@ -193,6 +193,18 @@ function runMigrations(db: Database.Database): void {
() => { () => {
try { db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); } catch {} try { db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time 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 {}
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {

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

View File

@@ -64,8 +64,8 @@ app.use(helmet({
}, },
crossOriginEmbedderPolicy: false, crossOriginEmbedderPolicy: 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 +172,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') {

View File

@@ -171,7 +171,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 };

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> = {

View File

@@ -84,10 +84,10 @@ 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)
); );
res.json({ res.json({
allow_registration: isDemo ? false : allowRegistration, allow_registration: isDemo ? false : allowRegistration,
@@ -98,7 +98,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined, oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
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,
}); });
}); });
@@ -107,7 +107,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 { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...safe } = user; const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...safe } = user;
@@ -205,7 +205,7 @@ 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 (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;
@@ -229,7 +229,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') {
@@ -497,4 +497,30 @@ router.get('/travel-stats', authenticate, (req: Request, res: Response) => {
}); });
}); });
// GitHub releases proxy (cached, avoids client-side rate limits)
let releasesCache: { data: unknown[]; fetchedAt: number } | null = null;
const RELEASES_CACHE_TTL = 30 * 60 * 1000;
router.get('/github-releases', authenticate, async (req: Request, res: Response) => {
const page = parseInt(req.query.page as string) || 1;
const perPage = Math.min(parseInt(req.query.per_page as string) || 10, 30);
if (page === 1 && releasesCache && Date.now() - releasesCache.fetchedAt < RELEASES_CACHE_TTL) {
return res.json(releasesCache.data.slice(0, perPage));
}
try {
const resp = await fetch(
`https://api.github.com/repos/mauriceboe/NOMAD/releases?per_page=${perPage}&page=${page}`,
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
if (!resp.ok) return res.json([]);
const data = await resp.json();
if (page === 1) releasesCache = { data, fetchedAt: Date.now() };
res.json(data);
} catch {
res.status(500).json({ error: 'Failed to fetch releases' });
}
});
export default router; export default router;

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);

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;

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;

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 };
} }

View File

@@ -78,7 +78,7 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
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;

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);

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 }[];

View File

@@ -60,6 +60,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;
@@ -145,6 +146,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;
@@ -156,11 +159,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;