diff --git a/README.md b/README.md index 51a56700..1ab470d0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,11 @@ TREK -### Your trips. Your plan. Your server. + + + + Your trips. Your plan. Your server. + A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in. diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index 1933982c..36d543c1 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -61,11 +61,25 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: navigate('/login', { state: { noRedirect: true } }) } + // Keep track of the pending theme-transition cleanup so we can cancel it + // on unmount. Without this the timer fires after jsdom teardown in unit + // tests (document is gone) and triggers an unhandled ReferenceError that + // trips vitest's exit code. + const themeTransitionTimer = useRef(null) + useEffect(() => () => { + if (themeTransitionTimer.current !== null) { + window.clearTimeout(themeTransitionTimer.current) + themeTransitionTimer.current = null + } + }, []) + const toggleDarkMode = () => { document.documentElement.classList.add('trek-theme-transitioning') updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {}) - window.setTimeout(() => { + if (themeTransitionTimer.current !== null) window.clearTimeout(themeTransitionTimer.current) + themeTransitionTimer.current = window.setTimeout(() => { document.documentElement.classList.remove('trek-theme-transitioning') + themeTransitionTimer.current = null }, 360) } @@ -89,6 +103,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: touchAction: 'manipulation', paddingTop: 'env(safe-area-inset-top, 0px)', height: 'var(--nav-h)', + top: 'var(--offline-banner-h, 0px)', transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)', }} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]"> {/* Left side */} diff --git a/client/src/components/Layout/OfflineBanner.tsx b/client/src/components/Layout/OfflineBanner.tsx index 04b58cf1..5c3b8010 100644 --- a/client/src/components/Layout/OfflineBanner.tsx +++ b/client/src/components/Layout/OfflineBanner.tsx @@ -40,6 +40,22 @@ export default function OfflineBanner(): React.ReactElement | null { }, []) const hidden = isOnline && pendingCount === 0 + + // When the banner is visible, reserve space at the top of the page so it + // doesn't cover the nav/header. Uses a CSS var on so we can offset + // via a global `body` rule instead of rewiring every layout. + useEffect(() => { + const root = document.documentElement + if (hidden) { + root.style.removeProperty('--offline-banner-h') + } else { + // 32px for icon+text row + the top safe-area inset that the banner adds + // in its own padding. Kept in one place so it's easy to tweak. + root.style.setProperty('--offline-banner-h', 'calc(env(safe-area-inset-top, 0px) + 32px)') + } + return () => { root.style.removeProperty('--offline-banner-h') } + }, [hidden]) + if (hidden) return null const offline = !isOnline diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx index d1bdb03d..9311cbc1 100644 --- a/client/src/components/Packing/PackingListPanel.tsx +++ b/client/src/components/Packing/PackingListPanel.tsx @@ -208,9 +208,14 @@ interface ArtikelZeileProps { canEdit?: boolean } +// A category's first item is seeded with this sentinel because the server +// rejects empty names. Treat it as a placeholder in the UI. +const PACKING_PLACEHOLDER_NAME = '...' + function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) { + const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME const [editing, setEditing] = useState(false) - const [editName, setEditName] = useState(item.name) + const [editName, setEditName] = useState(isPlaceholder ? '' : item.name) const [hovered, setHovered] = useState(false) const [showCatPicker, setShowCatPicker] = useState(false) const [showBagPicker, setShowBagPicker] = useState(false) @@ -223,7 +228,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked) const handleSaveName = async () => { - if (!editName.trim()) { setEditing(false); setEditName(item.name); return } + if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return } try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) } catch { toast.error(t('packing.toast.saveError')) } } @@ -275,9 +280,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE {editing && canEdit ? ( setEditName(e.target.value)} onBlur={handleSaveName} - onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }} + onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }} style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }} /> ) : ( @@ -286,7 +292,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE style={{ flex: 1, fontSize: 13.5, cursor: !canEdit || item.checked ? 'default' : 'text', - color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)', + color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'), transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)', textDecoration: item.checked ? 'line-through' : 'none', }} diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 528c3c4d..8ef2282b 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -168,7 +168,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } return ( -
+
+ + +
+ } >
{/* Place Search */} @@ -613,23 +632,6 @@ export default function PlaceFormModal({
)} - {/* Actions */} -
- - -
) diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index 7710a188..4cf9c208 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -203,8 +203,10 @@ describe('ReservationModal', () => { fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } }); fireEvent.change(timePickers[1], { target: { value: '09:00' } }); - // When isEndBeforeStart=true the submit button is disabled, so submit the form directly - const form = screen.getByRole('button', { name: /^Add$/i }).closest('form')!; + // When isEndBeforeStart=true the submit button is disabled, so fire submit on the form directly. + // The Save button now lives in the Modal's sticky footer (outside the
), so we query + // the form by tag instead of walking up from the button. + const form = document.querySelector('form')!; fireEvent.submit(form); expect(onSave).not.toHaveBeenCalled(); diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 7064b4a5..7fa8a7e8 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -271,7 +271,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' } return ( - + + + +
+ } + > {/* Type selector */} @@ -622,15 +637,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p )} - {/* Actions */} -
- - -
) diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index ff98434a..00c105c2 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -148,13 +148,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'} onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'} > - {/* Header */} + {/* Header — wraps to a second row on narrow screens so the status/category chips + never collide with the title. */}
-
+
+ + +
+ } >
@@ -412,15 +422,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
- {/* Actions */} -
- - -
) diff --git a/client/src/components/Settings/DisplaySettingsTab.test.tsx b/client/src/components/Settings/DisplaySettingsTab.test.tsx index 4c770e6c..d0ff467e 100644 --- a/client/src/components/Settings/DisplaySettingsTab.test.tsx +++ b/client/src/components/Settings/DisplaySettingsTab.test.tsx @@ -155,7 +155,9 @@ describe('DisplaySettingsTab', () => { const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting }); render(); - await user.click(screen.getByText('24h (14:30)')); + // The label is split across a text node ('24h') and a responsive span (' (14:30)'). + // Click the button that contains the 24h text instead of matching the full string. + await user.click(screen.getByRole('button', { name: /24h/ })); expect(updateSetting).toHaveBeenCalledWith('time_format', '24h'); }); diff --git a/client/src/components/Settings/DisplaySettingsTab.tsx b/client/src/components/Settings/DisplaySettingsTab.tsx index 0eaec1ca..575041f9 100644 --- a/client/src/components/Settings/DisplaySettingsTab.tsx +++ b/client/src/components/Settings/DisplaySettingsTab.tsx @@ -188,8 +188,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
{[ - { value: '24h', label: '24h (14:30)' }, - { value: '12h', label: '12h (2:30 PM)' }, + { value: '24h', short: '24h', example: '14:30' }, + { value: '12h', short: '12h', example: '2:30 PM' }, ].map(opt => ( ))}
diff --git a/client/src/components/Settings/MapSettingsTab.tsx b/client/src/components/Settings/MapSettingsTab.tsx index 579973ae..d1bea396 100644 --- a/client/src/components/Settings/MapSettingsTab.tsx +++ b/client/src/components/Settings/MapSettingsTab.tsx @@ -240,14 +240,18 @@ export default function MapSettingsTab(): React.ReactElement { : 'border-slate-200 hover:border-slate-400 dark:border-slate-700' }`} > - - {t('settings.mapExperimental')} - -
-
Mapbox GL
+
+
+ Mapbox + Mapbox GL +
{t('settings.mapMapboxSubtitle')}
+ {/* Experimental badge only on ≥sm; on mobile there's no room next to the title. */} + + {t('settings.mapExperimental')} +

diff --git a/client/src/components/Settings/PhotoProvidersSection.tsx b/client/src/components/Settings/PhotoProvidersSection.tsx index 042d5e6a..bd1659a7 100644 --- a/client/src/components/Settings/PhotoProvidersSection.tsx +++ b/client/src/components/Settings/PhotoProvidersSection.tsx @@ -5,6 +5,7 @@ import { useToast } from '../../components/shared/Toast' import apiClient from '../../api/client' import { useAddonStore } from '../../store/addonStore' import Section from './Section' +import ToggleSwitch from './ToggleSwitch' interface ProviderField { key: string @@ -222,15 +223,13 @@ export default function PhotoProvidersSection(): React.ReactElement { {fields.map(field => (

{field.input_type === 'checkbox' ? ( -
))} -
+ {/* Wraps on mobile so the connection badge drops to its own row + instead of clipping off the side of the card. */} +
+ {/* On mobile the badge sits on its own row thanks to flex-wrap, so force a line break via basis-full. */} {connected ? ( - + {t('memories.connected')} ) : ( - + {t('memories.disconnected')} diff --git a/client/src/components/Settings/ToggleSwitch.tsx b/client/src/components/Settings/ToggleSwitch.tsx index 562d7238..b74e5513 100644 --- a/client/src/components/Settings/ToggleSwitch.tsx +++ b/client/src/components/Settings/ToggleSwitch.tsx @@ -2,9 +2,10 @@ import React from 'react' export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) { return ( -