refactoring: TypeScript migration, security fixes,

This commit is contained in:
Maurice
2026-03-27 18:40:18 +01:00
parent 510475a46f
commit 8396a75223
150 changed files with 8116 additions and 8467 deletions
@@ -4,6 +4,7 @@ import { adminApi, authApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useTranslation } from '../i18n'
import { getApiErrorMessage } from '../types'
import Navbar from '../components/Layout/Navbar'
import Modal from '../components/shared/Modal'
import { useToast } from '../components/shared/Toast'
@@ -14,7 +15,41 @@ import AddonManager from '../components/Admin/AddonManager'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun } from 'lucide-react'
import CustomSelect from '../components/shared/CustomSelect'
export default function AdminPage() {
interface AdminUser {
id: number
username: string
email: string
role: 'admin' | 'user'
created_at: string
last_login?: string | null
online?: boolean
oidc_issuer?: string | null
}
interface AdminStats {
totalUsers: number
totalTrips: number
totalPlaces: number
totalFiles: number
}
interface OidcConfig {
issuer: string
client_id: string
client_secret: string
client_secret_set: boolean
display_name: string
}
interface UpdateInfo {
update_available: boolean
latest: string
current: string
release_url?: string
is_docker?: boolean
}
export default function AdminPage(): React.ReactElement {
const { demoMode } = useAuthStore()
const { t, locale } = useTranslation()
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
@@ -27,39 +62,39 @@ export default function AdminPage() {
{ id: 'github', label: t('admin.tabs.github') },
]
const [activeTab, setActiveTab] = useState('users')
const [users, setUsers] = useState([])
const [stats, setStats] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [editingUser, setEditingUser] = useState(null)
const [editForm, setEditForm] = useState({ username: '', email: '', role: 'user', password: '' })
const [showCreateUser, setShowCreateUser] = useState(false)
const [createForm, setCreateForm] = useState({ username: '', email: '', password: '', role: 'user' })
const [activeTab, setActiveTab] = useState<string>('users')
const [users, setUsers] = useState<AdminUser[]>([])
const [stats, setStats] = useState<AdminStats | null>(null)
const [isLoading, setIsLoading] = useState<boolean>(true)
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
const [editForm, setEditForm] = useState<{ username: string; email: string; role: string; password: string }>({ username: '', email: '', role: 'user', password: '' })
const [showCreateUser, setShowCreateUser] = useState<boolean>(false)
const [createForm, setCreateForm] = useState<{ username: string; email: string; password: string; role: string }>({ username: '', email: '', password: '', role: 'user' })
// OIDC config
const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', display_name: '' })
const [savingOidc, setSavingOidc] = useState(false)
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '' })
const [savingOidc, setSavingOidc] = useState<boolean>(false)
// Registration toggle
const [allowRegistration, setAllowRegistration] = useState(true)
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
// File types
const [allowedFileTypes, setAllowedFileTypes] = useState('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
const [savingFileTypes, setSavingFileTypes] = useState(false)
const [allowedFileTypes, setAllowedFileTypes] = useState<string>('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
const [savingFileTypes, setSavingFileTypes] = useState<boolean>(false)
// API Keys
const [mapsKey, setMapsKey] = useState('')
const [weatherKey, setWeatherKey] = useState('')
const [showKeys, setShowKeys] = useState({})
const [savingKeys, setSavingKeys] = useState(false)
const [validating, setValidating] = useState({})
const [validation, setValidation] = useState({})
const [mapsKey, setMapsKey] = useState<string>('')
const [weatherKey, setWeatherKey] = useState<string>('')
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({})
const [savingKeys, setSavingKeys] = useState<boolean>(false)
const [validating, setValidating] = useState<Record<string, boolean>>({})
const [validation, setValidation] = useState<Record<string, boolean | undefined>>({})
// Version check & update
const [updateInfo, setUpdateInfo] = useState(null)
const [showUpdateModal, setShowUpdateModal] = useState(false)
const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState(null) // 'success' | 'error'
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
const [updating, setUpdating] = useState<boolean>(false)
const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null)
const { user: currentUser, updateApiKeys } = useAuthStore()
const navigate = useNavigate()
@@ -84,7 +119,7 @@ export default function AdminPage() {
])
setUsers(usersData.users)
setStats(statsData)
} catch (err) {
} catch (err: unknown) {
toast.error(t('admin.toast.loadError'))
} finally {
setIsLoading(false)
@@ -96,7 +131,7 @@ export default function AdminPage() {
const config = await authApi.getAppConfig()
setAllowRegistration(config.allow_registration)
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
} catch (err) {
} catch (err: unknown) {
// ignore
}
}
@@ -106,7 +141,7 @@ export default function AdminPage() {
const data = await authApi.getSettings()
setMapsKey(data.settings?.maps_api_key || '')
setWeatherKey(data.settings?.openweather_api_key || '')
} catch (err) {
} catch (err: unknown) {
// ignore
}
}
@@ -135,9 +170,9 @@ export default function AdminPage() {
setAllowRegistration(value)
try {
await authApi.updateAppSettings({ allow_registration: value })
} catch (err) {
} catch (err: unknown) {
setAllowRegistration(!value)
toast.error(err.response?.data?.error || t('common.error'))
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
@@ -153,8 +188,8 @@ export default function AdminPage() {
openweather_api_key: weatherKey,
})
toast.success(t('admin.keySaved'))
} catch (err) {
toast.error(err.message)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Unknown error')
} finally {
setSavingKeys(false)
}
@@ -167,7 +202,7 @@ export default function AdminPage() {
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
const result = await authApi.validateKeys()
setValidation(result)
} catch (err) {
} catch (err: unknown) {
toast.error(t('common.error'))
} finally {
setValidating({})
@@ -181,7 +216,7 @@ export default function AdminPage() {
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
const result = await authApi.validateKeys()
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
} catch (err) {
} catch (err: unknown) {
toast.error(t('common.error'))
} finally {
setValidating(prev => ({ ...prev, [keyType]: false }))
@@ -199,8 +234,8 @@ export default function AdminPage() {
setShowCreateUser(false)
setCreateForm({ username: '', email: '', password: '', role: 'user' })
toast.success(t('admin.toast.userCreated'))
} catch (err) {
toast.error(err.response?.data?.error || t('admin.toast.createError'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.toast.createError')))
}
}
@@ -221,8 +256,8 @@ export default function AdminPage() {
setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u))
setEditingUser(null)
toast.success(t('admin.toast.userUpdated'))
} catch (err) {
toast.error(err.response?.data?.error || t('admin.toast.updateError'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.toast.updateError')))
}
}
@@ -236,8 +271,8 @@ export default function AdminPage() {
await adminApi.deleteUser(user.id)
setUsers(prev => prev.filter(u => u.id !== user.id))
toast.success(t('admin.toast.userDeleted'))
} catch (err) {
toast.error(err.response?.data?.error || t('admin.toast.deleteError'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.toast.deleteError')))
}
}
@@ -676,6 +711,7 @@ export default function AdminPage() {
type="password"
value={oidcConfig.client_secret}
onChange={e => setOidcConfig(c => ({ ...c, client_secret: e.target.value }))}
placeholder={oidcConfig.client_secret_set ? '••••••••' : ''}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
@@ -683,10 +719,12 @@ export default function AdminPage() {
onClick={async () => {
setSavingOidc(true)
try {
await adminApi.updateOidc(oidcConfig)
const payload = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name }
if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret
await adminApi.updateOidc(payload)
toast.success(t('admin.oidcSaved'))
} catch (err) {
toast.error(err.response?.data?.error || t('common.error'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setSavingOidc(false)
}
@@ -6,9 +6,43 @@ import Navbar from '../components/Layout/Navbar'
import apiClient from '../api/client'
import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X } from 'lucide-react'
import L from 'leaflet'
import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types'
// Convert country code to flag emoji
function MobileStats({ data, stats, countries, resolveName, t, dark }) {
interface AtlasCountry {
code: string
tripCount: number
placeCount: number
firstVisit?: string | null
lastVisit?: string | null
}
interface AtlasStats {
totalTrips: number
totalPlaces: number
totalCountries: number
totalDays: number
totalCities?: number
}
interface AtlasData {
countries: AtlasCountry[]
stats: AtlasStats
mostVisited?: AtlasCountry | null
continents?: Record<string, number>
lastTrip?: { id: number; title: string; countryCode?: string } | null
nextTrip?: { id: number; title: string; countryCode?: string } | null
streak?: number
firstYear?: number
tripsThisYear?: number
}
interface CountryDetail {
places: AtlasPlace[]
trips: { id: number; title: string }[]
}
function MobileStats({ data, stats, countries, resolveName, t, dark }: { data: AtlasData | null; stats: AtlasStats; countries: AtlasCountry[]; resolveName: (code: string) => string; t: TranslationFn; dark: boolean }): React.ReactElement {
const tp = dark ? '#f1f5f9' : '#0f172a'
const tf = dark ? '#475569' : '#94a3b8'
const { continents, lastTrip, nextTrip, streak, firstYear, tripsThisYear } = data || {}
@@ -57,40 +91,40 @@ function MobileStats({ data, stats, countries, resolveName, t, dark }) {
)
}
function countryCodeToFlag(code) {
function countryCodeToFlag(code: string): string {
if (!code || code.length !== 2) return ''
return String.fromCodePoint(...[...code.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65))
}
function useCountryNames(language) {
const [resolver, setResolver] = useState(() => (code) => code)
function useCountryNames(language: string): (code: string) => string {
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
useEffect(() => {
try {
const dn = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' })
setResolver(() => (code) => { try { return dn.of(code) } catch { return code } })
setResolver(() => (code: string) => { try { return dn.of(code) || code } catch { return code } })
} catch { /* */ }
}, [language])
return resolver
}
// Map visited country codes to ISO-3166 alpha3 (GeoJSON uses alpha3)
const A2_TO_A3 = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BR":"BRA","BE":"BEL","BG":"BGR","CA":"CAN","CL":"CHL","CN":"CHN","CO":"COL","HR":"HRV","CZ":"CZE","DK":"DNK","EG":"EGY","EE":"EST","FI":"FIN","FR":"FRA","DE":"DEU","GR":"GRC","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JP":"JPN","KE":"KEN","KR":"KOR","LV":"LVA","LT":"LTU","LU":"LUX","MY":"MYS","MX":"MEX","MA":"MAR","NL":"NLD","NZ":"NZL","NO":"NOR","PK":"PAK","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","RO":"ROU","RU":"RUS","SA":"SAU","RS":"SRB","SK":"SVK","SI":"SVN","ZA":"ZAF","ES":"ESP","SE":"SWE","CH":"CHE","TH":"THA","TR":"TUR","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","VN":"VNM","NG":"NGA"}
const A2_TO_A3: Record<string, string> = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BR":"BRA","BE":"BEL","BG":"BGR","CA":"CAN","CL":"CHL","CN":"CHN","CO":"COL","HR":"HRV","CZ":"CZE","DK":"DNK","EG":"EGY","EE":"EST","FI":"FIN","FR":"FRA","DE":"DEU","GR":"GRC","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JP":"JPN","KE":"KEN","KR":"KOR","LV":"LVA","LT":"LTU","LU":"LUX","MY":"MYS","MX":"MEX","MA":"MAR","NL":"NLD","NZ":"NZL","NO":"NOR","PK":"PAK","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","RO":"ROU","RU":"RUS","SA":"SAU","RS":"SRB","SK":"SVK","SI":"SVN","ZA":"ZAF","ES":"ESP","SE":"SWE","CH":"CHE","TH":"THA","TR":"TUR","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","VN":"VNM","NG":"NGA"}
export default function AtlasPage() {
export default function AtlasPage(): React.ReactElement {
const { t, language } = useTranslation()
const { settings } = useSettingsStore()
const navigate = useNavigate()
const resolveName = useCountryNames(language)
const dm = settings.dark_mode
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const mapRef = useRef(null)
const mapInstance = useRef(null)
const geoLayerRef = useRef(null)
const glareRef = useRef(null)
const borderGlareRef = useRef(null)
const panelRef = useRef(null)
const mapRef = useRef<HTMLDivElement>(null)
const mapInstance = useRef<L.Map | null>(null)
const geoLayerRef = useRef<L.GeoJSON | null>(null)
const glareRef = useRef<HTMLDivElement>(null)
const borderGlareRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const handlePanelMouseMove = (e) => {
const handlePanelMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
const rect = panelRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
@@ -108,13 +142,13 @@ export default function AtlasPage() {
if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
}
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [sidebarOpen, setSidebarOpen] = useState(true)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
const [selectedCountry, setSelectedCountry] = useState(null)
const [countryDetail, setCountryDetail] = useState(null)
const [geoData, setGeoData] = useState(null)
const [data, setData] = useState<AtlasData | null>(null)
const [loading, setLoading] = useState<boolean>(true)
const [sidebarOpen, setSidebarOpen] = useState<boolean>(true)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<boolean>(false)
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
// Load atlas data
useEffect(() => {
@@ -256,7 +290,7 @@ export default function AtlasPage() {
}).addTo(mapInstance.current)
}, [geoData, data, dark])
const loadCountryDetail = async (code) => {
const loadCountryDetail = async (code: string): Promise<void> => {
setSelectedCountry(code)
try {
const r = await apiClient.get(`/addons/atlas/country/${code}`)
@@ -345,7 +379,20 @@ export default function AtlasPage() {
)
}
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, t, dark }) {
interface SidebarContentProps {
data: AtlasData | null
stats: AtlasStats
countries: AtlasCountry[]
selectedCountry: string | null
countryDetail: CountryDetail | null
resolveName: (code: string) => string
onCountryClick: (code: string) => void
onTripClick: (id: number) => void
t: TranslationFn
dark: boolean
}
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, t, dark }: SidebarContentProps): React.ReactElement {
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
const tp = dark ? '#f1f5f9' : '#0f172a'
const tm = dark ? '#94a3b8' : '#64748b'
@@ -4,6 +4,7 @@ import { tripsApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useTranslation } from '../i18n'
import { getApiErrorMessage } from '../types'
import Navbar from '../components/Layout/Navbar'
import DemoBanner from '../components/Layout/DemoBanner'
import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
@@ -15,16 +16,33 @@ import {
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
} from 'lucide-react'
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
interface DashboardTrip {
id: number
title: string
description?: string | null
start_date?: string | null
end_date?: string | null
cover_image?: string | null
is_archived?: boolean
is_owner?: boolean
owner_username?: string
day_count?: number
place_count?: number
[key: string]: string | number | boolean | null | undefined
}
function daysUntil(dateStr) {
const font: React.CSSProperties = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
const MS_PER_DAY = 86400000
function daysUntil(dateStr: string | null | undefined): number | null {
if (!dateStr) return null
const today = new Date(); today.setHours(0, 0, 0, 0)
const d = new Date(dateStr + 'T00:00:00'); d.setHours(0, 0, 0, 0)
return Math.round((d - today) / 86400000)
return Math.round((d - today) / MS_PER_DAY)
}
function getTripStatus(trip) {
function getTripStatus(trip: DashboardTrip): string | null {
const today = new Date().toISOString().split('T')[0]
if (trip.start_date && trip.end_date && trip.start_date <= today && trip.end_date >= today) return 'ongoing'
const until = daysUntil(trip.start_date)
@@ -35,17 +53,17 @@ function getTripStatus(trip) {
return 'past'
}
function formatDate(dateStr, locale = 'de-DE') {
function formatDate(dateStr: string | null | undefined, locale: string = 'de-DE'): string | null {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
}
function formatDateShort(dateStr, locale = 'de-DE') {
function formatDateShort(dateStr: string | null | undefined, locale: string = 'de-DE'): string | null {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
}
function sortTrips(trips) {
function sortTrips(trips: DashboardTrip[]): DashboardTrip[] {
const today = new Date().toISOString().split('T')[0]
function rank(t) {
if (t.start_date && t.end_date && t.start_date <= today && t.end_date >= today) return 0 // ongoing
@@ -72,15 +90,23 @@ const GRADIENTS = [
'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
'linear-gradient(135deg, #96fbc4 0%, #f9f586 100%)',
]
function tripGradient(id) { return GRADIENTS[id % GRADIENTS.length] }
function tripGradient(id: number): string { return GRADIENTS[id % GRADIENTS.length] }
// ── Liquid Glass hover effect ────────────────────────────────────────────────
function LiquidGlass({ children, dark, style, className = '', onClick }) {
const ref = useRef(null)
const glareRef = useRef(null)
const borderRef = useRef(null)
interface LiquidGlassProps {
children: React.ReactNode
dark: boolean
style?: React.CSSProperties
className?: string
onClick?: () => void
}
const onMove = (e) => {
function LiquidGlass({ children, dark, style, className = '', onClick }: LiquidGlassProps): React.ReactElement {
const ref = useRef<HTMLDivElement>(null)
const glareRef = useRef<HTMLDivElement>(null)
const borderRef = useRef<HTMLDivElement>(null)
const onMove = (e: React.MouseEvent<HTMLDivElement>): void => {
if (!ref.current || !glareRef.current || !borderRef.current) return
const rect = ref.current.getBoundingClientRect()
const x = e.clientX - rect.left
@@ -109,7 +135,18 @@ function LiquidGlass({ children, dark, style, className = '', onClick }) {
}
// ── Spotlight Card (next upcoming trip) ─────────────────────────────────────
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }) {
interface TripCardProps {
trip: DashboardTrip
onEdit: (trip: DashboardTrip) => void
onDelete: (trip: DashboardTrip) => void
onArchive: (id: number) => void
onClick: (trip: DashboardTrip) => void
t: (key: string, params?: Record<string, string | number | null>) => string
locale: string
dark?: boolean
}
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
const status = getTripStatus(trip)
const coverBg = trip.cover_image
@@ -190,7 +227,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
}
// ── Regular Trip Card ────────────────────────────────────────────────────────
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
const status = getTripStatus(trip)
const [hovered, setHovered] = useState(false)
@@ -279,7 +316,17 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
}
// ── Archived Trip Row ────────────────────────────────────────────────────────
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }) {
interface ArchivedRowProps {
trip: DashboardTrip
onEdit: (trip: DashboardTrip) => void
onUnarchive: (id: number) => void
onDelete: (trip: DashboardTrip) => void
onClick: (trip: DashboardTrip) => void
t: (key: string, params?: Record<string, string | number | null>) => string
locale: string
}
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement {
return (
<div onClick={() => onClick(trip)} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
@@ -322,7 +369,7 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function Stat({ value, label }) {
function Stat({ value, label }: { value: number | string; label: string }): React.ReactElement {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{value}</span>
@@ -331,7 +378,7 @@ function Stat({ value, label }) {
)
}
function CardAction({ onClick, icon, label, danger }) {
function CardAction({ onClick, icon, label, danger }: { onClick: () => void; icon: React.ReactNode; label: string; danger?: boolean }): React.ReactElement {
return (
<button onClick={onClick} style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8,
@@ -345,7 +392,7 @@ function CardAction({ onClick, icon, label, danger }) {
)
}
function IconBtn({ onClick, title, danger, loading, children }) {
function IconBtn({ onClick, title, danger, loading, children }: { onClick: () => void; title: string; danger?: boolean; loading?: boolean; children: React.ReactNode }): React.ReactElement {
return (
<button onClick={onClick} title={title} disabled={loading} style={{
width: 32, height: 32, borderRadius: 99, border: '1px solid rgba(255,255,255,0.25)',
@@ -361,7 +408,7 @@ function IconBtn({ onClick, title, danger, loading, children }) {
}
// ── Skeleton ─────────────────────────────────────────────────────────────────
function SkeletonCard() {
function SkeletonCard(): React.ReactElement {
return (
<div style={{ background: 'white', borderRadius: 16, overflow: 'hidden', border: '1px solid #f3f4f6' }}>
<div style={{ height: 120, background: '#f3f4f6', animation: 'pulse 1.5s ease-in-out infinite' }} />
@@ -374,14 +421,14 @@ function SkeletonCard() {
}
// ── Main Page ────────────────────────────────────────────────────────────────
export default function DashboardPage() {
const [trips, setTrips] = useState([])
const [archivedTrips, setArchivedTrips] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editingTrip, setEditingTrip] = useState(null)
const [showArchived, setShowArchived] = useState(false)
const [showWidgetSettings, setShowWidgetSettings] = useState(false)
export default function DashboardPage(): React.ReactElement {
const [trips, setTrips] = useState<DashboardTrip[]>([])
const [archivedTrips, setArchivedTrips] = useState<DashboardTrip[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
const [showForm, setShowForm] = useState<boolean>(false)
const [editingTrip, setEditingTrip] = useState<DashboardTrip | null>(null)
const [showArchived, setShowArchived] = useState<boolean>(false)
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile'>(false)
const navigate = useNavigate()
const toast = useToast()
@@ -427,8 +474,8 @@ export default function DashboardPage() {
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.created'))
return data
} catch (err) {
throw new Error(err.response?.data?.error || t('dashboard.toast.createError'))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, t('dashboard.toast.createError')))
}
}
@@ -437,8 +484,8 @@ export default function DashboardPage() {
const data = await tripsApi.update(editingTrip.id, tripData)
setTrips(prev => sortTrips(prev.map(t => t.id === editingTrip.id ? data.trip : t)))
toast.success(t('dashboard.toast.updated'))
} catch (err) {
throw new Error(err.response?.data?.error || t('dashboard.toast.updateError'))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, t('dashboard.toast.updateError')))
}
}
@@ -476,8 +523,8 @@ export default function DashboardPage() {
}
}
const handleCoverUpdate = (tripId, coverImage) => {
const update = t => t.id === tripId ? { ...t, cover_image: coverImage } : t
const handleCoverUpdate = (tripId: number, coverImage: string | null): void => {
const update = (t: DashboardTrip) => t.id === tripId ? { ...t, cover_image: coverImage } : t
setTrips(prev => prev.map(update))
setArchivedTrips(prev => prev.map(update))
}
@@ -6,23 +6,24 @@ import Navbar from '../components/Layout/Navbar'
import FileManager from '../components/Files/FileManager'
import { ArrowLeft } from 'lucide-react'
import { useTranslation } from '../i18n'
import type { Trip, Place, TripFile } from '../types'
export default function FilesPage() {
export default function FilesPage(): React.ReactElement {
const { t } = useTranslation()
const { id: tripId } = useParams()
const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate()
const tripStore = useTripStore()
const [trip, setTrip] = useState(null)
const [places, setPlaces] = useState([])
const [files, setFiles] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [trip, setTrip] = useState<Trip | null>(null)
const [places, setPlaces] = useState<Place[]>([])
const [files, setFiles] = useState<TripFile[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
loadData()
}, [tripId])
const loadData = async () => {
const loadData = async (): Promise<void> => {
setIsLoading(true)
try {
const [tripData, placesData] = await Promise.all([
@@ -32,7 +33,7 @@ export default function FilesPage() {
setTrip(tripData.trip)
setPlaces(placesData.places)
await tripStore.loadFiles(tripId)
} catch (err) {
} catch (err: unknown) {
navigate('/dashboard')
} finally {
setIsLoading(false)
@@ -43,11 +44,11 @@ export default function FilesPage() {
setFiles(tripStore.files)
}, [tripStore.files])
const handleUpload = async (formData) => {
const handleUpload = async (formData: FormData): Promise<void> => {
await tripStore.addFile(tripId, formData)
}
const handleDelete = async (fileId) => {
const handleDelete = async (fileId: number): Promise<void> => {
await tripStore.deleteFile(tripId, fileId)
}
@@ -61,7 +62,7 @@ export default function FilesPage() {
return (
<div className="min-h-screen bg-slate-50">
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
<Navbar tripTitle={trip?.name} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-5xl mx-auto px-4 py-6">
@@ -78,7 +79,7 @@ export default function FilesPage() {
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Dateien & Dokumente</h1>
<p className="text-gray-500 text-sm">{files.length} Dateien für {trip?.title}</p>
<p className="text-gray-500 text-sm">{files.length} Dateien für {trip?.name}</p>
</div>
</div>
@@ -6,44 +6,58 @@ import { useTranslation } from '../i18n'
import { authApi } from '../api/client'
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield } from 'lucide-react'
export default function LoginPage() {
interface AppConfig {
has_users: boolean
allow_registration: boolean
demo_mode: boolean
oidc_configured: boolean
oidc_display_name?: string
}
export default function LoginPage(): React.ReactElement {
const { t, language } = useTranslation()
const [mode, setMode] = useState('login') // 'login' | 'register'
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [appConfig, setAppConfig] = useState(null)
const [mode, setMode] = useState<'login' | 'register'>('login')
const [username, setUsername] = useState<string>('')
const [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>('')
const [showPassword, setShowPassword] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
const { login, register, demoLogin } = useAuthStore()
const { setLanguageLocal } = useSettingsStore()
const navigate = useNavigate()
useEffect(() => {
authApi.getAppConfig?.().catch(() => null).then(config => {
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
if (config) {
setAppConfig(config)
if (!config.has_users) setMode('register')
}
})
// Handle OIDC callback token (via URL fragment to avoid logging)
const hash = window.location.hash.substring(1)
const hashParams = new URLSearchParams(hash)
const token = hashParams.get('token')
// Handle OIDC callback via short-lived auth code (secure exchange)
const params = new URLSearchParams(window.location.search)
const oidcCode = params.get('oidc_code')
const oidcError = params.get('oidc_error')
if (token) {
localStorage.setItem('auth_token', token)
if (oidcCode) {
window.history.replaceState({}, '', '/login')
login.__fromOidc = true
navigate('/dashboard')
window.location.reload()
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode))
.then(r => r.json())
.then(data => {
if (data.token) {
localStorage.setItem('auth_token', data.token)
navigate('/dashboard')
window.location.reload()
} else {
setError(data.error || 'OIDC login failed')
}
})
.catch(() => setError('OIDC login failed'))
}
if (oidcError) {
const errorMessages = {
const errorMessages: Record<string, string> = {
registration_disabled: t('login.oidc.registrationDisabled'),
no_email: t('login.oidc.noEmail'),
token_failed: t('login.oidc.tokenFailed'),
@@ -54,23 +68,23 @@ export default function LoginPage() {
}
}, [])
const handleDemoLogin = async () => {
const handleDemoLogin = async (): Promise<void> => {
setError('')
setIsLoading(true)
try {
await demoLogin()
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
} catch (err) {
setError(err.message || t('login.demoFailed'))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('login.demoFailed'))
} finally {
setIsLoading(false)
}
}
const [showTakeoff, setShowTakeoff] = useState(false)
const [showTakeoff, setShowTakeoff] = useState<boolean>(false)
const handleSubmit = async (e) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
setError('')
setIsLoading(true)
@@ -84,15 +98,15 @@ export default function LoginPage() {
}
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
} catch (err) {
setError(err.message || t('login.error'))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('login.error'))
setIsLoading(false)
}
}
const showRegisterOption = appConfig?.allow_registration || !appConfig?.has_users
const inputBase = {
const inputBase: React.CSSProperties = {
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
borderRadius: 12, fontSize: 14, fontFamily: 'inherit', outline: 'none',
color: '#111827', background: 'white', boxSizing: 'border-box', transition: 'border-color 0.15s',
@@ -264,8 +278,8 @@ export default function LoginPage() {
cursor: 'pointer', fontFamily: 'inherit',
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,0,0,0.1)'}
onMouseLeave={e => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.1)'}
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
>
<Globe size={14} />
{language === 'en' ? 'EN' : 'DE'}
@@ -392,8 +406,8 @@ export default function LoginPage() {
{ Icon: Route, label: t('login.features.routes'), desc: t('login.features.routesDesc') },
].map(({ Icon, label, desc }) => (
<div key={label} style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 14, padding: '14px 12px', border: '1px solid rgba(255,255,255,0.06)', textAlign: 'left', transition: 'all 0.2s' }}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)' }}>
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)' }}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)' }}>
<Icon size={17} style={{ color: 'rgba(255,255,255,0.7)', marginBottom: 7 }} />
<div style={{ fontSize: 12.5, color: 'white', fontWeight: 600, marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)', lineHeight: 1.4 }}>{desc}</div>
@@ -441,10 +455,10 @@ export default function LoginPage() {
<div style={{ position: 'relative' }}>
<User size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="text" value={username} onChange={e => setUsername(e.target.value)} required
type="text" value={username} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)} required
placeholder="admin" style={inputBase}
onFocus={e => e.target.style.borderColor = '#111827'}
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
</div>
@@ -456,10 +470,10 @@ export default function LoginPage() {
<div style={{ position: 'relative' }}>
<Mail size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="email" value={email} onChange={e => setEmail(e.target.value)} required
type="email" value={email} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)} required
placeholder={t('login.emailPlaceholder')} style={inputBase}
onFocus={e => e.target.style.borderColor = '#111827'}
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
</div>
@@ -470,10 +484,10 @@ export default function LoginPage() {
<div style={{ position: 'relative' }}>
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type={showPassword ? 'text' : 'password'} value={password} onChange={e => setPassword(e.target.value)} required
type={showPassword ? 'text' : 'password'} value={password} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)} required
placeholder="••••••••" style={{ ...inputBase, paddingRight: 44 }}
onFocus={e => e.target.style.borderColor = '#111827'}
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
/>
<button type="button" onClick={() => setShowPassword(v => !v)} style={{
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
@@ -490,8 +504,8 @@ export default function LoginPage() {
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
opacity: isLoading ? 0.7 : 1, transition: 'opacity 0.15s',
}}
onMouseEnter={e => { if (!isLoading) e.currentTarget.style.background = '#1f2937' }}
onMouseLeave={e => e.currentTarget.style.background = '#111827'}
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { if (!isLoading) e.currentTarget.style.background = '#1f2937' }}
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = '#111827'}
>
{isLoading
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : t('login.signingIn')}</>
@@ -530,8 +544,8 @@ export default function LoginPage() {
textDecoration: 'none', transition: 'all 0.15s',
boxSizing: 'border-box',
}}
onMouseEnter={e => { e.currentTarget.style.background = '#f9fafb'; e.currentTarget.style.borderColor = '#9ca3af' }}
onMouseLeave={e => { e.currentTarget.style.background = 'white'; e.currentTarget.style.borderColor = '#d1d5db' }}
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#f9fafb'; e.currentTarget.style.borderColor = '#9ca3af' }}
onMouseLeave={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = 'white'; e.currentTarget.style.borderColor = '#d1d5db' }}
>
<Shield size={16} />
{t('login.oidcSignIn', { name: appConfig.oidc_display_name })}
@@ -551,8 +565,8 @@ export default function LoginPage() {
opacity: isLoading ? 0.7 : 1, transition: 'all 0.2s',
boxShadow: '0 2px 12px rgba(245, 158, 11, 0.3)',
}}
onMouseEnter={e => { if (!isLoading) e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 16px rgba(245, 158, 11, 0.4)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }}
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { if (!isLoading) e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 16px rgba(245, 158, 11, 0.4)' }}
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }}
>
<Plane size={18} />
{t('login.demoHint')}
@@ -6,24 +6,25 @@ import Navbar from '../components/Layout/Navbar'
import PhotoGallery from '../components/Photos/PhotoGallery'
import { ArrowLeft } from 'lucide-react'
import { useTranslation } from '../i18n'
import type { Trip, Day, Place, Photo } from '../types'
export default function PhotosPage() {
export default function PhotosPage(): React.ReactElement {
const { t } = useTranslation()
const { id: tripId } = useParams()
const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate()
const tripStore = useTripStore()
const [trip, setTrip] = useState(null)
const [days, setDays] = useState([])
const [places, setPlaces] = useState([])
const [photos, setPhotos] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [trip, setTrip] = useState<Trip | null>(null)
const [days, setDays] = useState<Day[]>([])
const [places, setPlaces] = useState<Place[]>([])
const [photos, setPhotos] = useState<Photo[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
loadData()
}, [tripId])
const loadData = async () => {
const loadData = async (): Promise<void> => {
setIsLoading(true)
try {
const [tripData, daysData, placesData] = await Promise.all([
@@ -37,7 +38,7 @@ export default function PhotosPage() {
// Load photos
await tripStore.loadPhotos(tripId)
} catch (err) {
} catch (err: unknown) {
navigate('/dashboard')
} finally {
setIsLoading(false)
@@ -49,15 +50,15 @@ export default function PhotosPage() {
setPhotos(tripStore.photos)
}, [tripStore.photos])
const handleUpload = async (formData) => {
const handleUpload = async (formData: FormData): Promise<void> => {
await tripStore.addPhoto(tripId, formData)
}
const handleDelete = async (photoId) => {
const handleDelete = async (photoId: number): Promise<void> => {
await tripStore.deletePhoto(tripId, photoId)
}
const handleUpdate = async (photoId, data) => {
const handleUpdate = async (photoId: number, data: Record<string, string | number | null>): Promise<void> => {
await tripStore.updatePhoto(tripId, photoId, data)
}
@@ -71,7 +72,7 @@ export default function PhotosPage() {
return (
<div className="min-h-screen bg-slate-50">
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
<Navbar tripTitle={trip?.name} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-7xl mx-auto px-4 py-6">
@@ -89,7 +90,7 @@ export default function PhotosPage() {
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Fotos</h1>
<p className="text-gray-500 text-sm">{photos.length} Fotos für {trip?.title}</p>
<p className="text-gray-500 text-sm">{photos.length} Fotos für {trip?.name}</p>
</div>
</div>
@@ -4,20 +4,20 @@ import { useAuthStore } from '../store/authStore'
import { useTranslation } from '../i18n'
import { Map, Eye, EyeOff, Mail, Lock, User } from 'lucide-react'
export default function RegisterPage() {
export default function RegisterPage(): React.ReactElement {
const { t } = useTranslation()
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [username, setUsername] = useState<string>('')
const [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>('')
const [confirmPassword, setConfirmPassword] = useState<string>('')
const [showPassword, setShowPassword] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const { register } = useAuthStore()
const navigate = useNavigate()
const handleSubmit = async (e) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
setError('')
@@ -35,8 +35,8 @@ export default function RegisterPage() {
try {
await register(username, email, password)
navigate('/dashboard')
} catch (err) {
setError(err.message || t('register.failed'))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('register.failed'))
} finally {
setIsLoading(false)
}
@@ -96,7 +96,7 @@ export default function RegisterPage() {
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)}
required
placeholder="johndoe"
minLength={3}
@@ -112,7 +112,7 @@ export default function RegisterPage() {
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
required
placeholder="your@email.com"
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
@@ -127,7 +127,7 @@ export default function RegisterPage() {
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
required
placeholder={t('register.minChars')}
className="w-full pl-10 pr-12 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
@@ -149,7 +149,7 @@ export default function RegisterPage() {
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value)}
required
placeholder={t('register.repeatPassword')}
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
@@ -8,8 +8,16 @@ import CustomSelect from '../components/shared/CustomSelect'
import { useToast } from '../components/shared/Toast'
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock } from 'lucide-react'
import { authApi, adminApi } from '../api/client'
import type { LucideIcon } from 'lucide-react'
import type { UserWithOidc } from '../types'
import { getApiErrorMessage } from '../types'
const MAP_PRESETS = [
interface MapPreset {
name: string
url: string
}
const MAP_PRESETS: MapPreset[] = [
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
@@ -17,7 +25,13 @@ const MAP_PRESETS = [
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
]
function Section({ title, icon: Icon, children }) {
interface SectionProps {
title: string
icon: LucideIcon
children: React.ReactNode
}
function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement {
return (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-6 py-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-secondary)' }}>
@@ -31,31 +45,32 @@ function Section({ title, icon: Icon, children }) {
)
}
export default function SettingsPage() {
export default function SettingsPage(): React.ReactElement {
const { user, updateProfile, uploadAvatar, deleteAvatar, logout } = useAuthStore()
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const avatarInputRef = React.useRef(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
const avatarInputRef = React.useRef<HTMLInputElement>(null)
const { settings, updateSetting, updateSettings } = useSettingsStore()
const { t, locale } = useTranslation()
const toast = useToast()
const navigate = useNavigate()
const [saving, setSaving] = useState({})
const [saving, setSaving] = useState<Record<string, boolean>>({})
// Map settings
const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '')
const [defaultLat, setDefaultLat] = useState(settings.default_lat || 48.8566)
const [defaultLng, setDefaultLng] = useState(settings.default_lng || 2.3522)
const [defaultZoom, setDefaultZoom] = useState(settings.default_zoom || 10)
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522)
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
// Display
const [tempUnit, setTempUnit] = useState(settings.temperature_unit || 'celsius')
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
// Account
const [username, setUsername] = useState(user?.username || '')
const [email, setEmail] = useState(user?.email || '')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [username, setUsername] = useState<string>(user?.username || '')
const [email, setEmail] = useState<string>(user?.email || '')
const [currentPassword, setCurrentPassword] = useState<string>('')
const [newPassword, setNewPassword] = useState<string>('')
const [confirmPassword, setConfirmPassword] = useState<string>('')
useEffect(() => {
setMapTileUrl(settings.map_tile_url || '')
@@ -70,36 +85,36 @@ export default function SettingsPage() {
setEmail(user?.email || '')
}, [user])
const saveMapSettings = async () => {
const saveMapSettings = async (): Promise<void> => {
setSaving(s => ({ ...s, map: true }))
try {
await updateSettings({
map_tile_url: mapTileUrl,
default_lat: parseFloat(defaultLat),
default_lng: parseFloat(defaultLng),
default_zoom: parseInt(defaultZoom),
default_lat: parseFloat(String(defaultLat)),
default_lng: parseFloat(String(defaultLng)),
default_zoom: parseInt(String(defaultZoom)),
})
toast.success(t('settings.toast.mapSaved'))
} catch (err) {
toast.error(err.message)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Error')
} finally {
setSaving(s => ({ ...s, map: false }))
}
}
const saveDisplay = async () => {
const saveDisplay = async (): Promise<void> => {
setSaving(s => ({ ...s, display: true }))
try {
await updateSetting('temperature_unit', tempUnit)
toast.success(t('settings.toast.displaySaved'))
} catch (err) {
toast.error(err.message)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Error')
} finally {
setSaving(s => ({ ...s, display: false }))
}
}
const handleAvatarUpload = async (e) => {
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = e.target.files?.[0]
if (!file) return
try {
@@ -111,7 +126,7 @@ export default function SettingsPage() {
if (avatarInputRef.current) avatarInputRef.current.value = ''
}
const handleAvatarRemove = async () => {
const handleAvatarRemove = async (): Promise<void> => {
try {
await deleteAvatar()
toast.success(t('settings.avatarRemoved'))
@@ -120,13 +135,13 @@ export default function SettingsPage() {
}
}
const saveProfile = async () => {
const saveProfile = async (): Promise<void> => {
setSaving(s => ({ ...s, profile: true }))
try {
await updateProfile({ username, email })
toast.success(t('settings.toast.profileSaved'))
} catch (err) {
toast.error(err.message)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Error')
} finally {
setSaving(s => ({ ...s, profile: false }))
}
@@ -149,7 +164,7 @@ export default function SettingsPage() {
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
<CustomSelect
value=""
onChange={value => { if (value) setMapTileUrl(value) }}
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map(p => ({
value: p.url,
@@ -161,7 +176,7 @@ export default function SettingsPage() {
<input
type="text"
value={mapTileUrl}
onChange={e => setMapTileUrl(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
@@ -175,7 +190,7 @@ export default function SettingsPage() {
type="number"
step="any"
value={defaultLat}
onChange={e => setDefaultLat(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLat(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
@@ -185,7 +200,7 @@ export default function SettingsPage() {
type="number"
step="any"
value={defaultLng}
onChange={e => setDefaultLng(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLng(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
@@ -220,7 +235,7 @@ export default function SettingsPage() {
onClick={async () => {
try {
await updateSetting('dark_mode', opt.value)
} catch (e) { toast.error(e.message) }
} catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -252,7 +267,7 @@ export default function SettingsPage() {
key={opt.value}
onClick={async () => {
try { await updateSetting('language', opt.value) }
catch (e) { toast.error(e.message) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -283,7 +298,7 @@ export default function SettingsPage() {
onClick={async () => {
setTempUnit(opt.value)
try { await updateSetting('temperature_unit', opt.value) }
catch (e) { toast.error(e.message) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -313,7 +328,7 @@ export default function SettingsPage() {
key={opt.value}
onClick={async () => {
try { await updateSetting('time_format', opt.value) }
catch (e) { toast.error(e.message) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -342,7 +357,7 @@ export default function SettingsPage() {
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('route_calculation', opt.value) }
catch (e) { toast.error(e.message) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -368,7 +383,7 @@ export default function SettingsPage() {
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
@@ -377,7 +392,7 @@ export default function SettingsPage() {
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
@@ -386,37 +401,45 @@ export default function SettingsPage() {
<div style={{ paddingTop: 8, marginTop: 8, borderTop: '1px solid var(--border-secondary)' }}>
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
<div className="space-y-3">
<input
type="password"
value={currentPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCurrentPassword(e.target.value)}
placeholder={t('settings.currentPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewPassword(e.target.value)}
placeholder={t('settings.newPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<input
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value)}
placeholder={t('settings.confirmPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<button
onClick={async () => {
if (!currentPassword) return toast.error(t('settings.currentPasswordRequired'))
if (!newPassword) return toast.error(t('settings.passwordRequired'))
if (newPassword.length < 8) return toast.error(t('settings.passwordTooShort'))
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'))
try {
await authApi.changePassword({ new_password: newPassword })
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
toast.success(t('settings.passwordChanged'))
setNewPassword(''); setConfirmPassword('')
} catch (err) {
toast.error(err.response?.data?.error || t('common.error'))
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'var(--bg-card)'}
>
<Lock size={14} />
{t('settings.updatePassword')}
@@ -455,8 +478,8 @@ export default function SettingsPage() {
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, transition: 'transform 0.15s, opacity 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.15)'; e.currentTarget.style.opacity = '0.85' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.opacity = '1' }}
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.transform = 'scale(1.15)'; e.currentTarget.style.opacity = '0.85' }}
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.opacity = '1' }}
>
<Camera size={14} />
</button>
@@ -481,7 +504,7 @@ export default function SettingsPage() {
<span className="font-medium" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-secondary)' }}>
{user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')}
</span>
{user?.oidc_issuer && (
{(user as UserWithOidc)?.oidc_issuer && (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 10, fontWeight: 500, padding: '1px 8px', borderRadius: 99,
@@ -491,9 +514,9 @@ export default function SettingsPage() {
</span>
)}
</div>
{user?.oidc_issuer && (
{(user as UserWithOidc)?.oidc_issuer && (
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -2 }}>
{t('settings.oidcLinked')} {user.oidc_issuer.replace('https://', '').replace(/\/+$/, '')}
{t('settings.oidcLinked')} {(user as UserWithOidc).oidc_issuer!.replace('https://', '').replace(/\/+$/, '')}
</p>
)}
</div>
@@ -513,7 +536,7 @@ export default function SettingsPage() {
if (user?.role === 'admin') {
try {
const data = await adminApi.stats()
const adminUsers = (await adminApi.users()).users.filter(u => u.role === 'admin')
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
if (adminUsers.length <= 1) {
setShowDeleteConfirm('blocked')
return
@@ -541,7 +564,7 @@ export default function SettingsPage() {
<div style={{
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}} onClick={e => e.stopPropagation()}>
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef3c7', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Shield size={18} style={{ color: '#d97706' }} />
@@ -576,7 +599,7 @@ export default function SettingsPage() {
<div style={{
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}} onClick={e => e.stopPropagation()}>
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Trash2 size={18} style={{ color: '#ef4444' }} />
@@ -603,8 +626,8 @@ export default function SettingsPage() {
await authApi.deleteOwnAccount()
logout()
navigate('/login')
} catch (err) {
toast.error(err.response?.data?.error || t('common.error'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
setShowDeleteConfirm(false)
}
}}
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { useParams, useNavigate } from 'react-router-dom'
import { useTripStore } from '../store/tripStore'
@@ -21,16 +21,16 @@ import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
import { useTranslation } from '../i18n'
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client'
import { calculateRoute, calculateSegments } from '../components/Map/RouteCalculator'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import { useResizablePanels } from '../hooks/useResizablePanels'
import { useTripWebSocket } from '../hooks/useTripWebSocket'
import { useRouteCalculation } from '../hooks/useRouteCalculation'
import { usePlaceSelection } from '../hooks/usePlaceSelection'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../types'
const MIN_SIDEBAR = 200
const MAX_SIDEBAR = 520
export default function TripPlannerPage() {
const { id: tripId } = useParams()
export default function TripPlannerPage(): React.ReactElement | null {
const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate()
const toast = useToast()
const { t } = useTranslation()
@@ -38,10 +38,10 @@ export default function TripPlannerPage() {
const tripStore = useTripStore()
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
const [enabledAddons, setEnabledAddons] = useState({ packing: true, budget: true, documents: true })
const [tripAccommodations, setTripAccommodations] = useState([])
const [allowedFileTypes, setAllowedFileTypes] = useState(null)
const [tripMembers, setTripMembers] = useState([])
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true })
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
const [allowedFileTypes, setAllowedFileTypes] = useState<string | null>(null)
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
const loadAccommodations = useCallback(() => {
if (tripId) accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
@@ -67,52 +67,30 @@ export default function TripPlannerPage() {
...(enabledAddons.collab ? [{ id: 'collab', label: 'Collab' }] : []),
]
const [activeTab, setActiveTab] = useState(() => {
const [activeTab, setActiveTab] = useState<string>(() => {
const saved = sessionStorage.getItem(`trip-tab-${tripId}`)
return saved || 'plan'
})
const handleTabChange = (tabId) => {
const handleTabChange = (tabId: string): void => {
setActiveTab(tabId)
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
if (tabId === 'finanzplan') tripStore.loadBudgetItems?.(tripId)
if (tabId === 'dateien' && (!files || files.length === 0)) tripStore.loadFiles?.(tripId)
}
const [leftWidth, setLeftWidth] = useState(() => parseInt(localStorage.getItem('sidebarLeftWidth')) || 340)
const [rightWidth, setRightWidth] = useState(() => parseInt(localStorage.getItem('sidebarRightWidth')) || 300)
const [leftCollapsed, setLeftCollapsed] = useState(false)
const [rightCollapsed, setRightCollapsed] = useState(false)
const [showDayDetail, setShowDayDetail] = useState(null) // day object or null
const isResizingLeft = useRef(false)
const isResizingRight = useRef(false)
const [selectedPlaceId, _setSelectedPlaceId] = useState(null)
const [selectedAssignmentId, setSelectedAssignmentId] = useState(null)
// Set place selection - from PlacesSidebar/Map (no assignment context)
const setSelectedPlaceId = useCallback((placeId) => {
_setSelectedPlaceId(placeId)
setSelectedAssignmentId(null)
}, [])
// Set assignment selection - from DayPlanSidebar (specific assignment)
const selectAssignment = useCallback((assignmentId, placeId) => {
setSelectedAssignmentId(assignmentId)
_setSelectedPlaceId(placeId)
}, [])
const [showPlaceForm, setShowPlaceForm] = useState(false)
const [editingPlace, setEditingPlace] = useState(null)
const [editingAssignmentId, setEditingAssignmentId] = useState(null)
const [showTripForm, setShowTripForm] = useState(false)
const [showMembersModal, setShowMembersModal] = useState(false)
const [showReservationModal, setShowReservationModal] = useState(false)
const [editingReservation, setEditingReservation] = useState(null)
const [route, setRoute] = useState(null)
const [routeInfo, setRouteInfo] = useState(null) // unused legacy
const [routeSegments, setRouteSegments] = useState([]) // { from, to, walkingText, drivingText }
const [fitKey, setFitKey] = useState(0)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(null) // 'left' | 'right' | null
const [deletePlaceId, setDeletePlaceId] = useState(null)
const { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } = useResizablePanels()
const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection()
const [showDayDetail, setShowDayDetail] = useState<Day | null>(null)
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
const [showTripForm, setShowTripForm] = useState<boolean>(false)
const [showMembersModal, setShowMembersModal] = useState<boolean>(false)
const [showReservationModal, setShowReservationModal] = useState<boolean>(false)
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
const [fitKey, setFitKey] = useState<number>(0)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
// Load trip + files (needed for place inspector file section)
useEffect(() => {
@@ -132,87 +110,13 @@ export default function TripPlannerPage() {
if (tripId) tripStore.loadReservations(tripId)
}, [tripId])
// WebSocket: join trip and listen for remote events
useEffect(() => {
if (!tripId) return
const handler = useTripStore.getState().handleRemoteEvent
joinTrip(tripId)
addListener(handler)
// Reload files when collab notes change (attachments sync) — from WebSocket (other users)
const collabFileSync = (event) => {
if (event?.type === 'collab:note:deleted' || event?.type === 'collab:note:updated') {
tripStore.loadFiles?.(tripId)
}
}
addListener(collabFileSync)
// Reload files when local collab actions change files (own user)
const localFileSync = () => tripStore.loadFiles?.(tripId)
window.addEventListener('collab-files-changed', localFileSync)
return () => {
leaveTrip(tripId)
removeListener(handler)
removeListener(collabFileSync)
window.removeEventListener('collab-files-changed', localFileSync)
}
}, [tripId])
useEffect(() => {
const onMove = (e) => {
if (isResizingLeft.current) {
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, e.clientX - 10))
setLeftWidth(w)
localStorage.setItem('sidebarLeftWidth', w)
}
if (isResizingRight.current) {
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, window.innerWidth - e.clientX - 10))
setRightWidth(w)
localStorage.setItem('sidebarRightWidth', w)
}
}
const onUp = () => {
isResizingLeft.current = false
isResizingRight.current = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
return () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
}, [])
useTripWebSocket(tripId)
const mapPlaces = useCallback(() => {
return places.filter(p => p.lat && p.lng)
}, [places])
const routeCalcEnabled = useSettingsStore(s => s.settings.route_calculation) !== false
const routeAbortRef = useRef(null)
const updateRouteForDay = useCallback(async (dayId) => {
// Abort any previous calculation
if (routeAbortRef.current) routeAbortRef.current.abort()
if (!dayId) { setRoute(null); setRouteSegments([]); return }
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
setRoute(waypoints.map(p => [p.lat, p.lng]))
if (!routeCalcEnabled) { setRouteSegments([]); return }
// Single OSRM request for all segments
const controller = new AbortController()
routeAbortRef.current = controller
try {
const segments = await calculateSegments(waypoints, { signal: controller.signal })
if (!controller.signal.aborted) setRouteSegments(segments)
} catch (err) {
if (err.name !== 'AbortError') setRouteSegments([])
}
}, [tripStore, routeCalcEnabled])
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation(tripStore, selectedDayId)
const handleSelectDay = useCallback((dayId, skipFit) => {
const changed = dayId !== selectedDayId
@@ -287,7 +191,7 @@ export default function TripPlannerPage() {
await tripStore.deletePlace(tripId, deletePlaceId)
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
toast.success(t('trip.toast.placeDeleted'))
} catch (err) { toast.error(err.message) }
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}, [deletePlaceId, tripId, tripStore, toast, selectedPlaceId])
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
@@ -297,14 +201,14 @@ export default function TripPlannerPage() {
await tripStore.assignPlaceToDay(tripId, target, placeId, position)
toast.success(t('trip.toast.assignedToDay'))
updateRouteForDay(target)
} catch (err) { toast.error(err.message) }
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}, [selectedDayId, tripId, tripStore, toast, updateRouteForDay])
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
try {
await tripStore.removeAssignment(tripId, dayId, assignmentId)
}
catch (err) { toast.error(err.message) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}, [tripId, tripStore, toast, updateRouteForDay])
const handleReorder = useCallback((dayId, orderedIds) => {
@@ -323,7 +227,7 @@ export default function TripPlannerPage() {
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
try { await tripStore.updateDayTitle(tripId, dayId, title) }
catch (err) { toast.error(err.message) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}, [tripId, tripStore, toast])
const handleSaveReservation = async (data) => {
@@ -339,12 +243,12 @@ export default function TripPlannerPage() {
setShowReservationModal(false)
return r
}
} catch (err) { toast.error(err.message) }
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}
const handleDeleteReservation = async (id) => {
try { await tripStore.deleteReservation(tripId, id); toast.success(t('trip.toast.deleted')) }
catch (err) { toast.error(err.message) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
@@ -363,12 +267,6 @@ export default function TripPlannerPage() {
return map
}, [selectedDayId, assignments])
// Auto-update route + segments when assignments change
useEffect(() => {
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
updateRouteForDay(selectedDayId)
}, [selectedDayId, assignments])
// Places assigned to selected day (with coords) — used for map fitting
const dayPlaces = useMemo(() => {
if (!selectedDayId) return []
@@ -510,7 +408,7 @@ export default function TripPlannerPage() {
/>
{!leftCollapsed && (
<div
onMouseDown={() => { isResizingLeft.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }}
onMouseDown={startResizeLeft}
style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 4, cursor: 'col-resize', background: 'transparent' }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,0,0,0.08)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
@@ -547,7 +445,7 @@ export default function TripPlannerPage() {
}}>
{!rightCollapsed && (
<div
onMouseDown={() => { isResizingRight.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }}
onMouseDown={startResizeRight}
style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 4, cursor: 'col-resize', background: 'transparent' }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,0,0,0.08)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
@@ -647,7 +545,7 @@ export default function TripPlannerPage() {
}))
} catch {}
}}
onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err) { toast.error(err.message) } }}
onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }}
/>
)}
@@ -11,17 +11,17 @@ import VacaySettings from '../components/Vacay/VacaySettings'
import { Plus, Minus, ChevronLeft, ChevronRight, Settings, CalendarDays, AlertTriangle, Users, Eye, Pencil, Trash2, Unlink, ShieldCheck, SlidersHorizontal } from 'lucide-react'
import Modal from '../components/shared/Modal'
export default function VacayPage() {
export default function VacayPage(): React.ReactElement {
const { t } = useTranslation()
const { years, selectedYear, setSelectedYear, addYear, removeYear, loadAll, loadPlan, loadEntries, loadStats, loadHolidays, loading, incomingInvites, acceptInvite, declineInvite, plan } = useVacayStore()
const [showSettings, setShowSettings] = useState(false)
const [deleteYear, setDeleteYear] = useState(null)
const [showMobileSidebar, setShowMobileSidebar] = useState(false)
const [showSettings, setShowSettings] = useState<boolean>(false)
const [deleteYear, setDeleteYear] = useState<number | null>(null)
const [showMobileSidebar, setShowMobileSidebar] = useState<boolean>(false)
useEffect(() => { loadAll() }, [])
// Live sync via WebSocket
const handleWsMessage = useCallback((msg) => {
const handleWsMessage = useCallback((msg: { type: string }) => {
if (msg.type === 'vacay:update' || msg.type === 'vacay:settings') {
loadPlan()
loadEntries(selectedYear)
@@ -263,7 +263,7 @@ export default function VacayPage() {
)
}
function InfoItem({ icon: Icon, text }) {
function InfoItem({ icon: Icon, text }: { icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>; text: string }): React.ReactElement {
return (
<div className="flex items-start gap-3 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
<Icon size={15} className="shrink-0 mt-0.5" style={{ color: 'var(--text-muted)' }} />
@@ -272,7 +272,7 @@ function InfoItem({ icon: Icon, text }) {
)
}
function LegendItem({ color, label }) {
function LegendItem({ color, label }: { color: string; label: string }): React.ReactElement {
return (
<div className="flex items-center gap-2">
<span className="w-4 h-3 rounded" style={{ background: color, border: `1px solid ${color}` }} />