- {/* Header card */}
+ {/* Support cards */}
+
+
+ {/* Loading / Error / Releases */}
+ {loading ? (
+
+ ) : error ? (
+
+
+
{t('admin.github.error')}
+
{error}
+
+
+ ) : (
@@ -258,6 +291,7 @@ export default function GitHubPanel() {
)}
+ )}
)
}
diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx
index db34077..171375f 100644
--- a/client/src/components/Budget/BudgetPanel.tsx
+++ b/client/src/components/Budget/BudgetPanel.tsx
@@ -7,6 +7,7 @@ import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check } from 'lucide-r
import CustomSelect from '../shared/CustomSelect'
import { budgetApi } from '../../api/client'
import type { BudgetItem, BudgetMember } from '../../types'
+import { currencyDecimals } from '../../utils/formatters'
interface TripMember {
id: number
@@ -34,7 +35,8 @@ const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5
const fmtNum = (v, locale, cur) => {
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)
@@ -543,7 +545,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
)}
- handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder="0,00" locale={locale} editTooltip={t('budget.editTooltip')} />
+ handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} />
|
{hasMultipleMembers ? (
@@ -620,7 +622,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
- {Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ {Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
{SYMBOLS[currency] || currency} {currency}
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx
index ea78eb1..a09dedb 100644
--- a/client/src/components/Collab/CollabNotes.tsx
+++ b/client/src/components/Collab/CollabNotes.tsx
@@ -1,7 +1,9 @@
import ReactDOM from 'react-dom'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
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 { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
@@ -412,7 +414,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
outline: 'none',
boxSizing: 'border-box',
resize: 'vertical',
- minHeight: 90,
+ minHeight: 180,
lineHeight: 1.5,
}}
/>
@@ -690,13 +692,14 @@ interface NoteCardProps {
onUpdate: (noteId: number, data: Partial) => Promise
onDelete: (noteId: number) => Promise
onEdit: (note: CollabNote) => void
+ onView: (note: CollabNote) => void
onPreviewFile: (file: NoteFile) => void
getCategoryColor: (category: string) => string
tripId: number
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 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
+ {note.content && (
+
+ )}
{/* ── New Note Modal ── */}
+ {/* View note modal */}
+ {viewingNote && ReactDOM.createPortal(
+ setViewingNote(null)}
+ >
+ e.stopPropagation()}
+ >
+
+
+ {viewingNote.title}
+ {viewingNote.category && (
+ {viewingNote.category}
+ )}
+
+
+
+
+
+
+
+ {viewingNote.content || ''}
+
+
+ ,
+ document.body
+ )}
+
{showNewModal && (
setShowNewModal(false)}
diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx
index a30f26c..c5fc0bd 100644
--- a/client/src/components/Files/FileManager.tsx
+++ b/client/src/components/Files/FileManager.tsx
@@ -1,15 +1,15 @@
import ReactDOM from 'react-dom'
-import { useState, useCallback } from 'react'
-import DOM from 'react-dom'
+import { useState, useCallback, useRef } from 'react'
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 { 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) {
if (!mimeType) return false
- return mimeType.startsWith('image/') // covers jpg, png, gif, webp, etc.
+ return mimeType.startsWith('image/')
}
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 {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
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(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 (
+ <>
+ 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
+ ? 
+ : name?.[0]?.toUpperCase()
+ }
+
+ {hover && ReactDOM.createPortal(
+
+ {name}
+ ,
+ document.body
+ )}
+ >
+ )
+}
+
interface FileManagerProps {
files?: TripFile[]
- onUpload: (fd: FormData) => Promise
+ onUpload: (fd: FormData) => Promise
onDelete: (fileId: number) => Promise
onUpdate: (fileId: number, data: Partial) => Promise
places: Place[]
+ days?: Day[]
+ assignments?: AssignmentsMap
reservations?: Reservation[]
tripId: number
allowedFileTypes: Record
}
-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 [filterType, setFilterType] = useState('all')
const [lightboxFile, setLightboxFile] = useState(null)
+ const [showTrash, setShowTrash] = useState(false)
+ const [trashFiles, setTrashFiles] = useState([])
+ const [loadingTrash, setLoadingTrash] = useState(false)
const toast = useToast()
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([])
+
const onDrop = useCallback(async (acceptedFiles) => {
if (acceptedFiles.length === 0) return
setUploading(true)
+ const uploadedIds: number[] = []
try {
for (const file of acceptedFiles) {
const formData = new FormData()
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 }))
+ // 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 {
toast.error(t('files.uploadError'))
} finally {
setUploading(false)
}
- }, [onUpload, toast, t])
+ }, [onUpload, toast, t, places, reservations])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
@@ -130,24 +246,24 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
noClick: false,
})
- // Paste support
const handlePaste = useCallback((e) => {
const items = e.clipboardData?.items
if (!items) return
- const files = []
+ const pastedFiles = []
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const file = item.getAsFile()
- if (file) files.push(file)
+ if (file) pastedFiles.push(file)
}
}
- if (files.length > 0) {
+ if (pastedFiles.length > 0) {
e.preventDefault()
- onDrop(files)
+ onDrop(pastedFiles)
}
}, [onDrop])
const filteredFiles = files.filter(f => {
+ if (filterType === 'starred') return !!f.starred
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
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')
@@ -156,16 +272,25 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
})
const handleDelete = async (id) => {
- if (!confirm(t('files.confirm.delete'))) return
try {
await onDelete(id)
- toast.success(t('files.toast.deleted'))
+ toast.success(t('files.toast.trashed') || 'Moved to trash')
} catch {
toast.error(t('files.toast.deleteError'))
}
}
const [previewFile, setPreviewFile] = useState(null)
+ const [assignFileId, setAssignFileId] = useState(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) => {
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 (
+ e.currentTarget.style.borderColor = 'var(--text-faint)'}
+ onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
+ className="group"
+ >
+ {/* Icon or thumbnail */}
+ !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)
+ ? 
+ : (() => {
+ const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
+ const isPdf = file.mime_type === 'application/pdf'
+ return (
+
+ {ext}
+
+ )
+ })()
+ }
+
+
+ {/* Info */}
+
+
+ {file.uploaded_by_name && (
+
+ )}
+ {!isTrash && file.starred ? : null}
+ !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}
+
+
+
+ {file.description && (
+ {file.description}
+ )}
+
+
+ {file.file_size && {formatSize(file.file_size)}}
+ {formatDateWithLocale(file.created_at, locale)}
+
+ {linkedPlace && (
+
+ )}
+ {linkedReservation && (
+
+ )}
+ {file.note_id && (
+
+ )}
+
+
+
+ {/* Actions — always visible on mobile, hover on desktop */}
+
+ {isTrash ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
+
+
+ )
+ }
+
return (
{/* Lightbox */}
{lightboxFile && setLightboxFile(null)} />}
- {/* Datei-Vorschau Modal — portal to body to escape stacking context */}
+ {/* Assign modal */}
+ {assignFileId && ReactDOM.createPortal(
+ setAssignFileId(null)}>
+ e.stopPropagation()}>
+
+
+ {t('files.assignTitle')}
+
+ {files.find(f => f.id === assignFileId)?.original_name || ''}
+
+
+
+
+
+
+ {t('files.noteLabel') || 'Note'}
+
+ 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',
+ }}
+ />
+
+
+ {(() => {
+ const file = files.find(f => f.id === assignFileId)
+ if (!file) return null
+ const assignedPlaceIds = new Set ()
+ 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) => (
+
+ )
+
+ const placesSection = places.length > 0 && (
+
+
+ {t('files.assignPlace')}
+
+ {dayGroups.map(({ day, dayPlaces }) => (
+
+
+ {day.title || `${t('dayplan.dayN', { n: day.day_number })}${day.date ? ` · ${day.date}` : ''}`}
+
+ {dayPlaces.map(placeBtn)}
+
+ ))}
+ {unassigned.length > 0 && (
+
+ {dayGroups.length > 0 && {t('files.unassigned')} }
+ {unassigned.map(placeBtn)}
+
+ )}
+
+ )
+
+ const bookingsSection = reservations.length > 0 && (
+
+
+ {t('files.assignBooking')}
+
+ {reservations.map(r => (
+
+ ))}
+
+ )
+
+ const hasBoth = placesSection && bookingsSection
+ return (
+
+ {placesSection}
+ {hasBoth && }
+ {hasBoth && }
+ {bookingsSection}
+
+ )
+ })()}
+
+
+ ,
+ document.body
+ )}
+
+ {/* PDF preview modal */}
{previewFile && ReactDOM.createPortal(
- {t('files.title')}
+ {showTrash ? (t('files.trash') || 'Trash') : t('files.title')}
- {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 }))}
+
- {/* Upload zone */}
-
-
-
- {uploading ? (
-
-
- {t('files.uploading')}
+ {showTrash ? (
+ /* Trash view */
+
+ {trashFiles.length > 0 && (
+
+
+
+ )}
+ {loadingTrash ? (
+
+ ) : trashFiles.length === 0 ? (
+
+
+ {t('files.trashEmpty') || 'Trash is empty'}
+
+ ) : (
+
+ {trashFiles.map(file => renderFileRow(file, true))}
+
+ )}
+
+ ) : (
+ <>
+ {/* Upload zone */}
+
+
+
+ {uploading ? (
+
+
+ {t('files.uploading')}
+
+ ) : (
+ <>
+ {t('files.dropzone')}
+ {t('files.dropzoneHint')}
+
+ {(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
+
+ >
+ )}
- ) : (
- <>
- {t('files.dropzone')}
- {t('files.dropzoneHint')}
-
- {(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
-
- >
- )}
-
- {/* Filter tabs */}
-
- {[
- { id: 'all', label: t('files.filterAll') },
- { id: 'pdf', label: t('files.filterPdf') },
- { id: 'image', label: t('files.filterImages') },
- { id: 'doc', label: t('files.filterDocs') },
- ...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
- ].map(tab => (
-
- ))}
-
- {filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
-
-
-
- {/* File list */}
-
- {filteredFiles.length === 0 ? (
-
-
- {t('files.empty')}
- {t('files.emptyHint')}
+ {/* Filter tabs */}
+
+ {[
+ { id: 'all', label: t('files.filterAll') },
+ ...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
+ { id: 'pdf', label: t('files.filterPdf') },
+ { id: 'image', label: t('files.filterImages') },
+ { id: 'doc', label: t('files.filterDocs') },
+ ...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
+ ].map(tab => (
+
+ ))}
+
+ {filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
+
- ) : (
-
- {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 (
- e.currentTarget.style.borderColor = 'var(--text-faint)'}
- onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
- className="group"
- >
- {/* Icon or thumbnail */}
- 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)
- ? 
- : (() => {
- const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
- const isPdf = file.mime_type === 'application/pdf'
- return (
-
- {ext}
-
- )
- })()
- }
-
-
- {/* Info */}
-
- openFile({ ...file, url: fileUrl })}
- style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}
- >
- {file.original_name}
-
-
-
- {file.file_size && {formatSize(file.file_size)}}
- {formatDateWithLocale(file.created_at, locale)}
-
- {linkedPlace && (
-
- )}
- {linkedReservation && (
-
- )}
- {file.note_id && (
-
- )}
-
-
- {file.description && !linkedReservation && (
- {file.description}
- )}
-
-
- {/* Actions */}
-
-
-
-
-
- )
- })}
+ {/* File list */}
+
+ {filteredFiles.length === 0 ? (
+
+
+ {t('files.empty')}
+ {t('files.emptyHint')}
+
+ ) : (
+
+ {filteredFiles.map(file => renderFileRow(file))}
+
+ )}
- )}
-
+ >
+ )}
)
diff --git a/client/src/components/Layout/DemoBanner.tsx b/client/src/components/Layout/DemoBanner.tsx
index fcb49a7..7a4386f 100644
--- a/client/src/components/Layout/DemoBanner.tsx
+++ b/client/src/components/Layout/DemoBanner.tsx
@@ -25,7 +25,7 @@ const texts: Record = {
de: {
titleBefore: 'Willkommen bei ',
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.',
resetIn: 'Naechster Reset in',
minutes: 'Minuten',
@@ -48,7 +48,7 @@ const texts: Record = {
['Dokumente', 'Dateien an Reisen anhaengen'],
['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.',
selfHost: 'Open Source — ',
selfHostLink: 'selbst hosten',
@@ -57,7 +57,7 @@ const texts: Record = {
en: {
titleBefore: 'Welcome to ',
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.',
resetIn: 'Next reset in',
minutes: 'minutes',
@@ -80,7 +80,7 @@ const texts: Record = {
['Documents', 'Attach files to trips'],
['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.',
selfHost: 'Open source — ',
selfHostLink: 'self-host it',
@@ -123,7 +123,7 @@ export default function DemoBanner(): React.ReactElement | null {
- {t.titleBefore} {t.titleAfter}
+ {t.titleBefore} {t.titleAfter}
@@ -151,7 +151,7 @@ export default function DemoBanner(): React.ReactElement | null {
- {/* What is NOMAD */}
+ {/* What is TREK */}
- {language === 'de' ? 'Was ist ' : 'What is '} ?
+ {language === 'de' ? 'Was ist ' : 'What is '} ?
{t.whatIsDesc}
@@ -213,7 +213,7 @@ export default function DemoBanner(): React.ReactElement | null {
{t.selfHost}
-
{t.selfHostLink}
diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx
index c07d159..52ce0cf 100644
--- a/client/src/components/Layout/Navbar.tsx
+++ b/client/src/components/Layout/Navbar.tsx
@@ -91,8 +91,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
)}
- 
- 
+ 
+ 
{/* Global addon nav items */}
@@ -231,7 +231,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{appVersion && (
- 
+
v{appVersion}
diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx
index 6743965..fa26706 100644
--- a/client/src/components/Map/MapView.tsx
+++ b/client/src/components/Map/MapView.tsx
@@ -182,6 +182,16 @@ function MapClickHandler({ onClick }: MapClickHandlerProps) {
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 ──
interface RouteLabelProps {
midpoint: [number, number]
@@ -234,6 +244,7 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
// Module-level photo cache shared with PlaceAvatar
const mapPhotoCache = new Map()
+const mapPhotoInFlight = new Set()
export function MapView({
places = [],
@@ -243,6 +254,7 @@ export function MapView({
selectedPlaceId = null,
onMarkerClick,
onMapClick,
+ onMapContextMenu = null,
center = [48.8566, 2.3522],
zoom = 10,
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
@@ -264,23 +276,32 @@ export function MapView({
}, [leftWidth, rightWidth, hasInspector])
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(() => {
places.forEach(place => {
- if (place.image_url || !place.google_place_id) return
- if (mapPhotoCache.has(place.google_place_id)) {
- const cached = mapPhotoCache.get(place.google_place_id)
- if (cached) setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: cached }))
+ if (place.image_url) return
+ const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
+ if (!cacheKey) return
+ if (mapPhotoCache.has(cacheKey)) {
+ const cached = mapPhotoCache.get(cacheKey)
+ if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached }))
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 => {
if (data.photoUrl) {
- mapPhotoCache.set(place.google_place_id, data.photoUrl)
- setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: data.photoUrl }))
+ mapPhotoCache.set(cacheKey, 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])
@@ -302,6 +323,7 @@ export function MapView({
0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} />
+
{places.map((place) => {
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 icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected)
diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx
index abaf03f..cf162f7 100644
--- a/client/src/components/PDF/TripPDF.tsx
+++ b/client/src/components/PDF/TripPDF.tsx
@@ -190,7 +190,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
@@ -199,7 +199,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
}).join('')
const html = `
-
+
diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx
index 7cb9d5a..b22376c 100644
--- a/client/src/components/Planner/DayDetailPanel.tsx
+++ b/client/src/components/Planner/DayDetailPanel.tsx
@@ -61,6 +61,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const [weather, setWeather] = useState(null)
const [loading, setLoading] = useState(false)
const [accommodation, setAccommodation] = useState(null)
+ const [dayAccommodations, setDayAccommodations] = useState ([])
const [accommodations, setAccommodations] = useState([])
const [showHotelPicker, setShowHotelPicker] = useState(false)
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)
.then(data => {
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)
)
- setAccommodation(acc || null)
+ setDayAccommodations(allForDay)
+ setAccommodation(allForDay[0] || null)
})
.catch(() => {})
}, [tripId, day?.id])
@@ -287,57 +289,101 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{t('day.accommodation')}
- {accommodation ? (
-
- {/* Hotel header */}
-
-
- {accommodation.place_image ? (
- 
- ) : (
-
- )}
-
-
- {accommodation.place_name}
- {accommodation.place_address && {accommodation.place_address} }
-
-
-
- {/* Details row */}
- {/* Details grid */}
-
- {accommodation.check_in && (
-
- {fmtTime(accommodation.check_in)}
-
- {t('day.checkIn')}
+ {dayAccommodations.length > 0 ? (
+
+ {dayAccommodations.map(acc => {
+ const isCheckInDay = acc.start_day_id === day.id
+ const isCheckOutDay = acc.end_day_id === day.id
+ const isMiddleDay = !isCheckInDay && !isCheckOutDay
+ const dayLabel = isCheckInDay && isCheckOutDay ? t('day.checkIn') + ' & ' + t('day.checkOut')
+ : isCheckInDay ? t('day.checkIn')
+ : isCheckOutDay ? t('day.checkOut')
+ : null
+ const linked = reservations.find(r => r.accommodation_id === acc.id)
+ const confirmed = linked?.status === 'confirmed'
+
+ return (
+
+ {/* Day label */}
+ {dayLabel && (
+
+ {isCheckInDay && }
+ {isCheckOutDay && !isCheckInDay && }
+ {dayLabel}
+
+ )}
+ {/* Hotel header */}
+
+
+ {acc.place_image ? (
+ 
+ ) : (
+
+ )}
+
+
+ {acc.place_name}
+ {acc.place_address && {acc.place_address} }
+
+
+
-
- )}
- {accommodation.check_out && (
-
- {fmtTime(accommodation.check_out)}
-
- {t('day.checkOut')}
+ {/* Details grid */}
+
+ {acc.check_in && (
+
+ {fmtTime(acc.check_in)}
+
+ {t('day.checkIn')}
+
+
+ )}
+ {acc.check_out && (
+
+ {fmtTime(acc.check_out)}
+
+ {t('day.checkOut')}
+
+
+ )}
+ {acc.confirmation && (
+
+ {acc.confirmation}
+
+ {t('day.confirmation')}
+
+
+ )}
+ {/* Linked booking */}
+ {linked && (
+
+
+
+ {linked.title}
+
+ {confirmed ? t('reservations.confirmed') : t('reservations.pending')}
+ {linked.confirmation_number && #{linked.confirmation_number}}
+
+
+
+ )}
- )}
- {accommodation.confirmation && (
-
- {accommodation.confirmation}
-
- {t('day.confirmation')}
-
-
- )}
-
-
+ )
+ })}
+ {/* Add another hotel */}
+
) : (
{(() => {
- const acc = accommodations.find(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
- return acc ? (
- { 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' }}>
-
- {acc.place_name}
-
- ) : null
+ const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
+ if (dayAccs.length === 0) return null
+ return dayAccs.map(acc => {
+ const isCheckIn = acc.start_day_id === day.id
+ const isCheckOut = acc.end_day_id === day.id
+ const bg = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.08)' : isCheckIn ? 'rgba(34,197,94,0.08)' : 'var(--bg-secondary)'
+ 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 (
+ { 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' }}>
+
+ {acc.place_name}
+
+ )
+ })
})()}
)}
@@ -735,6 +743,14 @@ export default function DayPlanSidebar({
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
)}
+ {(() => {
+ const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
+ if (!meta) return null
+ if (meta.airline && meta.flight_number) return {meta.airline} {meta.flight_number}
+ if (meta.flight_number) return {meta.flight_number}
+ if (meta.train_number) return {meta.train_number}
+ return null
+ })()}
)
})()}
@@ -979,7 +995,7 @@ export default function DayPlanSidebar({
{totalCost > 0 && (
{t('dayplan.totalCost')}
- {totalCost.toFixed(2)} {currency}
+ {totalCost.toFixed(currencyDecimals(currency))} {currency}
)}
diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx
index a40ce48..6f48961 100644
--- a/client/src/components/Planner/PlaceFormModal.tsx
+++ b/client/src/components/Planner/PlaceFormModal.tsx
@@ -42,6 +42,7 @@ interface PlaceFormModalProps {
onClose: () => void
onSave: (data: PlaceFormData, files?: File[]) => Promise | void
place: Place | null
+ prefillCoords?: { lat: number; lng: number; name?: string; address?: string } | null
tripId: number
categories: Category[]
onCategoryCreated: (category: Category) => void
@@ -50,7 +51,7 @@ interface PlaceFormModalProps {
}
export default function PlaceFormModal({
- isOpen, onClose, onSave, place, tripId, categories,
+ isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
onCategoryCreated, assignmentId, dayAssignments = [],
}: PlaceFormModalProps) {
const [form, setForm] = useState(DEFAULT_FORM)
@@ -81,11 +82,19 @@ export default function PlaceFormModal({
transport_mode: place.transport_mode || 'walking',
website: place.website || '',
})
+ } else if (prefillCoords) {
+ setForm({
+ ...DEFAULT_FORM,
+ lat: String(prefillCoords.lat),
+ lng: String(prefillCoords.lng),
+ name: prefillCoords.name || '',
+ address: prefillCoords.address || '',
+ })
} else {
setForm(DEFAULT_FORM)
}
setPendingFiles([])
- }, [place, isOpen])
+ }, [place, prefillCoords, isOpen])
const handleChange = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }))
@@ -112,6 +121,9 @@ export default function PlaceFormModal({
lat: result.lat || prev.lat,
lng: result.lng || prev.lng,
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([])
setMapsSearch('')
diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx
index 68dff2d..e1cf7ae 100644
--- a/client/src/components/Planner/PlaceInspector.tsx
+++ b/client/src/components/Planner/PlaceInspector.tsx
@@ -20,23 +20,21 @@ function setSessionCache(key, value) {
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
}
-function useGoogleDetails(googlePlaceId, language) {
+function usePlaceDetails(googlePlaceId, osmId, language) {
const [details, setDetails] = useState(null)
- const cacheKey = `gdetails_${googlePlaceId}_${language}`
+ const detailId = googlePlaceId || osmId
+ const cacheKey = `gdetails_${detailId}_${language}`
useEffect(() => {
- if (!googlePlaceId) { setDetails(null); return }
- // In-memory cache (fastest)
+ if (!detailId) { setDetails(null); return }
if (detailsCache.has(cacheKey)) { setDetails(detailsCache.get(cacheKey)); return }
- // sessionStorage cache (survives reload)
const cached = getSessionCache(cacheKey)
if (cached) { detailsCache.set(cacheKey, cached); setDetails(cached); return }
- // Fetch from API
- mapsApi.details(googlePlaceId, language).then(data => {
+ mapsApi.details(detailId, language).then(data => {
detailsCache.set(cacheKey, data.place)
setSessionCache(cacheKey, data.place)
setDetails(data.place)
}).catch(() => {})
- }, [googlePlaceId, language])
+ }, [detailId, language])
return details
}
@@ -138,7 +136,7 @@ export default function PlaceInspector({
const [nameValue, setNameValue] = useState('')
const nameInputRef = 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 = () => {
if (!onUpdatePlace) return
@@ -327,20 +325,20 @@ export default function PlaceInspector({
{/* Telefon */}
- {place.phone && (
+ {(place.phone || googleDetails?.phone) && (
)}
- {/* Description */}
- {(place.description || place.notes) && (
+ {/* Description / Summary */}
+ {(place.description || place.notes || googleDetails?.summary) && (
- {place.description || place.notes}
+ {place.description || place.notes || googleDetails?.summary}
)}
@@ -391,6 +389,20 @@ export default function PlaceInspector({
)}
{res.notes && {res.notes} }
+ {(() => {
+ 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 {parts.join(' · ')}
+ })()}
)
})()}
@@ -502,8 +514,12 @@ export default function PlaceInspector({
window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={}
label={{t('inspector.google')}} />
)}
- {place.website && (
- window.open(place.website, '_blank')} variant="ghost" icon={}
+ {!googleDetails?.google_maps_url && place.lat && place.lng && (
+ window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank')} variant="ghost" icon={}
+ label={Google Maps} />
+ )}
+ {(place.website || googleDetails?.website) && (
+ window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={}
label={{t('inspector.website')}} />
)}
diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx
index eb6d772..dafaac4 100644
--- a/client/src/components/Planner/ReservationModal.tsx
+++ b/client/src/components/Planner/ReservationModal.tsx
@@ -6,7 +6,7 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
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 = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
@@ -58,17 +58,22 @@ interface ReservationModalProps {
files?: TripFile[]
onFileUpload: (fd: FormData) => Promise
onFileDelete: (fileId: number) => Promise
+ 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 { t, locale } = useTranslation()
const fileInputRef = useRef(null)
const [form, setForm] = useState({
title: '', type: 'other', status: 'pending',
- reservation_time: '', location: '', confirmation_number: '',
- notes: '', assignment_id: '',
+ reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
+ 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 [uploadingFile, setUploadingFile] = useState(false)
@@ -81,6 +86,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
useEffect(() => {
if (reservation) {
+ const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
setForm({
title: reservation.title || '',
type: reservation.type || 'other',
@@ -91,12 +97,28 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '',
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 {
setForm({
title: '', type: 'other', status: 'pending',
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([])
}
@@ -109,10 +131,41 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (!form.title.trim()) return
setIsSaving(true)
try {
- const saved = await onSave({
- ...form,
+ const metadata: Record = {}
+ 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 = {
+ 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,
- })
+ 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) {
for (const file of pendingFiles) {
const fd = new FormData()
@@ -190,7 +243,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
- {/* Assignment Picker + Date */}
+ {/* Assignment Picker + Date (hidden for hotels) */}
+ {form.type !== 'hotel' && (
{assignmentOptions.length > 0 && (
@@ -231,24 +285,29 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
/>
+ )}
{/* Start Time + End Time + Status */}
-
-
- { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
- onChange={t => {
- const [d] = (form.reservation_time || '').split('T')
- const date = d || new Date().toISOString().split('T')[0]
- set('reservation_time', t ? `${date}T${t}` : date)
- }}
- />
-
-
-
- set('reservation_end_time', v)} />
-
+ {form.type !== 'hotel' && (
+ <>
+
+
+ { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
+ onChange={t => {
+ const [d] = (form.reservation_time || '').split('T')
+ const date = d || new Date().toISOString().split('T')[0]
+ set('reservation_time', t ? `${date}T${t}` : date)
+ }}
+ />
+
+
+
+ set('reservation_end_time', v)} />
+
+ >
+ )}
+ {/* Type-specific fields */}
+ {form.type === 'flight' && (
+
+ )}
+
+ {form.type === 'hotel' && (
+ <>
+ {/* Hotel place + day range */}
+
+
+
+ {
+ 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"
+ />
+
+
+
+ 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"
+ />
+
+
+
+ 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"
+ />
+
+
+ {/* Check-in/out times */}
+
+
+
+ set('meta_check_in_time', v)} />
+
+
+
+ set('meta_check_out_time', v)} />
+
+
+ >
+ )}
+
+ {form.type === 'train' && (
+
+ )}
+
{/* Notes */}
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx
index 86fae51..c8de359 100644
--- a/client/src/components/Planner/ReservationsPanel.tsx
+++ b/client/src/components/Planner/ReservationsPanel.tsx
@@ -112,7 +112,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{/* Details */}
- {(r.reservation_time || r.confirmation_number || r.location || linked) && (
+ {(r.reservation_time || r.confirmation_number || r.location || linked || r.metadata) && (
{/* Row 1: Date, Time, Code */}
{(r.reservation_time || r.confirmation_number) && (
@@ -139,8 +139,34 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
)}
)}
+ {/* 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 (
+
+ {cells.map((c, i) => (
+
+ {c.label}
+ {c.value}
+
+ ))}
+
+ )
+ })()}
{/* Row 2: Location + Assignment */}
- {(r.location || linked) && (
+ {(r.location || linked || r.accommodation_name) && (
{r.location && (
@@ -151,6 +177,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
)}
+ {r.accommodation_name && (
+
+ {t('reservations.meta.linkAccommodation')}
+
+
+ {r.accommodation_name}
+
+
+ )}
{linked && (
{t('reservations.linkAssignment')}
diff --git a/client/src/components/shared/PlaceAvatar.tsx b/client/src/components/shared/PlaceAvatar.tsx
index 57e18f2..c250160 100644
--- a/client/src/components/shared/PlaceAvatar.tsx
+++ b/client/src/components/shared/PlaceAvatar.tsx
@@ -9,34 +9,53 @@ interface Category {
}
interface PlaceAvatarProps {
- place: Pick
+ place: Pick
size?: number
category?: Category | null
}
-const googlePhotoCache = new Map()
+const photoCache = new Map()
+const photoInFlight = new Set()
export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
const [photoSrc, setPhotoSrc] = useState(place.image_url || null)
useEffect(() => {
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)) {
- setPhotoSrc(googlePhotoCache.get(place.google_place_id)!)
+ const cacheKey = photoId || `${place.lat},${place.lng}`
+ if (photoCache.has(cacheKey)) {
+ const cached = photoCache.get(cacheKey)
+ if (cached) setPhotoSrc(cached)
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 }) => {
if (data.photoUrl) {
- googlePhotoCache.set(place.google_place_id!, data.photoUrl)
+ photoCache.set(cacheKey, data.photoUrl)
setPhotoSrc(data.photoUrl)
+ } else {
+ photoCache.set(cacheKey, null)
}
+ photoInFlight.delete(cacheKey)
})
- .catch(() => {})
- }, [place.id, place.image_url, place.google_place_id])
+ .catch(() => { photoCache.set(cacheKey, null); photoInFlight.delete(cacheKey) })
+ }, [place.id, place.image_url, place.google_place_id, place.osm_id])
const bgColor = category?.color || '#6366f1'
const IconComp = getCategoryIcon(category?.icon)
diff --git a/client/src/components/shared/Toast.tsx b/client/src/components/shared/Toast.tsx
index 22d2c36..29ceb60 100644
--- a/client/src/components/shared/Toast.tsx
+++ b/client/src/components/shared/Toast.tsx
@@ -19,6 +19,13 @@ declare global {
let toastIdCounter = 0
+const ICON_COLORS: Record = {
+ success: '#22c55e',
+ error: '#ef4444',
+ warning: '#f59e0b',
+ info: '#6366f1',
+}
+
export function ToastContainer() {
const [toasts, setToasts] = useState([])
@@ -31,7 +38,7 @@ export function ToastContainer() {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
- }, 300)
+ }, 400)
}, duration)
}
@@ -42,7 +49,7 @@ export function ToastContainer() {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
- }, 300)
+ }, 400)
}, [])
useEffect(() => {
@@ -51,42 +58,83 @@ export function ToastContainer() {
}, [addToast])
const icons: Record = {
- success: ,
- error: ,
- warning: ,
- info: ,
- }
-
- const bgColors: Record = {
- 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',
+ success: ,
+ error: ,
+ warning: ,
+ info: ,
}
return (
-
- {toasts.map(toast => (
-
- {icons[toast.type] || icons.info}
- {toast.message}
-
+ ))}
+
+ >
)
}
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index 2fc0d5b..2bec291 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -191,7 +191,7 @@ const de: Record = {
'login.signingIn': 'Anmelden…',
'login.signIn': 'Anmelden',
'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.createAccountHint': 'Neues Konto registrieren.',
'login.creating': 'Erstelle…',
@@ -295,7 +295,7 @@ const de: Record = {
// Addons
'admin.tabs.addons': '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.subtitleAfter': ' nach deinen Wünschen anzupassen.',
'admin.addons.enabled': 'Aktiviert',
@@ -310,7 +310,7 @@ const de: Record = {
// Weather info
'admin.weather.title': 'Wetterdaten',
'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.forecastDesc': 'Statt bisher 5 Tage (OpenWeatherMap)',
'admin.weather.climate': 'Historische Klimadaten',
@@ -333,11 +333,11 @@ const de: Record = {
'admin.github.by': 'von',
'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.install': '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.warning': 'Die App ist während des Neustarts kurz nicht erreichbar.',
'admin.update.confirm': 'Jetzt aktualisieren',
@@ -347,7 +347,7 @@ const de: Record = {
'admin.update.backupHint': 'Wir empfehlen, vor dem Update ein Backup zu erstellen und herunterzuladen.',
'admin.update.backupLink': 'Zum Backup',
'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.',
// Vacay addon
@@ -393,9 +393,9 @@ const de: Record = {
'vacay.carryOver': 'Urlaubsmitnahme',
'vacay.carryOverHint': 'Resturlaub automatisch ins Folgejahr übertragen',
'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.shareEmailPlaceholder': 'E-Mail des NOMAD-Benutzers',
+ 'vacay.shareEmailPlaceholder': 'E-Mail des TREK-Benutzers',
'vacay.shareSuccess': 'Plan erfolgreich geteilt',
'vacay.shareError': 'Plan konnte nicht geteilt werden',
'vacay.dissolve': 'Fusion auflösen',
@@ -407,7 +407,7 @@ const de: Record = {
'vacay.noData': 'Keine Daten',
'vacay.changeColor': 'Farbe ändern',
'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.sendInvite': 'Einladung senden',
'vacay.inviteSent': 'Einladung gesendet',
@@ -586,6 +586,23 @@ const de: Record = {
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',
'reservations.notes': '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.hotel': 'Hotel',
'reservations.type.restaurant': 'Restaurant',
@@ -679,6 +696,28 @@ const de: Record = {
'files.sourceBooking': 'Buchung',
'files.attach': 'Anhängen',
'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.title': 'Packliste',
@@ -968,7 +1007,6 @@ const de: Record = {
'collab.chat.justNow': 'gerade eben',
'collab.chat.minutesAgo': 'vor {n} Min.',
'collab.chat.hoursAgo': 'vor {n} Std.',
- 'collab.chat.yesterday': 'gestern',
'collab.notes.title': 'Notizen',
'collab.notes.new': 'Neue Notiz',
'collab.notes.empty': 'Noch keine Notizen',
diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts
index 4e0a76e..73c42a2 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -191,7 +191,7 @@ const en: Record = {
'login.signingIn': 'Signing in…',
'login.signIn': 'Sign In',
'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.createAccountHint': 'Register a new account.',
'login.creating': 'Creating…',
@@ -295,7 +295,7 @@ const en: Record = {
// Addons
'admin.tabs.addons': '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.subtitleAfter': ' experience.',
'admin.addons.enabled': 'Enabled',
@@ -310,7 +310,7 @@ const en: Record = {
// Weather info
'admin.weather.title': 'Weather Data',
'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.forecastDesc': 'Previously 5 days (OpenWeatherMap)',
'admin.weather.climate': 'Historical climate data',
@@ -333,11 +333,11 @@ const en: Record = {
'admin.github.by': 'by',
'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.install': '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.warning': 'The app will be briefly unavailable during the restart.',
'admin.update.confirm': 'Update Now',
@@ -347,7 +347,7 @@ const en: Record = {
'admin.update.backupHint': 'We recommend creating a backup before updating.',
'admin.update.backupLink': 'Go to Backup',
'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.',
// Vacay addon
@@ -393,9 +393,9 @@ const en: Record = {
'vacay.carryOver': 'Carry Over',
'vacay.carryOverHint': 'Automatically carry remaining vacation days into the next year',
'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.shareEmailPlaceholder': 'Email of NOMAD user',
+ 'vacay.shareEmailPlaceholder': 'Email of TREK user',
'vacay.shareSuccess': 'Plan shared successfully',
'vacay.shareError': 'Could not share plan',
'vacay.dissolve': 'Dissolve Fusion',
@@ -407,7 +407,7 @@ const en: Record = {
'vacay.noData': 'No data',
'vacay.changeColor': 'Change color',
'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.sendInvite': 'Send Invite',
'vacay.inviteSent': 'Invite sent',
@@ -586,6 +586,23 @@ const en: Record = {
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
'reservations.notes': '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.hotel': 'Hotel',
'reservations.type.restaurant': 'Restaurant',
@@ -679,6 +696,28 @@ const en: Record = {
'files.sourceBooking': 'Booking',
'files.attach': 'Attach',
'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.title': 'Packing List',
@@ -968,7 +1007,6 @@ const en: Record = {
'collab.chat.justNow': 'just now',
'collab.chat.minutesAgo': '{n}m ago',
'collab.chat.hoursAgo': '{n}h ago',
- 'collab.chat.yesterday': 'yesterday',
'collab.notes.title': 'Notes',
'collab.notes.new': 'New Note',
'collab.notes.empty': 'No notes yet',
diff --git a/client/src/index.css b/client/src/index.css
index f45c88e..cbba8d1 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -337,7 +337,7 @@ body {
}
/* Brand images: no save/copy/drag */
-img[alt="NOMAD"] {
+img[alt="TREK"] {
pointer-events: none;
user-select: none;
-webkit-user-select: none;
@@ -460,3 +460,23 @@ img[alt="NOMAD"] {
align-items: 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; }
diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx
index 84e9d9c..5ed5423 100644
--- a/client/src/pages/LoginPage.tsx
+++ b/client/src/pages/LoginPage.tsx
@@ -186,7 +186,7 @@ export default function LoginPage(): React.ReactElement {
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
}}>
-
+
{t('login.tagline')}
@@ -384,7 +384,7 @@ export default function LoginPage(): React.ReactElement {
{/* Logo */}
- 
+
@@ -429,7 +429,7 @@ export default function LoginPage(): React.ReactElement {
- 
+
{t('login.tagline')}
diff --git a/client/src/pages/RegisterPage.tsx b/client/src/pages/RegisterPage.tsx
index 1146b64..762d1f1 100644
--- a/client/src/pages/RegisterPage.tsx
+++ b/client/src/pages/RegisterPage.tsx
@@ -75,7 +75,7 @@ export default function RegisterPage(): React.ReactElement {
- NOMAD
+ TREK
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index baedaa9..89b239b 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -33,7 +33,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate()
const toast = useToast()
- const { t } = useTranslation()
+ const { t, language } = useTranslation()
const { settings } = useSettingsStore()
const tripStore = useTripStore()
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 ([])
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])
useEffect(() => {
@@ -83,6 +86,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [showDayDetail, setShowDayDetail] = useState(null)
const [showPlaceForm, setShowPlaceForm] = useState(false)
const [editingPlace, setEditingPlace] = useState(null)
+ const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string } | null>(null)
const [editingAssignmentId, setEditingAssignmentId] = useState(null)
const [showTripForm, setShowTripForm] = useState(false)
const [showMembersModal, setShowMembersModal] = useState(false)
@@ -145,6 +149,22 @@ export default function TripPlannerPage(): React.ReactElement | 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 pendingFiles = 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)
toast.success(t('trip.toast.reservationUpdated'))
setShowReservationModal(false)
+ if (data.type === 'hotel') {
+ accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
+ }
return r
} else {
const r = await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationAdded'))
setShowReservationModal(false)
+ // Refresh accommodations if hotel was created
+ if (data.type === 'hotel') {
+ accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
+ }
return r
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}
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') }
}
@@ -345,6 +377,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
selectedPlaceId={selectedPlaceId}
onMarkerClick={handleMarkerClick}
onMapClick={handleMapClick}
+ onMapContextMenu={handleMapContextMenu}
center={defaultCenter}
zoom={defaultZoom}
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) } }}
reservations={reservations}
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}
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
@@ -605,8 +638,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
files={files || []}
onUpload={(fd) => tripStore.addFile(tripId, fd)}
onDelete={(id) => tripStore.deleteFile(tripId, id)}
- onUpdate={null}
+ onUpdate={(id, data) => tripStore.loadFiles(tripId)}
places={places}
+ days={days}
+ assignments={assignments}
reservations={reservations}
tripId={tripId}
allowedFileTypes={allowedFileTypes}
@@ -621,10 +656,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
- { 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)} />
+ { 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)} />
setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
- { 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)} />
+ { 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} />
setDeletePlaceId(null)}
diff --git a/client/src/types.ts b/client/src/types.ts
index c411637..761ae47 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -1,4 +1,4 @@
-// Shared types for the NOMAD travel planner
+// Shared types for the TREK travel planner
export interface User {
id: number
@@ -46,6 +46,7 @@ export interface Place {
price: string | null
image_url: string | null
google_place_id: string | null
+ osm_id: string | null
place_time: string | null
end_time: string | null
created_at: string
@@ -114,6 +115,7 @@ export interface Reservation {
id: number
trip_id: number
name: string
+ title?: string
type: string | null
status: 'pending' | 'confirmed'
date: string | null
@@ -121,17 +123,30 @@ export interface Reservation {
confirmation_number: string | null
notes: string | null
url: string | null
+ accommodation_id?: number | null
+ metadata?: Record | null
created_at: string
}
export interface TripFile {
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
original_name: string
+ file_size?: number | null
mime_type: string
- size: number
+ description?: string | null
+ starred?: number
+ deleted_at?: string | null
created_at: string
+ reservation_title?: string
+ url?: string
}
export interface Settings {
diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts
index e2e47c3..4fd3409 100644
--- a/client/src/utils/formatters.ts
+++ b/client/src/utils/formatters.ts
@@ -1,5 +1,11 @@
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 {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
diff --git a/client/vite.config.js b/client/vite.config.js
index cc6a319..a2d3632 100644
--- a/client/vite.config.js
+++ b/client/vite.config.js
@@ -66,9 +66,9 @@ export default defineConfig({
],
},
manifest: {
- name: 'NOMAD \u2014 Travel Planner',
- short_name: 'NOMAD',
- description: 'Navigation Organizer for Maps, Activities & Destinations',
+ name: 'TREK \u2014 Travel Planner',
+ short_name: 'TREK',
+ description: 'Travel Resource & Exploration Kit',
theme_color: '#111827',
background_color: '#0f172a',
display: 'standalone',
diff --git a/server/package-lock.json b/server/package-lock.json
index 6be8c5f..28bc934 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nomad-server",
- "version": "2.6.0",
+ "version": "2.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nomad-server",
- "version": "2.6.0",
+ "version": "2.6.1",
"dependencies": {
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
diff --git a/server/package.json b/server/package.json
index a8f78aa..39ae754 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
{
- "name": "nomad-server",
- "version": "2.6.1",
+ "name": "trek-server",
+ "version": "2.6.2",
"main": "src/index.ts",
"scripts": {
"start": "node --import tsx src/index.ts",
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index dfcdf9e..a56f08e 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -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 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) {
diff --git a/server/src/demo/demo-seed.ts b/server/src/demo/demo-seed.ts
index 1ef6734..57a747d 100644
--- a/server/src/demo/demo-seed.ts
+++ b/server/src/demo/demo-seed.ts
@@ -3,9 +3,9 @@ import Database from 'better-sqlite3';
function seedDemoData(db: Database.Database): { adminId: number; demoId: number } {
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 DEMO_EMAIL = 'demo@nomad.app';
+ const DEMO_EMAIL = 'demo@trek.app';
const DEMO_PASS = 'demo12345';
// Create admin user if not exists
diff --git a/server/src/index.ts b/server/src/index.ts
index 3e16ee5..7b1b09a 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -64,8 +64,8 @@ app.use(helmet({
},
crossOriginEmbedderPolicy: false,
}));
-// Redirect HTTP to HTTPS in production
-if (process.env.NODE_ENV === 'production' && process.env.FORCE_HTTPS !== 'false') {
+// Redirect HTTP to HTTPS (opt-in via FORCE_HTTPS=true)
+if (process.env.FORCE_HTTPS === 'true') {
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
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 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'}`);
if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED');
if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') {
diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts
index e4928e1..53e538b 100644
--- a/server/src/routes/admin.ts
+++ b/server/src/routes/admin.ts
@@ -171,7 +171,7 @@ router.get('/version-check', async (_req: Request, res: Response) => {
try {
const resp = await fetch(
'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 });
const data = await resp.json() as { tag_name?: string; html_url?: string };
diff --git a/server/src/routes/atlas.ts b/server/src/routes/atlas.ts
index c6e2f11..d5a7227 100644
--- a/server/src/routes/atlas.ts
+++ b/server/src/routes/atlas.ts
@@ -24,12 +24,18 @@ const COUNTRY_BOXES: Record = {
};
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)) {
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 = {
diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts
index e64eb8e..2996128 100644
--- a/server/src/routes/auth.ts
+++ b/server/src/routes/auth.ts
@@ -84,10 +84,10 @@ router.get('/app-config', (_req: Request, res: Response) => {
const isDemo = process.env.DEMO_MODE === 'true';
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 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 = !!(
- (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_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) &&
+ (process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value)
);
res.json({
allow_registration: isDemo ? false : allowRegistration,
@@ -98,7 +98,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
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',
demo_mode: isDemo,
- demo_email: isDemo ? 'demo@nomad.app' : undefined,
+ demo_email: isDemo ? 'demo@trek.app' : undefined,
demo_password: isDemo ? 'demo12345' : undefined,
});
});
@@ -107,7 +107,7 @@ router.post('/demo-login', (_req: Request, res: Response) => {
if (process.env.DEMO_MODE !== 'true') {
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' });
const token = generateToken(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) => {
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.' });
}
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) => {
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.' });
}
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;
diff --git a/server/src/routes/days.ts b/server/src/routes/days.ts
index b739b39..c59e1b7 100644
--- a/server/src/routes/days.ts
+++ b/server/src/routes/days.ts
@@ -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 (?, ?, ?, ?, ?, ?, ?, ?)'
).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 = {};
+ 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 });
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) => {
@@ -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 = ?'
).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));
res.json({ accommodation });
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);
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);
res.json({ success: true });
broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, req.headers['x-socket-id'] as string);
diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts
index aac21dc..378f074 100644
--- a/server/src/routes/files.ts
+++ b/server/src/routes/files.ts
@@ -57,6 +57,13 @@ function verifyTripOwnership(tripId: string | number, userId: number) {
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) {
return {
...file,
@@ -64,24 +71,23 @@ function formatFile(file: TripFile) {
};
}
+// List files (excludes soft-deleted by default)
router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
+ const showTrash = req.query.trash === 'true';
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const files = db.prepare(`
- SELECT f.*, r.title as reservation_title
- 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[];
+ const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL';
+ const files = db.prepare(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`).all(tripId) as TripFile[];
res.json({ files: files.map(formatFile) });
});
+// Upload file
router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single('file'), (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
const { tripId } = req.params;
const { place_id, description, reservation_id } = req.body;
@@ -90,8 +96,8 @@ router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single
}
const result = db.prepare(`
- INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description, uploaded_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId,
place_id || null,
@@ -100,19 +106,16 @@ router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single
req.file.originalname,
req.file.size,
req.file.mimetype,
- description || null
+ description || null,
+ authReq.user.id
);
- const file = db.prepare(`
- 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;
+ const file = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(result.lastInsertRowid) as TripFile;
res.status(201).json({ file: formatFile(file) });
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) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
@@ -126,7 +129,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
db.prepare(`
UPDATE trip_files SET
- description = COALESCE(?, description),
+ description = ?,
place_id = ?,
reservation_id = ?
WHERE id = ?
@@ -137,16 +140,31 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
id
);
- const updated = db.prepare(`
- 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;
+ 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);
});
+// 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) => {
const authReq = req as AuthRequest;
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;
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);
if (fs.existsSync(filePath)) {
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);
});
+// 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;
diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts
index 0cd18e9..b71e80d 100644
--- a/server/src/routes/maps.ts
+++ b/server/src/routes/maps.ts
@@ -13,6 +13,166 @@ interface NominatimResult {
lon: string;
}
+interface OverpassElement {
+ tags?: Record;
+}
+
+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 {
+ const typeMap: Record = { 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();
+ 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, 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 } };
+ 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 } };
+ 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 {
id: string;
displayName?: { text: string };
@@ -69,13 +229,13 @@ async function searchNominatim(query: string, lang?: string) {
'accept-language': lang || 'en',
});
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');
const data = await response.json() as NominatimResult[];
return data.map(item => ({
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] || '',
address: item.display_name || '',
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 { 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);
if (!apiKey) {
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,
photo: r.authorAttribution?.photoUri || null,
})),
+ source: 'google' as const,
};
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 });
}
+ // 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);
- if (!apiKey) {
- return res.status(400).json({ error: 'Google Maps API key not configured' });
+ const isCoordLookup = placeId.startsWith('coords:');
+
+ // 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 {
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
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 };
+ 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;
diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts
index 405ea8a..404f017 100644
--- a/server/src/routes/oidc.ts
+++ b/server/src/routes/oidc.ts
@@ -52,10 +52,10 @@ setInterval(() => {
function getOidcConfig() {
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 clientId = get('oidc_client_id');
- const clientSecret = get('oidc_client_secret');
- const displayName = get('oidc_display_name') || 'SSO';
+ const issuer = process.env.OIDC_ISSUER || get('oidc_issuer');
+ const clientId = process.env.OIDC_CLIENT_ID || get('oidc_client_id');
+ const clientSecret = process.env.OIDC_CLIENT_SECRET || get('oidc_client_secret');
+ const displayName = process.env.OIDC_DISPLAY_NAME || get('oidc_display_name') || 'SSO';
if (!issuer || !clientId || !clientSecret) return null;
return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName };
}
diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts
index 57c9169..9acddb7 100644
--- a/server/src/routes/places.ts
+++ b/server/src/routes/places.ts
@@ -78,7 +78,7 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
const {
name, description, lat, lng, address, category_id, price, currency,
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 = []
} = req.body;
@@ -89,13 +89,13 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
const result = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
- duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, transport_mode)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId, name, description || null, lat || null, lng || null, address || null,
category_id || null, price || null, currency || 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;
diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts
index 7cbbc66..302c1a1 100644
--- a/server/src/routes/reservations.ts
+++ b/server/src/routes/reservations.ts
@@ -18,10 +18,13 @@ router.get('/', authenticate, (req: Request, res: Response) => {
if (!trip) return res.status(404).json({ error: 'Trip not found' });
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
LEFT JOIN days d ON r.day_id = d.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 = ?
ORDER BY r.reservation_time ASC, r.created_at ASC
`).all(tripId);
@@ -32,16 +35,29 @@ router.get('/', authenticate, (req: Request, res: Response) => {
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
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);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
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(`
- INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId,
day_id || null,
@@ -54,14 +70,32 @@ router.post('/', authenticate, (req: Request, res: Response) => {
confirmation_number || null,
notes || null,
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(`
- 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
LEFT JOIN days d ON r.day_id = d.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 = ?
`).get(result.lastInsertRowid);
@@ -72,7 +106,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
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);
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;
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(`
UPDATE reservations SET
title = COALESCE(?, title),
@@ -92,7 +144,9 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
place_id = ?,
assignment_id = ?,
status = COALESCE(?, status),
- type = COALESCE(?, type)
+ type = COALESCE(?, type),
+ accommodation_id = ?,
+ metadata = ?
WHERE id = ?
`).run(
title || null,
@@ -106,14 +160,34 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
assignment_id !== undefined ? (assignment_id || null) : reservation.assignment_id,
status || null,
type || null,
+ resolvedAccId,
+ metadata !== undefined ? (metadata ? JSON.stringify(metadata) : null) : reservation.metadata,
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(`
- 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
LEFT JOIN days d ON r.day_id = d.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 = ?
`).get(id);
@@ -128,9 +202,15 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
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' });
+ // 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);
res.json({ success: true });
broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string);
diff --git a/server/src/routes/vacay.ts b/server/src/routes/vacay.ts
index fcf9e94..02b13d9 100644
--- a/server/src/routes/vacay.ts
+++ b/server/src/routes/vacay.ts
@@ -69,6 +69,7 @@ function getOwnPlan(userId: number) {
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_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;
}
@@ -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 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;
- if (myColor && existingColors.includes(myColor.color)) {
+ const effectiveColor = myColor?.color || '#6366f1';
+ if (existingColors.includes(effectiveColor)) {
const available = COLORS.find(c => !existingColors.includes(c));
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 }[];
diff --git a/server/src/types.ts b/server/src/types.ts
index cce6dbd..b9f2142 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -60,6 +60,7 @@ export interface Place {
notes?: string | null;
image_url?: string | null;
google_place_id?: string | null;
+ osm_id?: string | null;
website?: string | null;
phone?: string | null;
transport_mode?: string;
@@ -145,6 +146,8 @@ export interface Reservation {
notes?: string | null;
status: string;
type: string;
+ accommodation_id?: number | null;
+ metadata?: string | null;
created_at?: string;
day_number?: number;
place_name?: string;
@@ -156,11 +159,15 @@ export interface TripFile {
place_id?: number | null;
reservation_id?: number | null;
note_id?: number | null;
+ uploaded_by?: number | null;
+ uploaded_by_name?: string | null;
filename: string;
original_name: string;
file_size?: number | null;
mime_type?: string | null;
description?: string | null;
+ starred?: number;
+ deleted_at?: string | null;
created_at?: string;
reservation_title?: string;
url?: string;
|