diff --git a/client/src/components/Admin/PermissionsPanel.tsx b/client/src/components/Admin/PermissionsPanel.tsx index 85a6f2a4..acab4f0c 100644 --- a/client/src/components/Admin/PermissionsPanel.tsx +++ b/client/src/components/Admin/PermissionsPanel.tsx @@ -107,10 +107,12 @@ export default function PermissionsPanel(): React.ReactElement { + + ) + })()} {onUndo && (
} {canEditDays && onAddTransport && ( + + )} {(() => { const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id) @@ -1217,15 +1257,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
- {canEditDays && } + } - - {selectMode && ( -
- - {t('places.selectionCount', { count: selectedIds.size })} - - - - -
- )} } {/* Filter-Tabs */} -
- {([{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }, hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null] as const).filter(Boolean).map(f => ( - - ))} -
+ {(() => { + const baseFiltered = places.filter(p => { + if (categoryFilters.size > 0) { + if (p.category_id == null) { + if (!categoryFilters.has('uncategorized')) return false + } else if (!categoryFilters.has(String(p.category_id))) return false + } + if (search && !p.name.toLowerCase().includes(search.toLowerCase()) && + !(p.address || '').toLowerCase().includes(search.toLowerCase())) return false + return true + }) + const counts = { + all: baseFiltered.length, + unplanned: baseFiltered.filter(p => !plannedIds.has(p.id)).length, + tracks: baseFiltered.filter(p => p.route_geometry).length, + } + const tabs = ([ + { id: 'all', label: t('places.all') }, + { id: 'unplanned', label: t('places.unplanned') }, + hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null, + ] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'tracks'; label: string }> + return ( +
+ {tabs.map(f => { + const active = filter === f.id + return ( + + ) + })} +
+ ) + })()} {/* Suchfeld */}
@@ -470,9 +462,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ ? (categoryFilters.has('uncategorized') ? t('places.noCategory') : categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories')) : `${categoryFilters.size} ${t('places.categoriesSelected')}` return ( -
+
+ {canEditPlaces && ( + + + + )} {catDropOpen && (
- {/* Anzahl */} -
- {filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })} -
+ {/* Anzahl / Auswahl-Leiste */} + {selectMode ? ( +
+ + {t('places.selectionCount', { count: selectedIds.size })} + + 0 ? t('common.deselectAll') : t('common.selectAll')} placement="bottom"> + + + + + +
+ ) : ( +
+ {filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })} +
+ )} {/* Liste */}
diff --git a/client/src/components/Settings/DisplaySettingsTab.test.tsx b/client/src/components/Settings/DisplaySettingsTab.test.tsx index bf2dd919..4c770e6c 100644 --- a/client/src/components/Settings/DisplaySettingsTab.test.tsx +++ b/client/src/components/Settings/DisplaySettingsTab.test.tsx @@ -42,7 +42,7 @@ describe('DisplaySettingsTab', () => { it('FE-COMP-DISPLAY-005: shows Auto mode button', () => { render(); - expect(screen.getByText('Auto')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Auto/i })).toBeInTheDocument(); }); it('FE-COMP-DISPLAY-006: shows Language section', () => { @@ -95,16 +95,16 @@ describe('DisplaySettingsTab', () => { const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'light' }), updateSetting }); render(); - await user.click(screen.getByText('Auto')); + await user.click(screen.getByRole('button', { name: /Auto/i })); expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'auto'); }); it('FE-COMP-DISPLAY-014: active color mode button has border with var(--text-primary)', () => { seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) }); render(); - const darkBtn = screen.getByText('Dark').closest('button')!; - const lightBtn = screen.getByText('Light').closest('button')!; - const autoBtn = screen.getByText('Auto').closest('button')!; + const darkBtn = screen.getByRole('button', { name: /^Dark$/i }); + const lightBtn = screen.getByRole('button', { name: /^Light$/i }); + const autoBtn = screen.getByRole('button', { name: /Auto/i }); expect(darkBtn.style.border).toContain('var(--text-primary)'); expect(lightBtn.style.border).toContain('var(--border-primary)'); expect(autoBtn.style.border).toContain('var(--border-primary)'); @@ -122,8 +122,11 @@ describe('DisplaySettingsTab', () => { it('FE-COMP-DISPLAY-016: active language button is visually highlighted', () => { seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) }); render(); - const englishBtn = screen.getByText('English').closest('button')!; - expect(englishBtn.style.border).toContain('var(--text-primary)'); + // Multiple elements contain "English" (desktop grid button + mobile dropdown trigger). + // The desktop grid button is the one with the active border style. + const englishMatches = screen.getAllByText('English').map(el => el.closest('button')!).filter(Boolean); + const activeBtn = englishMatches.find(btn => (btn.style.border || '').includes('var(--text-primary)')); + expect(activeBtn).toBeDefined(); }); it('FE-COMP-DISPLAY-017: shows Temperature section label', () => { diff --git a/client/src/components/Settings/DisplaySettingsTab.tsx b/client/src/components/Settings/DisplaySettingsTab.tsx index e84f00e1..0eaec1ca 100644 --- a/client/src/components/Settings/DisplaySettingsTab.tsx +++ b/client/src/components/Settings/DisplaySettingsTab.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from 'react' -import { Palette, Sun, Moon, Monitor } from 'lucide-react' +import React, { useState, useEffect, useRef } from 'react' +import { Palette, Sun, Moon, Monitor, ChevronDown, Check } from 'lucide-react' import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useToast } from '../shared/Toast' @@ -10,6 +10,17 @@ export default function DisplaySettingsTab(): React.ReactElement { const { t } = useTranslation() const toast = useToast() const [tempUnit, setTempUnit] = useState(settings.temperature_unit || 'celsius') + const [langOpen, setLangOpen] = useState(false) + const langDropdownRef = useRef(null) + + useEffect(() => { + if (!langOpen) return + const handler = (e: MouseEvent) => { + if (langDropdownRef.current && !langDropdownRef.current.contains(e.target as Node)) setLangOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [langOpen]) useEffect(() => { setTempUnit(settings.temperature_unit || 'celsius') @@ -46,8 +57,13 @@ export default function DisplaySettingsTab(): React.ReactElement { transition: 'all 0.15s', }} > - - {opt.label} + + {opt.value === 'auto' ? ( + <> + {opt.label} + Auto + + ) : opt.label} ) })} @@ -57,7 +73,8 @@ export default function DisplaySettingsTab(): React.ReactElement { {/* Language */}
-
+ {/* Desktop: Button grid */} +
{SUPPORTED_LANGUAGES.map(opt => (
+ {/* Mobile: Custom dropdown */} +
+ {(() => { + const current = SUPPORTED_LANGUAGES.find(o => o.value === settings.language) || SUPPORTED_LANGUAGES[0] + return ( + + ) + })()} + {langOpen && ( +
+ {SUPPORTED_LANGUAGES.map(opt => { + const active = settings.language === opt.value + return ( + + ) + })} +
+ )} +
{/* Temperature */} diff --git a/client/src/components/shared/Tooltip.tsx b/client/src/components/shared/Tooltip.tsx new file mode 100644 index 00000000..bb9653f3 --- /dev/null +++ b/client/src/components/shared/Tooltip.tsx @@ -0,0 +1,98 @@ +import React, { useState, useRef, useEffect } from 'react' +import ReactDOM from 'react-dom' + +type Placement = 'top' | 'bottom' | 'left' | 'right' + +interface TooltipProps { + label: string + placement?: Placement + delay?: number + disabled?: boolean + children: React.ReactElement +} + +export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, children }: TooltipProps) { + const [open, setOpen] = useState(false) + const [coords, setCoords] = useState<{ top: number; left: number } | null>(null) + const triggerRef = useRef(null) + const tooltipRef = useRef(null) + const timerRef = useRef(null) + + const show = () => { + if (disabled || !label) return + if (timerRef.current) window.clearTimeout(timerRef.current) + timerRef.current = window.setTimeout(() => setOpen(true), delay) + } + const hide = () => { + if (timerRef.current) window.clearTimeout(timerRef.current) + setOpen(false) + } + + useEffect(() => () => { if (timerRef.current) window.clearTimeout(timerRef.current) }, []) + + useEffect(() => { + if (!open || !triggerRef.current) return + const r = triggerRef.current.getBoundingClientRect() + const tipW = tooltipRef.current?.offsetWidth ?? 0 + const tipH = tooltipRef.current?.offsetHeight ?? 0 + const gap = 6 + let top = 0, left = 0 + if (placement === 'top') { top = r.top - tipH - gap; left = r.left + r.width / 2 - tipW / 2 } + else if (placement === 'bottom') { top = r.bottom + gap; left = r.left + r.width / 2 - tipW / 2 } + else if (placement === 'left') { top = r.top + r.height / 2 - tipH / 2; left = r.left - tipW - gap } + else { top = r.top + r.height / 2 - tipH / 2; left = r.right + gap } + const pad = 6 + left = Math.max(pad, Math.min(left, window.innerWidth - tipW - pad)) + top = Math.max(pad, Math.min(top, window.innerHeight - tipH - pad)) + setCoords({ top, left }) + }, [open, placement, label]) + + const child = React.Children.only(children) + const trigger = React.cloneElement(child, { + ref: (node: HTMLElement | null) => { + triggerRef.current = node + const r = (child as any).ref + if (typeof r === 'function') r(node) + else if (r && typeof r === 'object') r.current = node + }, + onMouseEnter: (e: any) => { show(); child.props.onMouseEnter?.(e) }, + onMouseLeave: (e: any) => { hide(); child.props.onMouseLeave?.(e) }, + onFocus: (e: any) => { show(); child.props.onFocus?.(e) }, + onBlur: (e: any) => { hide(); child.props.onBlur?.(e) }, + }) + + return ( + <> + {trigger} + {open && ReactDOM.createPortal( +
+ {label} +
, + document.body, + )} + + ) +} + +export default Tooltip diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 9ae9a435..3ff2dfaa 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -148,7 +148,7 @@ const de: Record = { 'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen', 'settings.tabs.display': 'Anzeige', 'settings.tabs.map': 'Karte', - 'settings.tabs.notifications': 'Benachrichtigungen', + 'settings.tabs.notifications': 'Mitteilungen', 'settings.tabs.integrations': 'Integrationen', 'settings.tabs.account': 'Konto', 'settings.tabs.offline': 'Offline', @@ -182,7 +182,7 @@ const de: Record = { 'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten', 'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.', 'settings.blurBookingCodes': 'Buchungscodes verbergen', - 'settings.notifications': 'Benachrichtigungen', + 'settings.notifications': 'Mitteilungen', 'settings.notifyTripInvite': 'Trip-Einladungen', 'settings.notifyBookingChange': 'Buchungsänderungen', 'settings.notifyTripReminder': 'Trip-Erinnerungen', @@ -873,7 +873,7 @@ const de: Record = { // Trip Planner 'trip.tabs.plan': 'Karte', - 'trip.tabs.transports': 'Transporte', + 'trip.tabs.transports': 'Transport', 'trip.tabs.reservations': 'Buchungen', 'trip.tabs.reservationsShort': 'Buchung', 'trip.tabs.packing': 'Liste', @@ -908,6 +908,8 @@ const de: Record = { 'dayplan.cannotDropOnTimed': 'Orte können nicht zwischen zeitgebundene Einträge geschoben werden', 'dayplan.cannotBreakChronology': 'Die zeitliche Reihenfolge von Uhrzeiten und Buchungen darf nicht verletzt werden', 'dayplan.addNote': 'Notiz hinzufügen', + 'dayplan.expandAll': 'Alle Tage ausklappen', + 'dayplan.collapseAll': 'Alle Tage einklappen', 'dayplan.editNote': 'Notiz bearbeiten', 'dayplan.noteAdd': 'Notiz hinzufügen', 'dayplan.noteEdit': 'Notiz bearbeiten', @@ -932,7 +934,7 @@ const de: Record = { // Places Sidebar 'places.addPlace': 'Ort/Aktivität hinzufügen', - 'places.importFile': 'Datei importieren', + 'places.importFile': 'Dateimport', 'places.sidebarDrop': 'Ablegen zum Importieren', 'places.importFileHint': '.gpx-, .kml- oder .kmz-Dateien aus Tools wie Google My Maps, Google Earth oder einem GPS-Tracker importieren.', 'places.importFileDropHere': 'Datei auswählen oder hierher ziehen und ablegen', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 9f12c2a7..5e2e593e 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -965,6 +965,8 @@ const en: Record = { 'dayplan.cannotDropOnTimed': 'Items cannot be placed between time-bound entries', 'dayplan.cannotBreakChronology': 'This would break the chronological order of timed items and bookings', 'dayplan.addNote': 'Add Note', + 'dayplan.expandAll': 'Expand all days', + 'dayplan.collapseAll': 'Collapse all days', 'dayplan.editNote': 'Edit Note', 'dayplan.noteAdd': 'Add Note', 'dayplan.noteEdit': 'Edit Note', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 9b976734..31f99000 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -903,7 +903,7 @@ export default function AdminPage(): React.ReactElement {