mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
refactoring: TypeScript migration, security fixes,
This commit is contained in:
@@ -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}` }} />
|
||||
Reference in New Issue
Block a user