From 9e5100c71cac210811d1676585cd02ed8b8cc7f9 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 21 Apr 2026 21:36:43 +0200 Subject: [PATCH 01/11] fix: keep modal save button visible on mobile (#803, #804) Two fixes in Modal.tsx: - Replace 100vh with 100dvh so iOS Safari PWA respects the actual visible viewport. Explicitly subtract --bottom-nav-h on mobile so the modal never extends behind the tab bar. - overflow-hidden on the container so the footer's bottom corners inherit rounded-2xl. - flex-shrink-0 on header and footer + min-h-0 on the body so the body shrinks and scrolls while the footer stays put. One fix in PlaceFormModal.tsx: - Save/cancel were rendered inside the scrollable body. Moved them into the Modal's footer slot. --- .../src/components/Planner/PlaceFormModal.tsx | 36 ++++++++++--------- client/src/components/shared/Modal.tsx | 17 ++++----- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 1b1588c9..725ad8f0 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -360,6 +360,25 @@ export default function PlaceFormModal({ onClose={onClose} title={place ? t('places.editPlace') : t('places.addPlace')} size="lg" + footer={ +
+ + +
+ } >
{/* Place Search */} @@ -613,23 +632,6 @@ export default function PlaceFormModal({ )} - {/* Actions */} -
- - -
) diff --git a/client/src/components/shared/Modal.tsx b/client/src/components/shared/Modal.tsx index 54b27ead..246de62d 100644 --- a/client/src/components/shared/Modal.tsx +++ b/client/src/components/shared/Modal.tsx @@ -61,14 +61,15 @@ export default function Modal({
e.stopPropagation()} > - {/* Header */} -
+ {/* Header — stays put even while the body scrolls */} +

{title}

{!hideCloseButton && ( + +
+ } + >
{/* Type selector */} @@ -622,15 +637,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p )} - {/* Actions */} -
- - -
) diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index c04d6003..83ca6a58 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -237,6 +237,16 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel onClose={onClose} title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')} size="2xl" + footer={ +
+ + +
+ } >
@@ -412,15 +422,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
- {/* Actions */} -
- - -
) From 0e3d9f6ddc88a32c52b1de7f22ca4e7fbc59fc5e Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 21 Apr 2026 21:46:58 +0200 Subject: [PATCH 04/11] fix: reservation card header overlap on mobile (#810) Status and category chips collided with the reservation title on narrow viewports because the header was a single-line flex with inline chips of natural width. flexWrap on the outer row plus the inner chip group lets the title+actions drop to a second row when content overflows, so the chips and the title never overlap. --- client/src/components/Planner/ReservationsPanel.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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. */}
-
+
Date: Tue, 21 Apr 2026 21:50:56 +0200 Subject: [PATCH 05/11] fix: treat new-category placeholder name '...' as a UI placeholder (#811) When a user adds a new packing category, the first item is seeded with name '...' because the server rejects empty names. That string was rendered as a real value in the input, forcing users to delete the dots before typing. Now we detect the sentinel, show it as a faint placeholder in the display span, and start the edit input empty (with '...' as the HTML placeholder). --- client/src/components/Packing/PackingListPanel.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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', }} From 534149ba22f8be5780ca0771b927b57e9ca40144 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 21 Apr 2026 21:52:46 +0200 Subject: [PATCH 06/11] fix(test): query form by tag since Save button is now in Modal footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After moving Save/Cancel into the Modal's sticky footer prop, the button no longer lives inside the
element, so walking up via closest('form') returns null. Query the form directly via document.querySelector('form') — same semantics, just doesn't assume the button is a descendant of the form. --- client/src/components/Planner/ReservationModal.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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(); From 069269e69cb0e9683ee702bfb9810e21eebbf530 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 21 Apr 2026 22:03:20 +0200 Subject: [PATCH 07/11] fix: integrations settings squish on mobile (#812) + polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PhotoProvidersSection: - Replace raw with TREK's ToggleSwitch so the 'spiegeln zu Immich'-style options match the rest of the app. - Wrap action row in flex-wrap so the connected/disconnected badge drops to its own line on mobile instead of clipping. - Add a short 'Test' translation (memories.testShort) shown on mobile in place of 'Test connection' — 14 languages kept in sync. ToggleSwitch: - Explicit type='button' (never a form submitter), minWidth + flex- shrink:0 so the toggle doesn't get squished next to long labels, padding:0 so no inherited UA margin warps the inner circle. MapSettingsTab: - 'Mapbox' instead of 'Mapbox GL' on narrow screens — the provider card is too cramped on mobile for the full name. - Drop the 'Experimental' badge on mobile entirely; it overlapped the title at that width. Still shown on >=sm. DisplaySettingsTab: - Time format buttons show just '24h' / '12h' on mobile; the '(14:30)' / '(2:30 PM)' hint stays on >=sm. Test updated to match the role query since the label is now split across nodes. --- .../Settings/DisplaySettingsTab.test.tsx | 4 ++- .../Settings/DisplaySettingsTab.tsx | 7 +++--- .../components/Settings/MapSettingsTab.tsx | 14 +++++++---- .../Settings/PhotoProvidersSection.tsx | 25 +++++++++++-------- .../src/components/Settings/ToggleSwitch.tsx | 5 ++-- client/src/i18n/translations/ar.ts | 1 + client/src/i18n/translations/br.ts | 1 + client/src/i18n/translations/cs.ts | 1 + client/src/i18n/translations/de.ts | 1 + client/src/i18n/translations/en.ts | 1 + client/src/i18n/translations/es.ts | 1 + client/src/i18n/translations/fr.ts | 1 + client/src/i18n/translations/hu.ts | 1 + client/src/i18n/translations/id.ts | 1 + client/src/i18n/translations/it.ts | 1 + client/src/i18n/translations/nl.ts | 1 + client/src/i18n/translations/pl.ts | 1 + client/src/i18n/translations/ru.ts | 1 + client/src/i18n/translations/zh.ts | 1 + client/src/i18n/translations/zhTw.ts | 1 + 20 files changed, 48 insertions(+), 22 deletions(-) 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 ( -