diff --git a/README.md b/README.md index d725ece..5380b3f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,21 @@ -# NOMAD +

+ NOMAD +
+ Navigation Organizer for Maps, Activities & Destinations +

-**Navigation Organizer for Maps, Activities & Destinations** +

+ License: AGPL v3 + Docker Pulls + GitHub Stars + Last Commit +

-A self-hosted, real-time collaborative travel planner for organizing trips with interactive maps, budgets, packing lists, and more. - -[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE) -[![Docker Pulls](https://img.shields.io/docker/pulls/mauriceboe/nomad)](https://hub.docker.com/r/mauriceboe/nomad) -[![GitHub Stars](https://img.shields.io/github/stars/mauriceboe/NOMAD)](https://github.com/mauriceboe/NOMAD) -[![Last Commit](https://img.shields.io/github/last-commit/mauriceboe/NOMAD)](https://github.com/mauriceboe/NOMAD/commits) - -**[Live Demo](https://demo-nomad.pakulat.org)** — Try NOMAD without installing. Resets hourly. +

+ A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more. +
+ Live Demo — Try NOMAD without installing. Resets hourly. +

![NOMAD Screenshot](docs/screenshot.png) @@ -26,38 +32,52 @@ A self-hosted, real-time collaborative travel planner for organizing trips with ## Features -- **Real-Time Collaboration** — Plan together via WebSocket live sync — changes appear instantly across all connected users -- **Interactive Map** — Leaflet map with marker clustering, route visualization, and customizable tile sources -- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed) -- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider +### Trip Planning - **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves -- **Weather Forecasts** — Current weather and 5-day forecasts with smart caching (requires API key) +- **Interactive Map** — Leaflet map with photo markers, clustering, route visualization, and customizable tile sources +- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed) +- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering +- **Route Optimization** — Auto-optimize place order and export to Google Maps +- **Weather Forecasts** — Current weather and 5-day forecasts with smart caching + +### Travel Management +- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments - **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support - **Packing Lists** — Categorized checklists with progress tracking, color coding, and smart suggestions -- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments - **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file) -- **PDF Export** — Export complete trip plans as PDF with images and notes +- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and NOMAD branding + +### Mobile & PWA +- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed +- **Offline Support** — Service Worker caches map tiles, API data, uploads, and static assets via Workbox +- **Native App Feel** — Fullscreen standalone mode, custom app icon, themed status bar, and splash screen +- **Touch Optimized** — Responsive design with mobile-specific layouts, touch-friendly controls, and safe area handling + +### Collaboration +- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users - **Multi-User** — Invite members to collaborate on shared trips with role-based access -- **Addon System** — Modular features that admins can enable/disable: Packing Lists, Budget, Documents, and global addons -- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with WebSocket live sync, and carry-over tracking -- **Atlas** — Interactive world map showing visited countries with travel stats, continent breakdown, streak tracking, and country details on click +- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider + +### Addons (modular, admin-toggleable) +- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking +- **Atlas** — Interactive world map with visited countries, travel stats, continent breakdown, streak tracking, and liquid glass UI effects - **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user -- **Admin Panel** — User management with online status, global categories, addon management, API key configuration, and backups -- **Auto-Backups** — Scheduled backups with configurable interval and retention -- **Route Optimization** — Auto-optimize place order and export to Google Maps -- **Day Notes** — Add timestamped notes to individual days -- **Dark Mode** — Full light and dark theme support + +### Customization & Admin +- **Dark Mode** — Full light and dark theme with dynamic status bar color matching - **Multilingual** — English and German (i18n) -- **Mobile Friendly** — Responsive design with touch-optimized controls +- **Admin Panel** — User management, global categories, addon management, API keys, and backups +- **Auto-Backups** — Scheduled backups with configurable interval and retention - **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates ## Tech Stack - **Backend**: Node.js 22 + Express + SQLite (`node:sqlite`) - **Frontend**: React 18 + Vite + Tailwind CSS +- **PWA**: vite-plugin-pwa + Workbox - **Real-Time**: WebSocket (`ws`) - **State**: Zustand -- **Auth**: JWT +- **Auth**: JWT + OIDC - **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional) - **Weather**: OpenWeatherMap API (optional) - **Icons**: lucide-react @@ -70,6 +90,15 @@ docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads maurice The app runs on port `3000`. The first user to register becomes the admin. +### Install as App (PWA) + +NOMAD works as a Progressive Web App — no App Store needed: + +1. Open your NOMAD instance in the browser (HTTPS required) +2. **iOS**: Share button → "Add to Home Screen" +3. **Android**: Menu → "Install app" or "Add to Home Screen" +4. NOMAD launches fullscreen with its own icon, just like a native app +
Docker Compose (recommended for production) diff --git a/client/index.html b/client/index.html index 7638131..59b5ef4 100644 --- a/client/index.html +++ b/client/index.html @@ -6,14 +6,19 @@ NOMAD - + - + + + + + + diff --git a/client/package.json b/client/package.json index 4172556..1462833 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "nomad-client", - "version": "2.5.1", + "version": "2.5.2", "private": true, "type": "module", "scripts": { diff --git a/client/public/icons/icon-dark.svg b/client/public/icons/icon-dark.svg new file mode 100644 index 0000000..48d4ea4 --- /dev/null +++ b/client/public/icons/icon-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/icons/icon-white.svg b/client/public/icons/icon-white.svg new file mode 100644 index 0000000..7310d26 --- /dev/null +++ b/client/public/icons/icon-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/icons/icon.svg b/client/public/icons/icon.svg index 2ee6384..a680c89 100644 --- a/client/public/icons/icon.svg +++ b/client/public/icons/icon.svg @@ -4,11 +4,12 @@ + + + - - - - - + + + diff --git a/client/public/logo-dark.svg b/client/public/logo-dark.svg new file mode 100644 index 0000000..2a26bc7 --- /dev/null +++ b/client/public/logo-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/logo-light.svg b/client/public/logo-light.svg new file mode 100644 index 0000000..2c218b9 --- /dev/null +++ b/client/public/logo-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/text-dark.svg b/client/public/text-dark.svg new file mode 100644 index 0000000..daa0c2d --- /dev/null +++ b/client/public/text-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/text-light.svg b/client/public/text-light.svg new file mode 100644 index 0000000..d44ac5e --- /dev/null +++ b/client/public/text-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/App.jsx b/client/src/App.jsx index c2fb83c..26830f9 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -87,7 +87,7 @@ export default function App() { } const meta = document.querySelector('meta[name="theme-color"]') if (meta) { - meta.setAttribute('content', settings.dark_mode ? '#121215' : '#ffffff') + meta.setAttribute('content', settings.dark_mode ? '#09090b' : '#ffffff') } }, [settings.dark_mode]) diff --git a/client/src/components/Admin/AddonManager.jsx b/client/src/components/Admin/AddonManager.jsx index 4751193..30e9575 100644 --- a/client/src/components/Admin/AddonManager.jsx +++ b/client/src/components/Admin/AddonManager.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' +import { useSettingsStore } from '../../store/settingsStore' import { useToast } from '../shared/Toast' import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase } from 'lucide-react' @@ -15,6 +16,7 @@ function AddonIcon({ name, size = 20 }) { export default function AddonManager() { const { t } = useTranslation() + const dark = useSettingsStore(s => s.settings.dark_mode) const toast = useToast() const [addons, setAddons] = useState([]) const [loading, setLoading] = useState(true) @@ -67,7 +69,9 @@ export default function AddonManager() {

{t('admin.addons.title')}

-

{t('admin.addons.subtitle')}

+

+ {t('admin.addons.subtitleBefore')}NOMAD{t('admin.addons.subtitleAfter')} +

{addons.length === 0 ? ( diff --git a/client/src/components/Layout/DemoBanner.jsx b/client/src/components/Layout/DemoBanner.jsx index 4b79820..4c5983b 100644 --- a/client/src/components/Layout/DemoBanner.jsx +++ b/client/src/components/Layout/DemoBanner.jsx @@ -4,6 +4,8 @@ import { useTranslation } from '../../i18n' const texts = { de: { + titleBefore: 'Willkommen bei ', + titleAfter: '', title: 'Willkommen zur NOMAD Demo', description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.', resetIn: 'Naechster Reset in', @@ -34,6 +36,8 @@ const texts = { close: 'Verstanden', }, en: { + titleBefore: 'Welcome to ', + titleAfter: '', title: 'Welcome to the NOMAD Demo', description: 'You can view, edit and create trips. All changes are automatically reset every hour.', resetIn: 'Next reset in', @@ -98,15 +102,9 @@ export default function DemoBanner() { {/* Header */}
-
- -
-

- {t.title} + +

+ {t.titleBefore}NOMAD{t.titleAfter}

@@ -141,7 +139,9 @@ export default function DemoBanner() { }}>
- {t.whatIs} + + {language === 'de' ? 'Was ist ' : 'What is '}NOMAD? +

{t.whatIsDesc}

diff --git a/client/src/components/Layout/Navbar.jsx b/client/src/components/Layout/Navbar.jsx index 4a2bc0c..f44f87a 100644 --- a/client/src/components/Layout/Navbar.jsx +++ b/client/src/components/Layout/Navbar.jsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useRef } from 'react' +import ReactDOM from 'react-dom' import { Link, useNavigate, useLocation } from 'react-router-dom' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' @@ -72,10 +73,9 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }) )} - - - NOMAD + + NOMAD + NOMAD {/* Global addon nav items */} @@ -169,11 +169,10 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }) - {userMenuOpen && ( + {userMenuOpen && ReactDOM.createPortal( <> -
setUserMenuOpen(false)} /> -
+
setUserMenuOpen(false)} /> +

{user.username}

{user.email}

@@ -213,13 +212,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }) {t('nav.logout')} {appVersion && ( -
- NOMAD v{appVersion} +
+
+ NOMAD + v{appVersion} +
)}
- + , + document.body )}
)} diff --git a/client/src/components/PDF/TripPDF.jsx b/client/src/components/PDF/TripPDF.jsx index b04c67b..ef110c5 100644 --- a/client/src/components/PDF/TripPDF.jsx +++ b/client/src/components/PDF/TripPDF.jsx @@ -1,8 +1,16 @@ // Trip PDF via browser print window import { createElement } from 'react' import { getCategoryIcon } from '../shared/categoryIcons' +import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } from 'lucide-react' import { mapsApi } from '../../api/client' +const NOTE_ICON_MAP = { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } +function noteIconSvg(iconId) { + if (!_renderToStaticMarkup) return '' + const Icon = NOTE_ICON_MAP[iconId] || FileText + return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })) +} + // ── SVG inline icons (for chips) ───────────────────────────────────────────── const svgPin = `` const svgClock = `` @@ -104,7 +112,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor const cost = dayCost(assignments, day.id, loc) const merged = [] - assigned.forEach(a => merged.push({ type: 'place', k: a.sort_order ?? 0, data: a })) + assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a })) notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n })) merged.sort((a, b) => a.k - b.k) @@ -117,12 +125,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor return `
- - - - - - + ${noteIconSvg(note.icon)}
${escHtml(note.text)}
${note.time ? `
${escHtml(note.time)}
` : ''} @@ -200,6 +203,24 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; } svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; } + /* Footer on every printed page */ + .pdf-footer { + position: fixed; + bottom: 20px; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + opacity: 0.3; + } + .pdf-footer span { + font-size: 7px; + color: #64748b; + letter-spacing: 0.5px; + } + /* ── Cover ─────────────────────────────────────── */ .cover { width: 100%; min-height: 100vh; @@ -215,8 +236,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor .cover-dim { position: absolute; inset: 0; background: rgba(8,12,28,0.55); } .cover-brand { position: absolute; top: 36px; right: 52px; - font-size: 9px; font-weight: 600; letter-spacing: 2.5px; - color: rgba(255,255,255,0.3); text-transform: uppercase; + z-index: 2; } .cover-body { position: relative; z-index: 1; } .cover-circle { @@ -316,11 +336,17 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor + + +
${coverImg ? `
` : ''}
-
NOMAD
+
${coverImg ? `
` diff --git a/client/src/components/Planner/DayPlanSidebar.jsx b/client/src/components/Planner/DayPlanSidebar.jsx index e36a15c..7283c49 100644 --- a/client/src/components/Planner/DayPlanSidebar.jsx +++ b/client/src/components/Planner/DayPlanSidebar.jsx @@ -462,7 +462,7 @@ export default function DayPlanSidebar({ outlineOffset: -2, borderRadius: isDragTarget ? 8 : 0, }} - onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-hover)' }} + onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }} onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }} > {/* Tages-Badge */} @@ -536,7 +536,7 @@ export default function DayPlanSidebar({ {/* Aufgeklappte Orte + Notizen */} {isExpanded && (
{ e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }} onDrop={e => { e.preventDefault() @@ -614,7 +614,7 @@ export default function DayPlanSidebar({ dragDataRef.current = { assignmentId: String(assignment.id), fromDayId: String(day.id) } setDraggingId(assignment.id) }} - onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); setDropTargetKey(`place-${assignment.id}`) }} + onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); if (dropTargetKey !== `place-${assignment.id}`) setDropTargetKey(`place-${assignment.id}`) }} onDrop={e => { e.preventDefault(); e.stopPropagation() const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e) @@ -754,7 +754,7 @@ export default function DayPlanSidebar({ draggable onDragStart={e => { e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }} onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }} - onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`note-${note.id}`) }} + onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }} onDrop={e => { e.preventDefault(); e.stopPropagation() const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e) @@ -819,9 +819,8 @@ export default function DayPlanSidebar({ )} {/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
{ e.preventDefault(); e.stopPropagation(); setDropTargetKey(`end-${day.id}`) }} - onDragLeave={() => { if (dropTargetKey === `end-${day.id}`) setDropTargetKey(null) }} + style={{ minHeight: 12, padding: '2px 8px' }} + onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `end-${day.id}`) setDropTargetKey(`end-${day.id}`) }} onDrop={e => { e.preventDefault(); e.stopPropagation() const { placeId, assignmentId, noteId, fromDayId } = getDragData(e) diff --git a/client/src/i18n/translations/de.js b/client/src/i18n/translations/de.js index da07029..551ec7e 100644 --- a/client/src/i18n/translations/de.js +++ b/client/src/i18n/translations/de.js @@ -252,6 +252,8 @@ const de = { 'admin.tabs.addons': 'Addons', 'admin.addons.title': 'Addons', 'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um NOMAD 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', 'admin.addons.disabled': 'Deaktiviert', 'admin.addons.type.trip': 'Trip', diff --git a/client/src/i18n/translations/en.js b/client/src/i18n/translations/en.js index aa24d49..4d88d37 100644 --- a/client/src/i18n/translations/en.js +++ b/client/src/i18n/translations/en.js @@ -252,6 +252,8 @@ const en = { 'admin.tabs.addons': 'Addons', 'admin.addons.title': 'Addons', 'admin.addons.subtitle': 'Enable or disable features to customize your NOMAD experience.', + 'admin.addons.subtitleBefore': 'Enable or disable features to customize your ', + 'admin.addons.subtitleAfter': ' experience.', 'admin.addons.enabled': 'Enabled', 'admin.addons.disabled': 'Disabled', 'admin.addons.type.trip': 'Trip', diff --git a/client/src/index.css b/client/src/index.css index 7d05340..e01393f 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -326,6 +326,15 @@ body { color: var(--text-faint); } +/* Brand images: no save/copy/drag */ +img[alt="NOMAD"] { + pointer-events: none; + user-select: none; + -webkit-user-select: none; + -webkit-user-drag: none; + -webkit-touch-callout: none; +} + /* Weiche Übergänge */ .transition-smooth { transition: all 0.2s ease; diff --git a/client/src/pages/DashboardPage.jsx b/client/src/pages/DashboardPage.jsx index 6c613cd..899266e 100644 --- a/client/src/pages/DashboardPage.jsx +++ b/client/src/pages/DashboardPage.jsx @@ -74,8 +74,42 @@ const GRADIENTS = [ ] function tripGradient(id) { return GRADIENTS[id % GRADIENTS.length] } +// ── Liquid Glass hover effect ──────────────────────────────────────────────── +function LiquidGlass({ children, dark, style, className = '', onClick }) { + const ref = useRef(null) + const glareRef = useRef(null) + const borderRef = useRef(null) + + const onMove = (e) => { + if (!ref.current || !glareRef.current || !borderRef.current) return + const rect = ref.current.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + glareRef.current.style.background = `radial-gradient(circle 250px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'} 0%, transparent 70%)` + glareRef.current.style.opacity = '1' + borderRef.current.style.opacity = '1' + borderRef.current.style.maskImage = `radial-gradient(circle 120px at ${x}px ${y}px, black 0%, transparent 100%)` + borderRef.current.style.WebkitMaskImage = `radial-gradient(circle 120px at ${x}px ${y}px, black 0%, transparent 100%)` + } + const onLeave = () => { + if (glareRef.current) glareRef.current.style.opacity = '0' + if (borderRef.current) borderRef.current.style.opacity = '0' + } + + return ( +
+
+
+ {children} +
+ ) +} + // ── Spotlight Card (next upcoming trip) ───────────────────────────────────── -function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) { +function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }) { const status = getTripStatus(trip) const coverBg = trip.cover_image @@ -83,7 +117,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale } : tripGradient(trip.id) return ( -
onClick(trip)}> {/* Cover / Background */}
@@ -151,7 +185,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }
-
+ ) } @@ -170,9 +204,9 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) { onMouseLeave={() => setHovered(false)} onClick={() => onClick(trip)} style={{ - background: 'var(--bg-card)', borderRadius: 16, overflow: 'hidden', cursor: 'pointer', - border: '1px solid var(--border-primary)', transition: 'all 0.18s', - boxShadow: hovered ? '0 8px 28px rgba(0,0,0,0.10)' : '0 1px 4px rgba(0,0,0,0.04)', + background: hovered ? 'var(--bg-tertiary)' : 'var(--bg-card)', borderRadius: 16, overflow: 'hidden', cursor: 'pointer', + border: `1px solid ${hovered ? 'var(--text-faint)' : 'var(--border-primary)'}`, transition: 'all 0.18s', + boxShadow: hovered ? '0 8px 28px rgba(0,0,0,0.15)' : '0 1px 4px rgba(0,0,0,0.04)', transform: hovered ? 'translateY(-2px)' : 'none', }} > @@ -354,6 +388,7 @@ export default function DashboardPage() { const { t, locale } = useTranslation() const { demoMode } = useAuthStore() const { settings, updateSetting } = useSettingsStore() + const dark = settings.dark_mode const showCurrency = settings.dashboard_currency !== 'off' const showTimezone = settings.dashboard_timezone !== 'off' const showSidebar = showCurrency || showTimezone @@ -575,7 +610,7 @@ export default function DashboardPage() { {!isLoading && spotlight && ( { setEditingTrip(tr); setShowForm(true) }} onDelete={handleDelete} onArchive={handleArchive} @@ -635,8 +670,8 @@ export default function DashboardPage() { {/* Widgets sidebar */} {showSidebar && (
- {showCurrency && } - {showTimezone && } + {showCurrency && } + {showTimezone && }
)}
diff --git a/client/src/pages/LoginPage.jsx b/client/src/pages/LoginPage.jsx index 50a8b5f..de36efd 100644 --- a/client/src/pages/LoginPage.jsx +++ b/client/src/pages/LoginPage.jsx @@ -67,6 +67,8 @@ export default function LoginPage() { } } + const [showTakeoff, setShowTakeoff] = useState(false) + const handleSubmit = async (e) => { e.preventDefault() setError('') @@ -79,10 +81,10 @@ export default function LoginPage() { } else { await login(email, password) } - navigate('/dashboard') + setShowTakeoff(true) + setTimeout(() => navigate('/dashboard'), 2600) } catch (err) { setError(err.message || t('login.error')) - } finally { setIsLoading(false) } } @@ -95,6 +97,157 @@ export default function LoginPage() { color: '#111827', background: 'white', boxSizing: 'border-box', transition: 'border-color 0.15s', } + if (showTakeoff) { + return ( +
+ {/* Sky gradient */} +
+ + {/* Stars */} + {Array.from({ length: 60 }, (_, i) => ( +
0.7 ? 3 : 1.5, + height: Math.random() > 0.7 ? 3 : 1.5, + borderRadius: '50%', + background: 'white', + top: `${Math.random() * 100}%`, + left: `${Math.random() * 100}%`, + animationDelay: `${0.3 + Math.random() * 0.5}s, ${Math.random() * 1}s`, + }} /> + ))} + + {/* Clouds rushing past */} + {[0, 1, 2, 3, 4].map(i => ( +
+ ))} + + {/* Speed lines */} + {Array.from({ length: 12 }, (_, i) => ( +
+ ))} + + {/* Plane */} +
+ + + + + + + + + + +
+ + {/* Contrail */} +
+ + {/* Logo fade in + burst */} +
+ NOMAD +

{t('login.tagline')}

+
+ + + +
+ ) + } + return (
@@ -215,14 +368,11 @@ export default function LoginPage() {
{/* Logo */} -
-
- -
- NOMAD +
+ NOMAD
-

+

{t('login.tagline')}

@@ -261,13 +411,11 @@ export default function LoginPage() {

{/* Mobile logo */} -
-
- -
- NOMAD + NOMAD +

{t('login.tagline')}

@@ -346,7 +494,7 @@ export default function LoginPage() { > {isLoading ? <>
{mode === 'register' ? t('login.creating') : t('login.signingIn')} - : mode === 'register' ? t('login.createAccount') : t('login.signIn') + : <>{mode === 'register' ? t('login.createAccount') : t('login.signIn')} } diff --git a/server/package.json b/server/package.json index 98d323a..24efe61 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "nomad-server", - "version": "2.5.1", + "version": "2.5.2", "main": "src/index.js", "scripts": { "start": "node --experimental-sqlite src/index.js",