mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
mobile settings polish
- settings: hide color-mode icons on mobile, shorten "Automatisch" -> "Auto" - settings: language picker as custom dropdown on mobile - admin permissions: reset button icon-only on mobile, sized to match save - admin places toggles: add flex-shrink-0 + row gap so switches don't collapse - de: settings.notifications label "Benachrichtigungen" -> "Mitteilungen"
This commit is contained in:
@@ -107,10 +107,12 @@ export default function PermissionsPanel(): React.ReactElement {
|
|||||||
<button
|
<button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
title={t('perm.resetDefaults')}
|
||||||
|
aria-label={t('perm.resetDefaults')}
|
||||||
|
className="flex items-center justify-center gap-1.5 px-0 sm:px-3 py-1.5 text-sm w-8 sm:w-auto border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
{t('perm.resetDefaults')}
|
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { Palette, Sun, Moon, Monitor } from 'lucide-react'
|
import { Palette, Sun, Moon, Monitor, ChevronDown, Check } from 'lucide-react'
|
||||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
|
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
@@ -10,6 +10,17 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
||||||
|
const [langOpen, setLangOpen] = useState(false)
|
||||||
|
const langDropdownRef = useRef<HTMLDivElement | null>(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(() => {
|
useEffect(() => {
|
||||||
setTempUnit(settings.temperature_unit || 'celsius')
|
setTempUnit(settings.temperature_unit || 'celsius')
|
||||||
@@ -46,8 +57,13 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<opt.icon size={16} />
|
<span className="hidden sm:inline-flex"><opt.icon size={16} /></span>
|
||||||
{opt.label}
|
{opt.value === 'auto' ? (
|
||||||
|
<>
|
||||||
|
<span className="hidden sm:inline">{opt.label}</span>
|
||||||
|
<span className="sm:hidden">Auto</span>
|
||||||
|
</>
|
||||||
|
) : opt.label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -57,7 +73,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
{/* Language */}
|
{/* Language */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
|
||||||
<div className="flex flex-wrap gap-3">
|
{/* Desktop: Button grid */}
|
||||||
|
<div className="hidden sm:flex flex-wrap gap-3">
|
||||||
{SUPPORTED_LANGUAGES.map(opt => (
|
{SUPPORTED_LANGUAGES.map(opt => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
@@ -79,6 +96,60 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Mobile: Custom dropdown */}
|
||||||
|
<div ref={langDropdownRef} className="sm:hidden" style={{ position: 'relative' }}>
|
||||||
|
{(() => {
|
||||||
|
const current = SUPPORTED_LANGUAGES.find(o => o.value === settings.language) || SUPPORTED_LANGUAGES[0]
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLangOpen(v => !v)}
|
||||||
|
style={{
|
||||||
|
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '10px 14px', borderRadius: 10,
|
||||||
|
border: '2px solid var(--border-primary)',
|
||||||
|
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||||
|
fontSize: 14, fontWeight: 500, fontFamily: 'inherit', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{current?.label}</span>
|
||||||
|
<ChevronDown size={14} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: langOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{langOpen && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, zIndex: 50,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.15)', padding: 4, maxHeight: 280, overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{SUPPORTED_LANGUAGES.map(opt => {
|
||||||
|
const active = settings.language === opt.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
setLangOpen(false)
|
||||||
|
try { await updateSetting('language', opt.value) }
|
||||||
|
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||||
|
padding: '9px 12px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||||
|
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
fontFamily: 'inherit', fontSize: 14, color: 'var(--text-primary)',
|
||||||
|
textAlign: 'left', fontWeight: active ? 600 : 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 1 }}>{opt.label}</span>
|
||||||
|
{active && <Check size={14} strokeWidth={2.5} color="var(--accent)" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Temperature */}
|
{/* Temperature */}
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen',
|
'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen',
|
||||||
'settings.tabs.display': 'Anzeige',
|
'settings.tabs.display': 'Anzeige',
|
||||||
'settings.tabs.map': 'Karte',
|
'settings.tabs.map': 'Karte',
|
||||||
'settings.tabs.notifications': 'Benachrichtigungen',
|
'settings.tabs.notifications': 'Mitteilungen',
|
||||||
'settings.tabs.integrations': 'Integrationen',
|
'settings.tabs.integrations': 'Integrationen',
|
||||||
'settings.tabs.account': 'Konto',
|
'settings.tabs.account': 'Konto',
|
||||||
'settings.tabs.offline': 'Offline',
|
'settings.tabs.offline': 'Offline',
|
||||||
@@ -182,7 +182,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten',
|
'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten',
|
||||||
'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.',
|
'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.',
|
||||||
'settings.blurBookingCodes': 'Buchungscodes verbergen',
|
'settings.blurBookingCodes': 'Buchungscodes verbergen',
|
||||||
'settings.notifications': 'Benachrichtigungen',
|
'settings.notifications': 'Mitteilungen',
|
||||||
'settings.notifyTripInvite': 'Trip-Einladungen',
|
'settings.notifyTripInvite': 'Trip-Einladungen',
|
||||||
'settings.notifyBookingChange': 'Buchungsänderungen',
|
'settings.notifyBookingChange': 'Buchungsänderungen',
|
||||||
'settings.notifyTripReminder': 'Trip-Erinnerungen',
|
'settings.notifyTripReminder': 'Trip-Erinnerungen',
|
||||||
|
|||||||
@@ -903,7 +903,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleAuthSetting('oidc_registration', !oidcRegistration, setOidcRegistration)}
|
onClick={() => handleToggleAuthSetting('oidc_registration', !oidcRegistration, setOidcRegistration)}
|
||||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||||
style={{ background: oidcRegistration ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
style={{ background: oidcRegistration ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -930,7 +930,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleToggleRequireMfa(!requireMfa)}
|
onClick={() => handleToggleRequireMfa(!requireMfa)}
|
||||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||||
style={{ background: requireMfa ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
style={{ background: requireMfa ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -1036,7 +1036,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Place Photos Toggle */}
|
{/* Place Photos Toggle */}
|
||||||
<div className="flex items-center justify-between py-3 border-t border-slate-100">
|
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-slate-700">{t('admin.placesPhotos.title')}</p>
|
<p className="text-sm font-medium text-slate-700">{t('admin.placesPhotos.title')}</p>
|
||||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesPhotos.subtitle')}</p>
|
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesPhotos.subtitle')}</p>
|
||||||
@@ -1048,7 +1048,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
setPlacesPhotosEnabled(next)
|
setPlacesPhotosEnabled(next)
|
||||||
try { await adminApi.updatePlacesPhotos(next) } catch { setPlacesPhotosEnabledState(!next); setPlacesPhotosEnabled(!next) }
|
try { await adminApi.updatePlacesPhotos(next) } catch { setPlacesPhotosEnabledState(!next); setPlacesPhotosEnabled(!next) }
|
||||||
}}
|
}}
|
||||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||||
style={{ background: placesPhotosEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
style={{ background: placesPhotosEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
>
|
>
|
||||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesPhotosEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesPhotosEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
@@ -1056,7 +1056,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Place Autocomplete Toggle */}
|
{/* Place Autocomplete Toggle */}
|
||||||
<div className="flex items-center justify-between py-3 border-t border-slate-100">
|
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-slate-700">{t('admin.placesAutocomplete.title')}</p>
|
<p className="text-sm font-medium text-slate-700">{t('admin.placesAutocomplete.title')}</p>
|
||||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesAutocomplete.subtitle')}</p>
|
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesAutocomplete.subtitle')}</p>
|
||||||
@@ -1068,7 +1068,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
setPlacesAutocompleteEnabled(next)
|
setPlacesAutocompleteEnabled(next)
|
||||||
try { await adminApi.updatePlacesAutocomplete(next) } catch { setPlacesAutocompleteEnabledState(!next); setPlacesAutocompleteEnabled(!next) }
|
try { await adminApi.updatePlacesAutocomplete(next) } catch { setPlacesAutocompleteEnabledState(!next); setPlacesAutocompleteEnabled(!next) }
|
||||||
}}
|
}}
|
||||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||||
style={{ background: placesAutocompleteEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
style={{ background: placesAutocompleteEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
>
|
>
|
||||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesAutocompleteEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesAutocompleteEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
@@ -1076,7 +1076,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Place Details Toggle */}
|
{/* Place Details Toggle */}
|
||||||
<div className="flex items-center justify-between py-3 border-t border-slate-100">
|
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-slate-700">{t('admin.placesDetails.title')}</p>
|
<p className="text-sm font-medium text-slate-700">{t('admin.placesDetails.title')}</p>
|
||||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesDetails.subtitle')}</p>
|
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesDetails.subtitle')}</p>
|
||||||
@@ -1088,7 +1088,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
setPlacesDetailsEnabled(next)
|
setPlacesDetailsEnabled(next)
|
||||||
try { await adminApi.updatePlacesDetails(next) } catch { setPlacesDetailsEnabledState(!next); setPlacesDetailsEnabled(!next) }
|
try { await adminApi.updatePlacesDetails(next) } catch { setPlacesDetailsEnabledState(!next); setPlacesDetailsEnabled(!next) }
|
||||||
}}
|
}}
|
||||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||||
style={{ background: placesDetailsEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
style={{ background: placesDetailsEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
>
|
>
|
||||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesDetailsEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesDetailsEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
@@ -1328,7 +1328,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
|
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
|
||||||
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
|
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
|
||||||
}}
|
}}
|
||||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||||
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
style={{ transform: smtpValues.smtp_skip_tls_verify === 'true' ? 'translateX(20px)' : 'translateX(0)' }} />
|
style={{ transform: smtpValues.smtp_skip_tls_verify === 'true' ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
|
|||||||
Reference in New Issue
Block a user