mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df695ee8d8 | |||
| d845057f84 | |||
| e70fe50ae3 | |||
| 2000371844 | |||
| d45d9c2cfa | |||
| d24f0b3ccd | |||
| c1fb745627 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nomad-client",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -29,10 +29,8 @@ function handleMessage(event) {
|
||||
// Store our socket ID from welcome message
|
||||
if (parsed.type === 'welcome') {
|
||||
mySocketId = parsed.socketId
|
||||
console.log('[WS] Got socketId:', mySocketId)
|
||||
return
|
||||
}
|
||||
console.log('[WS] Received:', parsed.type, parsed)
|
||||
listeners.forEach(fn => {
|
||||
try { fn(parsed) } catch (err) { console.error('WebSocket listener error:', err) }
|
||||
})
|
||||
@@ -61,14 +59,14 @@ function connectInternal(token, isReconnect = false) {
|
||||
socket = new WebSocket(url)
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log('[WS] Connected', isReconnect ? '(reconnect)' : '(initial)')
|
||||
// connection established
|
||||
reconnectDelay = 1000
|
||||
// Join active trips on any connect (initial or reconnect)
|
||||
if (activeTrips.size > 0) {
|
||||
activeTrips.forEach(tripId => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'join', tripId }))
|
||||
console.log('[WS] Joined trip', tripId)
|
||||
// joined trip room
|
||||
}
|
||||
})
|
||||
// Refetch trip data for active trips
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { backupApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive } from 'lucide-react'
|
||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
@@ -29,9 +29,10 @@ export default function BackupPanel() {
|
||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
|
||||
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
||||
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
||||
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
||||
const fileInputRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const { t, language, locale } = useTranslation()
|
||||
|
||||
const loadBackups = async () => {
|
||||
setIsLoading(true)
|
||||
@@ -67,32 +68,42 @@ export default function BackupPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async (filename) => {
|
||||
if (!confirm(t('backup.confirm.restore', { name: filename }))) return
|
||||
setRestoringFile(filename)
|
||||
try {
|
||||
await backupApi.restore(filename)
|
||||
toast.success(t('backup.toast.restored'))
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('backup.toast.restoreError'))
|
||||
setRestoringFile(null)
|
||||
}
|
||||
const handleRestore = (filename) => {
|
||||
setRestoreConfirm({ type: 'file', filename })
|
||||
}
|
||||
|
||||
const handleUploadRestore = async (e) => {
|
||||
const handleUploadRestore = (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
if (!confirm(t('backup.confirm.uploadRestore', { name: file.name }))) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
await backupApi.uploadRestore(file)
|
||||
toast.success(t('backup.toast.restored'))
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('backup.toast.uploadError'))
|
||||
setIsUploading(false)
|
||||
setRestoreConfirm({ type: 'upload', filename: file.name, file })
|
||||
}
|
||||
|
||||
const executeRestore = async () => {
|
||||
if (!restoreConfirm) return
|
||||
const { type, filename, file } = restoreConfirm
|
||||
setRestoreConfirm(null)
|
||||
|
||||
if (type === 'file') {
|
||||
setRestoringFile(filename)
|
||||
try {
|
||||
await backupApi.restore(filename)
|
||||
toast.success(t('backup.toast.restored'))
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('backup.toast.restoreError'))
|
||||
setRestoringFile(null)
|
||||
}
|
||||
} else {
|
||||
setIsUploading(true)
|
||||
try {
|
||||
await backupApi.uploadRestore(file)
|
||||
toast.success(t('backup.toast.restored'))
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('backup.toast.uploadError'))
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +368,71 @@ export default function BackupPanel() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Restore Warning Modal */}
|
||||
{restoreConfirm && (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{/* Red header */}
|
||||
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<AlertTriangle size={20} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
|
||||
{language === 'de' ? 'Backup wiederherstellen?' : 'Restore Backup?'}
|
||||
</h3>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
||||
{restoreConfirm.filename}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||
{language === 'de'
|
||||
? 'Alle aktuellen Daten (Reisen, Orte, Benutzer, Uploads) werden unwiderruflich durch das Backup ersetzt. Dieser Vorgang kann nicht rückgängig gemacht werden.'
|
||||
: 'All current data (trips, places, users, uploads) will be permanently replaced by the backup. This action cannot be undone.'}
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
|
||||
>
|
||||
{language === 'de'
|
||||
? 'Tipp: Erstelle zuerst ein Backup des aktuellen Stands, bevor du wiederherstellst.'
|
||||
: 'Tip: Create a backup of the current state before restoring.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{language === 'de' ? 'Abbrechen' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={executeRestore}
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: '#dc2626', color: 'white' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
|
||||
>
|
||||
{language === 'de' ? 'Ja, wiederherstellen' : 'Yes, restore'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Info, Github, Shield, Key, Users, Database, Upload, Clock } from 'lucide-react'
|
||||
import { Info, Github, Shield, Key, Users, Database, Upload, Clock, Puzzle, CalendarDays, Globe, ArrowRightLeft, Map, Briefcase, ListChecks, Wallet, FileText, Plane } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
const texts = {
|
||||
@@ -9,14 +9,27 @@ const texts = {
|
||||
resetIn: 'Naechster Reset in',
|
||||
minutes: 'Minuten',
|
||||
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
|
||||
fullVersionTitle: 'In der Vollversion zusaetzlich verfuegbar:',
|
||||
fullVersionTitle: 'In der Vollversion zusaetzlich:',
|
||||
features: [
|
||||
'Datei-Uploads (Fotos, Dokumente, Reise-Cover)',
|
||||
'API-Schluessel verwalten (Google Maps, Wetter)',
|
||||
'Benutzer & Rechte verwalten',
|
||||
'Automatische Backups & Wiederherstellung',
|
||||
'Datei-Uploads (Fotos, Dokumente, Cover)',
|
||||
'API-Schluessel (Google Maps, Wetter)',
|
||||
'Benutzer- & Rechteverwaltung',
|
||||
'Automatische Backups',
|
||||
'Addon-Verwaltung (aktivieren/deaktivieren)',
|
||||
'OIDC / SSO Single Sign-On',
|
||||
],
|
||||
selfHost: 'NOMAD ist Open Source — ',
|
||||
addonsTitle: 'Modulare Addons (in der Vollversion deaktivierbar)',
|
||||
addons: [
|
||||
['Vacay', 'Urlaubsplaner mit Kalender, Feiertagen & Fusion'],
|
||||
['Atlas', 'Weltkarte mit besuchten Laendern & Reisestatistiken'],
|
||||
['Packliste', 'Checklisten pro Reise'],
|
||||
['Budget', 'Kostenplanung mit Splitting'],
|
||||
['Dokumente', 'Dateien an Reisen anhaengen'],
|
||||
['Widgets', 'Waehrungsrechner & Zeitzonen'],
|
||||
],
|
||||
whatIs: 'Was ist NOMAD?',
|
||||
whatIsDesc: 'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
|
||||
selfHost: 'Open Source — ',
|
||||
selfHostLink: 'selbst hosten',
|
||||
close: 'Verstanden',
|
||||
},
|
||||
@@ -26,20 +39,34 @@ const texts = {
|
||||
resetIn: 'Next reset in',
|
||||
minutes: 'minutes',
|
||||
uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.',
|
||||
fullVersionTitle: 'Additionally available in the full version:',
|
||||
fullVersionTitle: 'Additionally in the full version:',
|
||||
features: [
|
||||
'File uploads (photos, documents, trip covers)',
|
||||
'File uploads (photos, documents, covers)',
|
||||
'API key management (Google Maps, Weather)',
|
||||
'User & permission management',
|
||||
'Automatic backups & restore',
|
||||
'Automatic backups',
|
||||
'Addon management (enable/disable)',
|
||||
'OIDC / SSO single sign-on',
|
||||
],
|
||||
selfHost: 'NOMAD is open source — ',
|
||||
addonsTitle: 'Modular Addons (can be deactivated in full version)',
|
||||
addons: [
|
||||
['Vacay', 'Vacation planner with calendar, holidays & user fusion'],
|
||||
['Atlas', 'World map with visited countries & travel stats'],
|
||||
['Packing', 'Checklists per trip'],
|
||||
['Budget', 'Expense tracking with splitting'],
|
||||
['Documents', 'Attach files to trips'],
|
||||
['Widgets', 'Currency converter & timezones'],
|
||||
],
|
||||
whatIs: 'What is NOMAD?',
|
||||
whatIsDesc: 'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
|
||||
selfHost: 'Open source — ',
|
||||
selfHostLink: 'self-host it',
|
||||
close: 'Got it',
|
||||
},
|
||||
}
|
||||
|
||||
const featureIcons = [Upload, Key, Users, Database]
|
||||
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
|
||||
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft]
|
||||
|
||||
export default function DemoBanner() {
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
@@ -57,85 +84,124 @@ export default function DemoBanner() {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
padding: 16, overflow: 'auto',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
}} onClick={() => setDismissed(true)}>
|
||||
<div style={{
|
||||
background: 'white', borderRadius: 20, padding: '32px 28px 24px',
|
||||
maxWidth: 440, width: '100%',
|
||||
background: 'white', borderRadius: 20, padding: '28px 24px 20px',
|
||||
maxWidth: 480, width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
maxHeight: '90vh', overflow: 'auto',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10,
|
||||
background: 'linear-gradient(135deg, #f59e0b, #d97706)',
|
||||
background: '#111827',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Info size={20} style={{ color: 'white' }} />
|
||||
<Plane size={18} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827' }}>
|
||||
{t.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 14, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
|
||||
<p style={{ fontSize: 13, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
|
||||
{t.description}
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 12px',
|
||||
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '10px 12px',
|
||||
}}>
|
||||
<Clock size={15} style={{ flexShrink: 0, color: '#0284c7' }} />
|
||||
<span style={{ fontSize: 13, color: '#0369a1', fontWeight: 600 }}>
|
||||
{t.resetIn} {minutesLeft} {t.minutes}
|
||||
</span>
|
||||
{/* Timer + Upload note */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '8px 10px',
|
||||
}}>
|
||||
<Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
|
||||
<span style={{ fontSize: 11, color: '#0369a1', fontWeight: 600 }}>
|
||||
{t.resetIn} {minutesLeft} {t.minutes}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '8px 10px',
|
||||
}}>
|
||||
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
|
||||
<span style={{ fontSize: 11, color: '#b45309' }}>{t.uploadNote}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{
|
||||
fontSize: 13, color: '#b45309', lineHeight: 1.5, margin: '0 0 20px',
|
||||
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '10px 12px',
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
{/* What is NOMAD */}
|
||||
<div style={{
|
||||
background: '#f8fafc', borderRadius: 12, padding: '12px 14px', marginBottom: 16,
|
||||
border: '1px solid #e2e8f0',
|
||||
}}>
|
||||
<Upload size={15} style={{ flexShrink: 0 }} />
|
||||
{t.uploadNote}
|
||||
</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<Map size={14} style={{ color: '#111827' }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#111827' }}>{t.whatIs}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 12, fontWeight: 700, color: '#374151', margin: '0 0 10px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{/* Addons */}
|
||||
<p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Puzzle size={12} />
|
||||
{t.addonsTitle}
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||||
{t.addons.map(([name, desc], i) => {
|
||||
const Icon = addonIcons[i]
|
||||
return (
|
||||
<div key={name} style={{
|
||||
background: '#f8fafc', borderRadius: 10, padding: '8px 10px',
|
||||
border: '1px solid #f1f5f9',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||
<Icon size={12} style={{ flexShrink: 0, color: '#111827' }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#111827' }}>{name}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 10, color: '#94a3b8', margin: 0, lineHeight: 1.3, paddingLeft: 18 }}>{desc}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Full version features */}
|
||||
<p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Shield size={12} />
|
||||
{t.fullVersionTitle}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||||
{t.features.map((text, i) => {
|
||||
const Icon = featureIcons[i]
|
||||
return (
|
||||
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, color: '#4b5563' }}>
|
||||
<Icon size={15} style={{ flexShrink: 0, color: '#d97706' }} />
|
||||
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: '#4b5563', padding: '4px 0' }}>
|
||||
<Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
paddingTop: 16, borderTop: '1px solid #e5e7eb',
|
||||
paddingTop: 14, borderTop: '1px solid #e5e7eb',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#9ca3af' }}>
|
||||
<Github size={14} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
|
||||
<Github size={13} />
|
||||
<span>{t.selfHost}</span>
|
||||
<a href="https://github.com/mauriceboe/NOMAD" target="_blank" rel="noopener noreferrer"
|
||||
style={{ color: '#d97706', fontWeight: 600, textDecoration: 'none' }}>
|
||||
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}>
|
||||
{t.selfHostLink}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button onClick={() => setDismissed(true)} style={{
|
||||
background: '#111827', color: 'white', border: 'none',
|
||||
borderRadius: 10, padding: '8px 20px', fontSize: 13,
|
||||
borderRadius: 10, padding: '8px 20px', fontSize: 12,
|
||||
fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
{t.close}
|
||||
|
||||
@@ -19,6 +19,11 @@ L.Icon.Default.mergeOptions({
|
||||
* Create a round photo-circle marker.
|
||||
* Shows image_url if available, otherwise category icon in colored circle.
|
||||
*/
|
||||
function escAttr(s) {
|
||||
if (!s) return ''
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function createPlaceIcon(place, orderNumber, isSelected) {
|
||||
const size = isSelected ? 44 : 36
|
||||
const borderColor = isSelected ? '#111827' : 'white'
|
||||
@@ -55,7 +60,7 @@ function createPlaceIcon(place, orderNumber, isSelected) {
|
||||
cursor:pointer;flex-shrink:0;position:relative;
|
||||
">
|
||||
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
||||
<img src="${place.image_url}" style="width:100%;height:100%;object-fit:cover;" />
|
||||
<img src="${escAttr(place.image_url)}" style="width:100%;height:100%;object-fit:cover;" />
|
||||
</div>
|
||||
${badgeHtml}
|
||||
</div>`,
|
||||
|
||||
@@ -421,23 +421,11 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{/* Next trip */}
|
||||
{nextTrip && (
|
||||
<button onClick={() => onTripClick(nextTrip.id)} className="flex items-center gap-2.5 text-left transition-opacity hover:opacity-75">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'rgba(129,140,248,0.12)' }}>
|
||||
<Calendar size={16} style={{ color: accent }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: tf }}>{t('atlas.nextTrip')}</p>
|
||||
<p className="text-[13px] font-black" style={{ color: accent }}>{nextTrip.daysUntil} {t('atlas.daysLeft')}</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{/* Streak */}
|
||||
{streak > 0 && (
|
||||
<div className="flex flex-col items-center justify-center px-3">
|
||||
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{streak}</span>
|
||||
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight" style={{ color: tf }}>
|
||||
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight whitespace-nowrap" style={{ color: tf }}>
|
||||
{streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -446,7 +434,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
{tripsThisYear > 0 && (
|
||||
<div className="flex flex-col items-center justify-center px-3">
|
||||
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{tripsThisYear}</span>
|
||||
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight" style={{ color: tf }}>
|
||||
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight whitespace-nowrap" style={{ color: tf }}>
|
||||
{tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -29,9 +29,11 @@ export default function LoginPage() {
|
||||
}
|
||||
})
|
||||
|
||||
// Handle OIDC callback token
|
||||
// Handle OIDC callback token (via URL fragment to avoid logging)
|
||||
const hash = window.location.hash.substring(1)
|
||||
const hashParams = new URLSearchParams(hash)
|
||||
const token = hashParams.get('token')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const token = params.get('token')
|
||||
const oidcError = params.get('oidc_error')
|
||||
if (token) {
|
||||
localStorage.setItem('auth_token', token)
|
||||
@@ -404,7 +406,7 @@ export default function LoginPage() {
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }}
|
||||
>
|
||||
<Plane size={18} />
|
||||
Demo ausprobieren — ohne Registrierung
|
||||
{language === 'de' ? 'Demo ausprobieren — ohne Registrierung' : 'Try the demo — no registration needed'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ services:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-to-a-long-random-string}
|
||||
- JWT_SECRET=${JWT_SECRET:-}
|
||||
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
|
||||
- PORT=3000
|
||||
volumes:
|
||||
|
||||
Generated
+12
-2
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "nomad-server",
|
||||
"version": "2.4.1",
|
||||
"version": "2.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nomad-server",
|
||||
"version": "2.4.1",
|
||||
"version": "2.5.1",
|
||||
"dependencies": {
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.1",
|
||||
"express": "^4.18.3",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^4.2.1",
|
||||
@@ -995,6 +996,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nomad-server",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.1",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node --experimental-sqlite src/index.js",
|
||||
@@ -12,6 +12,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.1",
|
||||
"express": "^4.18.3",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^4.2.1",
|
||||
|
||||
@@ -421,6 +421,7 @@ if (process.env.DEMO_MODE === 'true') {
|
||||
// without needing a server restart after reinitialize()
|
||||
const db = new Proxy({}, {
|
||||
get(_, prop) {
|
||||
if (!_db) throw new Error('Database connection is not available (restore in progress?)');
|
||||
const val = _db[prop];
|
||||
return typeof val === 'function' ? val.bind(_db) : val;
|
||||
},
|
||||
|
||||
+6
-10
@@ -1,6 +1,7 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -42,16 +43,11 @@ app.use(cors({
|
||||
origin: corsOrigin,
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
|
||||
// Security headers
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
next();
|
||||
});
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false, // managed by frontend meta tag or reverse proxy
|
||||
crossOriginEmbedderPolicy: false, // allows loading external images (maps, etc.)
|
||||
}));
|
||||
app.use(express.json({ limit: '100kb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Serve uploaded files
|
||||
|
||||
+26
-14
@@ -138,25 +138,37 @@ async function restoreFromZip(zipPath, res) {
|
||||
// Step 1: close DB connection BEFORE touching the file (required on Windows)
|
||||
closeDb();
|
||||
|
||||
// Step 2: remove WAL/SHM and overwrite DB file
|
||||
const dbDest = path.join(dataDir, 'travel.db');
|
||||
for (const ext of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbDest + ext); } catch (e) {}
|
||||
}
|
||||
fs.copyFileSync(extractedDb, dbDest);
|
||||
try {
|
||||
// Step 2: remove WAL/SHM and overwrite DB file
|
||||
const dbDest = path.join(dataDir, 'travel.db');
|
||||
for (const ext of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbDest + ext); } catch (e) {}
|
||||
}
|
||||
fs.copyFileSync(extractedDb, dbDest);
|
||||
|
||||
// Step 3: restore uploads
|
||||
const extractedUploads = path.join(extractDir, 'uploads');
|
||||
if (fs.existsSync(extractedUploads)) {
|
||||
if (fs.existsSync(uploadsDir)) fs.rmSync(uploadsDir, { recursive: true, force: true });
|
||||
fs.cpSync(extractedUploads, uploadsDir, { recursive: true });
|
||||
// Step 3: restore uploads — overwrite in-place instead of rmSync
|
||||
// (rmSync fails with EBUSY because express.static holds the directory)
|
||||
const extractedUploads = path.join(extractDir, 'uploads');
|
||||
if (fs.existsSync(extractedUploads)) {
|
||||
// Clear contents of each subdirectory without removing the root uploads dir
|
||||
for (const sub of fs.readdirSync(uploadsDir)) {
|
||||
const subPath = path.join(uploadsDir, sub);
|
||||
if (fs.statSync(subPath).isDirectory()) {
|
||||
for (const file of fs.readdirSync(subPath)) {
|
||||
try { fs.unlinkSync(path.join(subPath, file)); } catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Copy restored files over
|
||||
fs.cpSync(extractedUploads, uploadsDir, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
// Step 4: ALWAYS reopen DB — even if file copy failed, so the server stays functional
|
||||
reinitialize();
|
||||
}
|
||||
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
|
||||
// Step 4: reopen DB with restored data
|
||||
reinitialize();
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Restore error:', err);
|
||||
|
||||
@@ -35,6 +35,11 @@ const upload = multer({
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const blockedExts = ['.svg', '.html', '.htm', '.xml'];
|
||||
if (blockedExts.includes(ext) || file.mimetype.includes('svg')) {
|
||||
return cb(new Error('File type not allowed'));
|
||||
}
|
||||
if (allowed.includes(file.mimetype) || file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
|
||||
@@ -196,7 +196,7 @@ router.get('/callback', async (req, res) => {
|
||||
// Generate JWT and redirect to frontend
|
||||
const token = generateToken(user);
|
||||
// In dev mode, frontend runs on a different port
|
||||
res.redirect(frontendUrl(`/login?token=${token}`));
|
||||
res.redirect(frontendUrl(`/login#token=${token}`));
|
||||
} catch (err) {
|
||||
console.error('[OIDC] Callback error:', err);
|
||||
res.redirect(frontendUrl('/login?oidc_error=server_error'));
|
||||
|
||||
@@ -25,10 +25,12 @@ const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Nur Bilddateien sind erlaubt'));
|
||||
cb(new Error('Only jpg, png, gif, webp images allowed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,8 +24,13 @@ const uploadCover = multer({
|
||||
storage: coverStorage,
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) cb(null, true);
|
||||
else cb(new Error('Nur Bilder erlaubt'));
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only jpg, png, gif, webp images allowed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user