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)
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user