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
@@ -2,6 +2,17 @@ import React, { useEffect, useCallback } from 'react'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
interface ConfirmDialogProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
title?: string
message?: string
confirmLabel?: string
cancelLabel?: string
danger?: boolean
}
export default function ConfirmDialog({
isOpen,
onClose,
@@ -11,10 +22,10 @@ export default function ConfirmDialog({
confirmLabel,
cancelLabel,
danger = true,
}) {
}: ConfirmDialogProps) {
const { t } = useTranslation()
const handleEsc = useCallback((e) => {
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])
@@ -1,10 +1,25 @@
import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { LucideIcon } from 'lucide-react'
interface MenuItem {
label?: string
icon?: LucideIcon
onClick?: () => void
danger?: boolean
divider?: boolean
}
interface MenuState {
x: number
y: number
items: MenuItem[]
}
export function useContextMenu() {
const [menu, setMenu] = useState(null) // { x, y, items }
const [menu, setMenu] = useState<MenuState | null>(null)
const open = (e, items) => {
const open = (e: React.MouseEvent, items: MenuItem[]) => {
e.preventDefault()
e.stopPropagation()
setMenu({ x: e.clientX, y: e.clientY, items })
@@ -15,8 +30,13 @@ export function useContextMenu() {
return { menu, open, close }
}
export function ContextMenu({ menu, onClose }) {
const ref = useRef(null)
interface ContextMenuProps {
menu: MenuState | null
onClose: () => void
}
export function ContextMenu({ menu, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!menu) return
@@ -29,7 +49,6 @@ export function ContextMenu({ menu, onClose }) {
}
}, [menu, onClose])
// Adjust position if menu would overflow viewport
useEffect(() => {
if (!menu || !ref.current) return
const el = ref.current
@@ -60,7 +79,7 @@ export function ContextMenu({ menu, onClose }) {
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
const Icon = item.icon
return (
<button key={i} onClick={() => { item.onClick(); onClose() }} style={{
<button key={i} onClick={() => { item.onClick?.(); onClose() }} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '7px 10px', borderRadius: 7, border: 'none',
background: 'none', cursor: 'pointer', fontFamily: 'inherit',
@@ -3,24 +3,30 @@ import ReactDOM from 'react-dom'
import { Calendar, Clock, ChevronLeft, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n'
function daysInMonth(year, month) { return new Date(year, month + 1, 0).getDate() }
function getWeekday(year, month, day) { return new Date(year, month, day).getDay() }
function daysInMonth(year: number, month: number): number { return new Date(year, month + 1, 0).getDate() }
function getWeekday(year: number, month: number, day: number): number { return new Date(year, month, day).getDay() }
// ── Datum-Only Picker ────────────────────────────────────────────────────────
export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
interface CustomDatePickerProps {
value: string
onChange: (value: string) => void
placeholder?: string
style?: React.CSSProperties
}
export function CustomDatePicker({ value, onChange, placeholder, style = {} }: CustomDatePickerProps) {
const { locale, t } = useTranslation()
const [open, setOpen] = useState(false)
const ref = useRef(null)
const dropRef = useRef(null)
const ref = useRef<HTMLDivElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const parsed = value ? new Date(value + 'T00:00:00') : null
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
useEffect(() => {
const handler = (e) => {
if (ref.current?.contains(e.target)) return
if (dropRef.current?.contains(e.target)) return
const handler = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (dropRef.current?.contains(e.target as Node)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handler)
@@ -36,12 +42,12 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(locale, { month: 'long', year: 'numeric' })
const days = daysInMonth(viewYear, viewMonth)
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7 // Mo=0
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7
const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' }))
const displayValue = parsed ? parsed.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }) : null
const selectDay = (day) => {
const selectDay = (day: number) => {
const y = String(viewYear)
const m = String(viewMonth + 1).padStart(2, '0')
const d = String(day).padStart(2, '0')
@@ -51,7 +57,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null
const today = new Date()
const isToday = (d) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
@@ -81,11 +87,8 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
const vh = window.innerHeight
let left = r.left
let top = r.bottom + 4
// Keep within viewport horizontally
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
// If not enough space below, open above
if (top + 320 > vh) top = Math.max(pad, r.top - 320)
// On very small screens, center horizontally
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
return { top, left }
})(),
@@ -161,16 +164,21 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
)
}
// ── DateTime Picker (Datum + Uhrzeit kombiniert) ─────────────────────────────
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }) {
interface CustomDateTimePickerProps {
value: string
onChange: (value: string) => void
placeholder?: string
style?: React.CSSProperties
}
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }: CustomDateTimePickerProps) {
const { locale } = useTranslation()
// value = "2024-03-15T14:30" oder ""
const [datePart, timePart] = (value || '').split('T')
const handleDateChange = (d) => {
const handleDateChange = (d: string) => {
onChange(d ? `${d}T${timePart || '12:00'}` : '')
}
const handleTimeChange = (t) => {
const handleTimeChange = (t: string) => {
const d = datePart || new Date().toISOString().split('T')[0]
onChange(t ? `${d}T${t}` : d)
}
@@ -185,5 +193,4 @@ export function CustomDateTimePicker({ value, onChange, placeholder, style = {}
)
}
// Inline re-export for convenience
import CustomTimePicker from './CustomTimePicker'
@@ -2,29 +2,48 @@ import React, { useState, useRef, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { ChevronDown, Check } from 'lucide-react'
interface SelectOption {
value: string
label: string
icon?: React.ReactNode
isHeader?: boolean
searchLabel?: string
groupLabel?: string
}
interface CustomSelectProps {
value: string
onChange: (value: string) => void
options?: SelectOption[]
placeholder?: string
searchable?: boolean
style?: React.CSSProperties
size?: 'sm' | 'md'
}
export default function CustomSelect({
value,
onChange,
options = [], // [{ value, label, icon? }]
options = [],
placeholder = '',
searchable = false,
style = {},
size = 'md', // 'sm' | 'md'
}) {
size = 'md',
}: CustomSelectProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const ref = useRef(null)
const dropRef = useRef(null)
const searchRef = useRef(null)
const ref = useRef<HTMLDivElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open && searchable && searchRef.current) searchRef.current.focus()
}, [open, searchable])
useEffect(() => {
const handleClick = (e) => {
if (ref.current?.contains(e.target)) return
if (dropRef.current?.contains(e.target)) return
const handleClick = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (dropRef.current?.contains(e.target as Node)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handleClick)
@@ -35,8 +54,8 @@ export default function CustomSelect({
const filtered = searchable && search
? (() => {
const q = search.toLowerCase()
const result = []
let currentHeader = null
const result: SelectOption[] = []
let currentHeader: SelectOption | null = null
let headerAdded = false
for (const o of options) {
if (o.isHeader) {
@@ -44,7 +63,6 @@ export default function CustomSelect({
headerAdded = false
continue
}
// Match against label, searchLabel, or groupLabel
const haystack = [o.label, o.searchLabel, o.groupLabel].filter(Boolean).join(' ').toLowerCase()
if (haystack.includes(q)) {
if (currentHeader && !headerAdded) {
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'
import { Clock, ChevronUp, ChevronDown } from 'lucide-react'
import { useSettingsStore } from '../../store/settingsStore'
function formatDisplay(val, is12h) {
function formatDisplay(val: string, is12h: boolean): string {
if (!val) return ''
const [h, m] = val.split(':').map(Number)
if (isNaN(h) || isNaN(m)) return val
@@ -13,28 +13,35 @@ function formatDisplay(val, is12h) {
return `${h12}:${String(m).padStart(2, '0')} ${period}`
}
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {} }) {
interface CustomTimePickerProps {
value: string
onChange: (value: string) => void
placeholder?: string
style?: React.CSSProperties
}
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {} }: CustomTimePickerProps) {
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [open, setOpen] = useState(false)
const [inputFocused, setInputFocused] = useState(false)
const ref = useRef(null)
const dropRef = useRef(null)
const ref = useRef<HTMLDivElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const [h, m] = (value || '').split(':').map(Number)
const hour = isNaN(h) ? null : h
const minute = isNaN(m) ? null : m
useEffect(() => {
const handler = (e) => {
if (ref.current?.contains(e.target)) return
if (dropRef.current?.contains(e.target)) return
const handler = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (dropRef.current?.contains(e.target as Node)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const update = (newH, newM) => {
const update = (newH: number, newM: number) => {
const hh = String(Math.max(0, Math.min(23, newH))).padStart(2, '0')
const mm = String(Math.max(0, Math.min(59, newM))).padStart(2, '0')
onChange(`${hh}:${mm}`)
@@ -53,16 +60,15 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
update(newH, newM)
}
const btnStyle = {
const btnStyle: React.CSSProperties = {
background: 'none', border: 'none', cursor: 'pointer', padding: 2,
color: 'var(--text-faint)', display: 'flex', borderRadius: 4,
transition: 'color 0.15s',
}
const handleInput = (e) => {
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
onChange(raw)
// Auto-format: wenn "1430" → "14:30"
const clean = raw.replace(/[^0-9:]/g, '')
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
else if (/^\d{4}$/.test(clean)) onChange(clean.slice(0, 2) + ':' + clean.slice(2))
@@ -139,7 +145,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
animation: 'selectIn 0.15s ease-out',
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
}}>
{/* Stunden */}
{/* Hours */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<button type="button" onClick={incHour} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
@@ -163,7 +169,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text-faint)', marginTop: -2 }}>:</span>
{/* Minuten */}
{/* Minutes */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<button type="button" onClick={incMin} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
@@ -1,7 +1,7 @@
import React, { useEffect, useCallback, useRef } from 'react'
import { X } from 'lucide-react'
const sizeClasses = {
const sizeClasses: Record<string, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
@@ -9,6 +9,16 @@ const sizeClasses = {
'2xl': 'max-w-4xl',
}
interface ModalProps {
isOpen: boolean
onClose: () => void
title?: React.ReactNode
children?: React.ReactNode
size?: string
footer?: React.ReactNode
hideCloseButton?: boolean
}
export default function Modal({
isOpen,
onClose,
@@ -17,8 +27,8 @@ export default function Modal({
size = 'md',
footer,
hideCloseButton = false,
}) {
const handleEsc = useCallback((e) => {
}: ModalProps) {
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])
@@ -33,7 +43,7 @@ export default function Modal({
}
}, [isOpen, handleEsc])
const mouseDownTarget = useRef(null)
const mouseDownTarget = useRef<EventTarget | null>(null)
if (!isOpen) return null
@@ -1,25 +1,37 @@
import React, { useState, useEffect } from 'react'
import { mapsApi } from '../../api/client'
import { getCategoryIcon } from './categoryIcons'
import type { Place } from '../../types'
const googlePhotoCache = new Map()
interface Category {
color?: string
icon?: string
}
export default function PlaceAvatar({ place, size = 32, category }) {
const [photoSrc, setPhotoSrc] = useState(place.image_url || null)
interface PlaceAvatarProps {
place: Pick<Place, 'id' | 'name' | 'image_url' | 'google_place_id'>
size?: number
category?: Category | null
}
const googlePhotoCache = new Map<string, string>()
export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
useEffect(() => {
if (place.image_url) { setPhotoSrc(place.image_url); return }
if (!place.google_place_id) { setPhotoSrc(null); return }
if (googlePhotoCache.has(place.google_place_id)) {
setPhotoSrc(googlePhotoCache.get(place.google_place_id))
setPhotoSrc(googlePhotoCache.get(place.google_place_id)!)
return
}
mapsApi.placePhoto(place.google_place_id)
.then(data => {
.then((data: { photoUrl?: string }) => {
if (data.photoUrl) {
googlePhotoCache.set(place.google_place_id, data.photoUrl)
googlePhotoCache.set(place.google_place_id!, data.photoUrl)
setPhotoSrc(data.photoUrl)
}
})
@@ -30,7 +42,7 @@ export default function PlaceAvatar({ place, size = 32, category }) {
const IconComp = getCategoryIcon(category?.icon)
const iconSize = Math.round(size * 0.46)
const containerStyle = {
const containerStyle: React.CSSProperties = {
width: size, height: size,
borderRadius: '50%',
overflow: 'hidden',
@@ -1,14 +1,28 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
const ToastContext = createContext(null)
type ToastType = 'success' | 'error' | 'warning' | 'info'
interface Toast {
id: number
message: string
type: ToastType
duration: number
removing: boolean
}
declare global {
interface Window {
__addToast?: (message: string, type?: ToastType, duration?: number) => number
}
}
let toastIdCounter = 0
export function ToastContainer() {
const [toasts, setToasts] = useState([])
const [toasts, setToasts] = useState<Toast[]>([])
const addToast = useCallback((message, type = 'info', duration = 3000) => {
const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
const id = ++toastIdCounter
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
@@ -24,27 +38,26 @@ export function ToastContainer() {
return id
}, [])
const removeToast = useCallback((id) => {
const removeToast = useCallback((id: number) => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 300)
}, [])
// Make addToast globally accessible
useEffect(() => {
window.__addToast = addToast
return () => { delete window.__addToast }
}, [addToast])
const icons = {
const icons: Record<ToastType, React.ReactNode> = {
success: <CheckCircle className="w-5 h-5 text-emerald-500 flex-shrink-0" />,
error: <XCircle className="w-5 h-5 text-red-500 flex-shrink-0" />,
warning: <AlertCircle className="w-5 h-5 text-amber-500 flex-shrink-0" />,
info: <Info className="w-5 h-5 text-blue-500 flex-shrink-0" />,
}
const bgColors = {
const bgColors: Record<ToastType, string> = {
success: 'bg-white border-l-4 border-emerald-500',
error: 'bg-white border-l-4 border-red-500',
warning: 'bg-white border-l-4 border-amber-500',
@@ -78,17 +91,17 @@ export function ToastContainer() {
}
export const useToast = () => {
const show = useCallback((message, type, duration) => {
const show = useCallback((message: string, type: ToastType, duration?: number) => {
if (window.__addToast) {
window.__addToast(message, type, duration)
}
}, [])
return {
success: (message, duration) => show(message, 'success', duration),
error: (message, duration) => show(message, 'error', duration),
warning: (message, duration) => show(message, 'warning', duration),
info: (message, duration) => show(message, 'info', duration),
success: (message: string, duration?: number) => show(message, 'success', duration),
error: (message: string, duration?: number) => show(message, 'error', duration),
warning: (message: string, duration?: number) => show(message, 'warning', duration),
info: (message: string, duration?: number) => show(message, 'info', duration),
}
}
@@ -9,9 +9,10 @@ import {
Church, Library, Store, Home, Cross,
Heart, Star, CreditCard, Wifi,
Luggage, Backpack, Zap,
LucideIcon,
} from 'lucide-react'
export const CATEGORY_ICON_MAP = {
export const CATEGORY_ICON_MAP: Record<string, LucideIcon> = {
MapPin, Building2, BedDouble, UtensilsCrossed, Landmark, ShoppingBag,
Bus, Train, Car, Plane, Ship, Bike,
Activity, Dumbbell, Mountain, Tent, Anchor,
@@ -24,7 +25,7 @@ export const CATEGORY_ICON_MAP = {
Luggage, Backpack, Zap,
}
export const ICON_LABELS = {
export const ICON_LABELS: Record<string, string> = {
MapPin: 'Pin', Building2: 'Building', BedDouble: 'Hotel', UtensilsCrossed: 'Restaurant',
Landmark: 'Attraction', ShoppingBag: 'Shopping', Bus: 'Bus', Train: 'Train',
Car: 'Car', Plane: 'Plane', Ship: 'Ship', Bike: 'Bicycle',
@@ -38,6 +39,6 @@ export const ICON_LABELS = {
Luggage: 'Luggage', Backpack: 'Backpack', Zap: 'Adventure',
}
export function getCategoryIcon(iconName) {
return CATEGORY_ICON_MAP[iconName] || MapPin
export function getCategoryIcon(iconName: string | null | undefined): LucideIcon {
return (iconName && CATEGORY_ICON_MAP[iconName]) || MapPin
}