v2.5.2 — PWA, new branding, bug fixes
Progressive Web App: - Service worker with Workbox caching (map tiles, API, uploads, CDN) - Web app manifest with standalone display mode - Custom app icon with PNG generation from SVG - Apple meta tags, dynamic theme-color for dark/light mode - iOS safe area handling New Branding: - Custom NOMAD logo (icon + text variants for light/dark mode) - Logo used in navbar, login page, demo banner, admin, PDF export - MuseoModerno font for login tagline - Plane takeoff animation on login - Liquid glass hover effect on dashboard spotlight & widgets - Brand images protected from save/copy/drag - "made with NOMAD" footer on PDF exports Bug Fixes: - Fix mobile note reorder (missing tripId prop) - Fix Atlas city counting (strip postal codes, normalize case) - Fix Atlas country detection (add Japanese/Korean/Thai names) - Fix PDF note positioning (use order_index instead of sort_order) - Fix PDF note icons (render actual icon instead of hardcoded notepad) - Fix file source badge overflow on mobile (text truncation) - Fix navbar dropdown z-index overlap with mobile plan/places buttons - Fix dashboard trip card hover contrast in dark mode - Fix day header hover color matching place background in dark mode - Shorten settings button labels on mobile UI Improvements: - Mobile navbar shows icon only, desktop shows full logo - NOMAD version badge in profile dropdown - Top padding before first item in day planner - Improved drag & drop stability (larger drop zones, less flickering)
83
README.md
@@ -1,15 +1,21 @@
|
||||
# NOMAD
|
||||
<p align="center">
|
||||
<img src="client/public/logo-dark.svg" alt="NOMAD" height="60" />
|
||||
<br />
|
||||
<em>Navigation Organizer for Maps, Activities & Destinations</em>
|
||||
</p>
|
||||
|
||||
**Navigation Organizer for Maps, Activities & Destinations**
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/nomad"><img src="https://img.shields.io/docker/pulls/mauriceboe/nomad" alt="Docker Pulls" /></a>
|
||||
<a href="https://github.com/mauriceboe/NOMAD"><img src="https://img.shields.io/github/stars/mauriceboe/NOMAD" alt="GitHub Stars" /></a>
|
||||
<a href="https://github.com/mauriceboe/NOMAD/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/NOMAD" alt="Last Commit" /></a>
|
||||
</p>
|
||||
|
||||
A self-hosted, real-time collaborative travel planner for organizing trips with interactive maps, budgets, packing lists, and more.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/mauriceboe/nomad)
|
||||
[](https://github.com/mauriceboe/NOMAD)
|
||||
[](https://github.com/mauriceboe/NOMAD/commits)
|
||||
|
||||
**[Live Demo](https://demo-nomad.pakulat.org)** — Try NOMAD without installing. Resets hourly.
|
||||
<p align="center">
|
||||
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
|
||||
<br />
|
||||
<strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try NOMAD without installing. Resets hourly.
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
@@ -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
|
||||
|
||||
<details>
|
||||
<summary>Docker Compose (recommended for production)</summary>
|
||||
|
||||
|
||||
@@ -6,14 +6,19 @@
|
||||
<title>NOMAD</title>
|
||||
|
||||
<!-- PWA / iOS -->
|
||||
<meta name="theme-color" content="#111827" />
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="NOMAD" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nomad-client",
|
||||
"version": "2.5.1",
|
||||
"version": "2.5.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
1
client/public/icons/icon-dark.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="a5b4275efd"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="61932b752f"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.753906 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.753906 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#a5b4275efd)"><g clip-path="url(#61932b752f)"><path fill="#000000" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
1
client/public/icons/icon-white.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="ff6253e8fa"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="c6b14a8188"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#ff6253e8fa)"><g clip-path="url(#c6b14a8188)"><path fill="#ffffff" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -4,11 +4,12 @@
|
||||
<stop offset="0%" stop-color="#1e293b"/>
|
||||
<stop offset="100%" stop-color="#0f172a"/>
|
||||
</linearGradient>
|
||||
<clipPath id="icon">
|
||||
<path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<!-- Background -->
|
||||
<rect width="512" height="512" fill="url(#bg)"/>
|
||||
<!-- Navigation arrow (upper-left wing — brighter) -->
|
||||
<polygon points="128,249 384,128 236,276" fill="#ffffff"/>
|
||||
<!-- Navigation arrow (lower-right wing — subtle depth) -->
|
||||
<polygon points="384,128 263,384 236,276" fill="#e2e8f0"/>
|
||||
<g transform="translate(56,51) scale(0.267)">
|
||||
<rect width="1500" height="1500" fill="#ffffff" clip-path="url(#icon)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 582 B After Width: | Height: | Size: 2.0 KiB |
1
client/public/logo-dark.svg
Normal file
|
After Width: | Height: | Size: 10 KiB |
1
client/public/logo-light.svg
Normal file
|
After Width: | Height: | Size: 10 KiB |
1
client/public/text-dark.svg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
1
client/public/text-light.svg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
@@ -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])
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.addons.subtitle')}</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="NOMAD" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{addons.length === 0 ? (
|
||||
|
||||
@@ -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 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10,
|
||||
background: '#111827',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Plane size={18} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827' }}>
|
||||
{t.title}
|
||||
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
|
||||
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
{t.titleBefore}<img src="/text-dark.svg" alt="NOMAD" style={{ height: 18 }} />{t.titleAfter}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +139,9 @@ export default function DemoBanner() {
|
||||
}}>
|
||||
<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>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{language === 'de' ? 'Was ist ' : 'What is '}<img src="/text-dark.svg" alt="NOMAD" style={{ height: 13, marginRight: -2 }} />?
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
|
||||
</div>
|
||||
|
||||
@@ -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 })
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Link to="/dashboard" className="flex items-center gap-2 transition-colors flex-shrink-0"
|
||||
style={{ color: 'var(--text-primary)' }}>
|
||||
<Plane className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
|
||||
<span className="font-bold text-sm hidden sm:inline">NOMAD</span>
|
||||
<Link to="/dashboard" className="flex items-center transition-colors flex-shrink-0">
|
||||
<img src={dark ? '/icons/icon-white.svg' : '/icons/icon-dark.svg'} alt="NOMAD" className="sm:hidden" style={{ height: 22, width: 22 }} />
|
||||
<img src={dark ? '/logo-light.svg' : '/logo-dark.svg'} alt="NOMAD" className="hidden sm:block" style={{ height: 28 }} />
|
||||
</Link>
|
||||
|
||||
{/* Global addon nav items */}
|
||||
@@ -169,11 +169,10 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
||||
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} />
|
||||
</button>
|
||||
|
||||
{userMenuOpen && (
|
||||
{userMenuOpen && ReactDOM.createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setUserMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-2 w-52 rounded-xl shadow-xl border z-20 overflow-hidden"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
|
||||
<div className="w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
||||
@@ -213,13 +212,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
{appVersion && (
|
||||
<div className="px-4 py-1.5 text-center" style={{ fontSize: 10, color: 'var(--text-faint)' }}>
|
||||
NOMAD v{appVersion}
|
||||
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="NOMAD" style={{ height: 10, opacity: 0.5 }} />
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
|
||||
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
|
||||
@@ -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 `
|
||||
<div class="note-card">
|
||||
<div class="note-line"></div>
|
||||
<svg class="note-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.8" stroke-linecap="round">
|
||||
<rect x="4" y="3" width="16" height="18" rx="2"/>
|
||||
<line x1="8" y1="8" x2="16" y2="8"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
<line x1="8" y1="16" x2="13" y2="16"/>
|
||||
</svg>
|
||||
<span class="note-icon">${noteIconSvg(note.icon)}</span>
|
||||
<div class="note-body">
|
||||
<div class="note-text">${escHtml(note.text)}</div>
|
||||
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
|
||||
@@ -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
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Footer on every page -->
|
||||
<div class="pdf-footer">
|
||||
<span>made with</span>
|
||||
<img src="${absUrl('/logo-dark.svg')}" style="height:10px;opacity:0.6;" />
|
||||
</div>
|
||||
|
||||
<!-- Cover -->
|
||||
<div class="cover">
|
||||
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''}
|
||||
<div class="cover-dim"></div>
|
||||
<div class="cover-brand">NOMAD</div>
|
||||
<div class="cover-brand"><img src="${absUrl('/logo-light.svg')}" style="height:28px;opacity:0.5;" /></div>
|
||||
<div class="cover-body">
|
||||
${coverImg
|
||||
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
|
||||
|
||||
@@ -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 && (
|
||||
<div
|
||||
style={{ background: 'var(--bg-hover)' }}
|
||||
style={{ background: 'var(--bg-hover)', paddingTop: 6 }}
|
||||
onDragOver={e => { 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 */}
|
||||
<div
|
||||
style={{ minHeight: 8, padding: '2px 8px' }}
|
||||
onDragOver={e => { 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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div ref={ref} onMouseMove={onMove} onMouseLeave={onLeave} onClick={onClick} className={className}
|
||||
style={{ position: 'relative', overflow: 'hidden', ...style }}>
|
||||
<div ref={glareRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.3s', borderRadius: 'inherit', zIndex: 1 }} />
|
||||
<div ref={borderRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.3s', borderRadius: 'inherit', zIndex: 1,
|
||||
border: dark ? '1.5px solid rgba(255,255,255,0.4)' : '1.5px solid rgba(0,0,0,0.12)',
|
||||
}} />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div style={{ marginBottom: 32, borderRadius: 20, overflow: 'hidden', boxShadow: '0 8px 40px rgba(0,0,0,0.13)', position: 'relative', cursor: 'pointer' }}
|
||||
<LiquidGlass dark={dark} style={{ marginBottom: 32, borderRadius: 20, boxShadow: '0 8px 40px rgba(0,0,0,0.13)', cursor: 'pointer' }}
|
||||
onClick={() => onClick(trip)}>
|
||||
{/* Cover / Background */}
|
||||
<div style={{ height: 300, background: coverBg, position: 'relative' }}>
|
||||
@@ -151,7 +185,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<SpotlightCard
|
||||
trip={spotlight}
|
||||
t={t} locale={locale}
|
||||
t={t} locale={locale} dark={dark}
|
||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
@@ -635,8 +670,8 @@ export default function DashboardPage() {
|
||||
{/* Widgets sidebar */}
|
||||
{showSidebar && (
|
||||
<div className="hidden lg:flex flex-col gap-4" style={{ position: 'sticky', top: 80, flexShrink: 0, width: 280 }}>
|
||||
{showCurrency && <CurrencyWidget />}
|
||||
{showTimezone && <TimezoneWidget />}
|
||||
{showCurrency && <LiquidGlass dark={dark} style={{ borderRadius: 16 }}><CurrencyWidget /></LiquidGlass>}
|
||||
{showTimezone && <LiquidGlass dark={dark} style={{ borderRadius: 16 }}><TimezoneWidget /></LiquidGlass>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="takeoff-overlay" style={{ position: 'fixed', inset: 0, zIndex: 99999, overflow: 'hidden' }}>
|
||||
{/* Sky gradient */}
|
||||
<div className="takeoff-sky" style={{ position: 'absolute', inset: 0 }} />
|
||||
|
||||
{/* Stars */}
|
||||
{Array.from({ length: 60 }, (_, i) => (
|
||||
<div key={i} className="takeoff-star" style={{
|
||||
position: 'absolute',
|
||||
width: Math.random() > 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 => (
|
||||
<div key={i} className="takeoff-cloud" style={{
|
||||
position: 'absolute',
|
||||
width: 120 + i * 40,
|
||||
height: 40 + i * 10,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
filter: 'blur(8px)',
|
||||
right: -200,
|
||||
top: `${25 + i * 12}%`,
|
||||
animationDelay: `${0.3 + i * 0.25}s`,
|
||||
}} />
|
||||
))}
|
||||
|
||||
{/* Speed lines */}
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<div key={i} className="takeoff-speedline" style={{
|
||||
position: 'absolute',
|
||||
width: 80 + Math.random() * 120,
|
||||
height: 1.5,
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
|
||||
top: `${10 + Math.random() * 80}%`,
|
||||
right: -200,
|
||||
animationDelay: `${0.5 + i * 0.12}s`,
|
||||
}} />
|
||||
))}
|
||||
|
||||
{/* Plane */}
|
||||
<div className="takeoff-plane" style={{ position: 'absolute', left: '50%', bottom: '10%', transform: 'translate(-50%, 0)' }}>
|
||||
<svg viewBox="0 0 480 120" style={{ width: 200, filter: 'drop-shadow(0 0 20px rgba(255,255,255,0.3))' }}>
|
||||
<g fill="white" transform="translate(240,60) rotate(-12)">
|
||||
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
||||
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
||||
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
||||
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
|
||||
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
|
||||
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Contrail */}
|
||||
<div className="takeoff-trail" style={{
|
||||
position: 'absolute', left: '50%', bottom: '8%',
|
||||
width: 3, height: 0, background: 'linear-gradient(to top, transparent, rgba(255,255,255,0.5))',
|
||||
transformOrigin: 'bottom center',
|
||||
}} />
|
||||
|
||||
{/* Logo fade in + burst */}
|
||||
<div className="takeoff-logo" style={{
|
||||
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<img src="/logo-light.svg" alt="NOMAD" style={{ height: 72 }} />
|
||||
<p style={{ margin: 0, fontSize: 20, color: 'rgba(255,255,255,0.6)', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>{t('login.tagline')}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<style>{`
|
||||
.takeoff-sky {
|
||||
background: linear-gradient(to top, #1a1a2e 0%, #16213e 30%, #0f3460 60%, #0a0a23 100%);
|
||||
animation: skyShift 2.6s ease-in-out forwards;
|
||||
}
|
||||
@keyframes skyShift {
|
||||
0% { background: linear-gradient(to top, #0a0a23 0%, #0f172a 40%, #111827 100%); }
|
||||
100% { background: linear-gradient(to top, #000011 0%, #000016 50%, #000011 100%); }
|
||||
}
|
||||
|
||||
.takeoff-star {
|
||||
opacity: 0;
|
||||
animation: starAppear 0.5s ease-out forwards, starTwinkle 2s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes starAppear {
|
||||
0% { opacity: 0; transform: scale(0); }
|
||||
100% { opacity: 0.7; transform: scale(1); }
|
||||
}
|
||||
@keyframes starTwinkle {
|
||||
0% { opacity: 0.3; }
|
||||
100% { opacity: 0.9; }
|
||||
}
|
||||
|
||||
.takeoff-cloud {
|
||||
animation: cloudRush 0.6s ease-in forwards;
|
||||
}
|
||||
@keyframes cloudRush {
|
||||
0% { right: -200px; opacity: 0; }
|
||||
20% { opacity: 0.4; }
|
||||
100% { right: 120%; opacity: 0; }
|
||||
}
|
||||
|
||||
.takeoff-speedline {
|
||||
animation: speedRush 0.4s ease-in forwards;
|
||||
}
|
||||
@keyframes speedRush {
|
||||
0% { right: -200px; opacity: 0; }
|
||||
30% { opacity: 0.6; }
|
||||
100% { right: 120%; opacity: 0; }
|
||||
}
|
||||
|
||||
.takeoff-plane {
|
||||
animation: planeUp 1s ease-in forwards;
|
||||
}
|
||||
@keyframes planeUp {
|
||||
0% { transform: translate(-50%, 0) rotate(0deg) scale(1); bottom: 8%; left: 50%; opacity: 1; }
|
||||
100% { transform: translate(-50%, 0) rotate(-22deg) scale(0.15); bottom: 120%; left: 58%; opacity: 0; }
|
||||
}
|
||||
|
||||
.takeoff-trail {
|
||||
animation: trailGrow 0.9s ease-out 0.15s forwards;
|
||||
}
|
||||
@keyframes trailGrow {
|
||||
0% { height: 0; opacity: 0; transform: translateX(-50%) rotate(-5deg); }
|
||||
30% { height: 150px; opacity: 0.6; }
|
||||
60% { height: 350px; opacity: 0.4; }
|
||||
100% { height: 600px; opacity: 0; transform: translateX(-50%) rotate(-8deg); }
|
||||
}
|
||||
|
||||
.takeoff-logo {
|
||||
opacity: 0;
|
||||
animation: logoReveal 0.5s ease-out 0.9s forwards;
|
||||
}
|
||||
@keyframes logoReveal {
|
||||
0% { opacity: 0; transform: translate(-50%, -40%) scale(0.9); }
|
||||
100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
|
||||
|
||||
@@ -215,14 +368,11 @@ export default function LoginPage() {
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, maxWidth: 560, textAlign: 'center' }}>
|
||||
{/* Logo */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 48, justifyContent: 'center' }}>
|
||||
<div style={{ width: 48, height: 48, background: 'white', borderRadius: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 0 30px rgba(255,255,255,0.1)' }}>
|
||||
<Plane size={24} style={{ color: '#0f172a' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 28, fontWeight: 800, color: 'white', letterSpacing: '-0.02em' }}>NOMAD</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 48 }}>
|
||||
<img src="/logo-light.svg" alt="NOMAD" style={{ height: 64 }} />
|
||||
</div>
|
||||
|
||||
<h2 style={{ margin: '0 0 12px', fontSize: 36, fontWeight: 800, color: 'white', lineHeight: 1.15, letterSpacing: '-0.02em' }}>
|
||||
<h2 style={{ margin: '0 0 12px', fontSize: 36, fontWeight: 700, color: 'white', lineHeight: 1.15, letterSpacing: '-0.02em', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>
|
||||
{t('login.tagline')}
|
||||
</h2>
|
||||
<p style={{ margin: '0 0 44px', fontSize: 15, color: 'rgba(255,255,255,0.5)', lineHeight: 1.7 }}>
|
||||
@@ -261,13 +411,11 @@ export default function LoginPage() {
|
||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||
|
||||
{/* Mobile logo */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 36, justifyContent: 'center' }}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, marginBottom: 36 }}
|
||||
className="mobile-logo">
|
||||
<style>{`@media(min-width:1024px){.mobile-logo{display:none!important}}`}</style>
|
||||
<div style={{ width: 36, height: 36, background: '#111827', borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Plane size={18} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 22, fontWeight: 800, color: '#111827', letterSpacing: '-0.02em' }}>NOMAD</span>
|
||||
<img src="/logo-dark.svg" alt="NOMAD" style={{ height: 48 }} />
|
||||
<p style={{ margin: 0, fontSize: 18, color: '#9ca3af', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>{t('login.tagline')}</p>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}>
|
||||
@@ -346,7 +494,7 @@ export default function LoginPage() {
|
||||
>
|
||||
{isLoading
|
||||
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : t('login.signingIn')}</>
|
||||
: mode === 'register' ? t('login.createAccount') : t('login.signIn')
|
||||
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : t('login.signIn')}</>
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -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",
|
||||
|
||||