mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
Initial commit — NOMAD (Navigation Organizer for Maps, Activities & Destinations)
Self-hosted travel planner with Express.js, SQLite, React & Tailwind CSS.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
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() }
|
||||
|
||||
// ── Datum-Only Picker ────────────────────────────────────────────────────────
|
||||
export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
||||
const { locale } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const dropRef = useRef(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
|
||||
setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && parsed) { setViewYear(parsed.getFullYear()); setViewMonth(parsed.getMonth()) }
|
||||
}, [open])
|
||||
|
||||
const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) } else setViewMonth(m => m - 1) }
|
||||
const nextMonth = () => { if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1) } else setViewMonth(m => m + 1) }
|
||||
|
||||
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 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 y = String(viewYear)
|
||||
const m = String(viewMonth + 1).padStart(2, '0')
|
||||
const d = String(day).padStart(2, '0')
|
||||
onChange(`${y}-${m}-${d}`)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
||||
<button type="button" onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '8px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
fontSize: 13, fontFamily: 'inherit', cursor: 'pointer', outline: 'none',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
|
||||
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span>{displayValue || placeholder || 'Datum'}</span>
|
||||
</button>
|
||||
|
||||
{open && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed',
|
||||
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
|
||||
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
|
||||
zIndex: 99999,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 14, boxShadow: '0 8px 32px rgba(0,0,0,0.12)', padding: 12, width: 268,
|
||||
animation: 'selectIn 0.15s ease-out',
|
||||
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
|
||||
}}>
|
||||
{/* Month nav */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||
<button type="button" onClick={prevMonth} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{monthLabel}</span>
|
||||
<button type="button" onClick={nextMonth} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, marginBottom: 4 }}>
|
||||
{weekdays.map((d, i) => (
|
||||
<div key={i} style={{ textAlign: 'center', fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '2px 0' }}>{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Days grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
|
||||
{Array.from({ length: startDay }, (_, i) => <div key={`e-${i}`} />)}
|
||||
{Array.from({ length: days }, (_, i) => {
|
||||
const d = i + 1
|
||||
const sel = d === selectedDay
|
||||
const td = isToday(d)
|
||||
return (
|
||||
<button key={d} type="button" onClick={() => selectDay(d)}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 8, border: 'none',
|
||||
background: sel ? 'var(--accent)' : 'transparent',
|
||||
color: sel ? 'var(--accent-text)' : 'var(--text-primary)',
|
||||
fontSize: 12, fontWeight: sel ? 700 : td ? 600 : 400,
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
outline: td && !sel ? '2px solid var(--border-primary)' : 'none', outlineOffset: -2,
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = 'transparent' }}>
|
||||
{d}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Clear */}
|
||||
{value && (
|
||||
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'center' }}>
|
||||
<button type="button" onClick={() => { onChange(''); setOpen(false) }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', padding: '3px 8px', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<style>{`@keyframes selectIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── DateTime Picker (Datum + Uhrzeit kombiniert) ─────────────────────────────
|
||||
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }) {
|
||||
const { locale } = useTranslation()
|
||||
// value = "2024-03-15T14:30" oder ""
|
||||
const [datePart, timePart] = (value || '').split('T')
|
||||
|
||||
const handleDateChange = (d) => {
|
||||
onChange(d ? `${d}T${timePart || '12:00'}` : '')
|
||||
}
|
||||
const handleTimeChange = (t) => {
|
||||
const d = datePart || new Date().toISOString().split('T')[0]
|
||||
onChange(t ? `${d}T${t}` : `${d}T00:00`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, ...style }}>
|
||||
<CustomDatePicker value={datePart || ''} onChange={handleDateChange} style={{ flex: 1, minWidth: 0 }} />
|
||||
<div style={{ width: 110, flexShrink: 0 }}>
|
||||
<CustomTimePicker value={timePart || ''} onChange={handleTimeChange} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Inline re-export for convenience
|
||||
import CustomTimePicker from './CustomTimePicker'
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, Check } from 'lucide-react'
|
||||
|
||||
export default function CustomSelect({
|
||||
value,
|
||||
onChange,
|
||||
options = [], // [{ value, label, icon? }]
|
||||
placeholder = '',
|
||||
searchable = false,
|
||||
style = {},
|
||||
size = 'md', // 'sm' | 'md'
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const ref = useRef(null)
|
||||
const dropRef = useRef(null)
|
||||
const searchRef = useRef(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
|
||||
setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open])
|
||||
|
||||
const selected = options.find(o => o.value === value)
|
||||
const filtered = searchable && search
|
||||
? options.filter(o => o.label.toLowerCase().includes(search.toLowerCase()))
|
||||
: options
|
||||
|
||||
const sm = size === 'sm'
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
||||
{/* Trigger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(o => !o); setSearch('') }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: sm ? '8px 12px' : '8px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-input)', color: 'var(--text-primary)',
|
||||
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
|
||||
cursor: 'pointer', outline: 'none', textAlign: 'left',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||
>
|
||||
{selected?.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{selected.icon}</span>}
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none' }} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed',
|
||||
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
|
||||
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
|
||||
width: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.width : 200 })(),
|
||||
zIndex: 99999,
|
||||
background: 'var(--bg-card)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||
overflow: 'hidden',
|
||||
animation: 'selectIn 0.15s ease-out',
|
||||
}}>
|
||||
{/* Search */}
|
||||
{searchable && (
|
||||
<div style={{ padding: '6px 6px 2px' }}>
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="..."
|
||||
style={{
|
||||
width: '100%', border: '1px solid var(--border-secondary)', borderRadius: 6,
|
||||
padding: '5px 8px', fontSize: 12, outline: 'none', fontFamily: 'inherit',
|
||||
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
<div style={{ maxHeight: 220, overflowY: 'auto', padding: '4px' }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--text-faint)', textAlign: 'center' }}>—</div>
|
||||
) : (
|
||||
filtered.map(option => {
|
||||
const isSelected = option.value === value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => { onChange(option.value); setOpen(false); setSearch('') }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 10px', borderRadius: 6,
|
||||
border: 'none', background: isSelected ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-primary)', fontSize: 13, fontFamily: 'inherit',
|
||||
cursor: 'pointer', textAlign: 'left', transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = isSelected ? 'var(--bg-hover)' : 'transparent'}
|
||||
>
|
||||
{option.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{option.icon}</span>}
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{option.label}</span>
|
||||
{isSelected && <Check size={13} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes selectIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { Clock, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
function formatDisplay(val, is12h) {
|
||||
if (!val) return ''
|
||||
const [h, m] = val.split(':').map(Number)
|
||||
if (isNaN(h) || isNaN(m)) return val
|
||||
if (!is12h) return val
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
||||
}
|
||||
|
||||
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {} }) {
|
||||
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 [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
|
||||
setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
const update = (newH, newM) => {
|
||||
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}`)
|
||||
}
|
||||
|
||||
const incHour = () => update(((hour ?? -1) + 1) % 24, minute ?? 0)
|
||||
const decHour = () => update(((hour ?? 1) - 1 + 24) % 24, minute ?? 0)
|
||||
const incMin = () => {
|
||||
const newM = ((minute ?? -5) + 5) % 60
|
||||
const newH = newM < (minute ?? 0) ? ((hour ?? 0) + 1) % 24 : (hour ?? 0)
|
||||
update(newH, newM)
|
||||
}
|
||||
const decMin = () => {
|
||||
const newM = ((minute ?? 5) - 5 + 60) % 60
|
||||
const newH = newM > (minute ?? 0) ? ((hour ?? 0) - 1 + 24) % 24 : (hour ?? 0)
|
||||
update(newH, newM)
|
||||
}
|
||||
|
||||
const btnStyle = {
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 2,
|
||||
color: 'var(--text-faint)', display: 'flex', borderRadius: 4,
|
||||
transition: 'color 0.15s',
|
||||
}
|
||||
|
||||
const handleInput = (e) => {
|
||||
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))
|
||||
else if (/^\d{1,2}:\d{2}$/.test(clean)) {
|
||||
const [hh, mm] = clean.split(':')
|
||||
onChange(hh.padStart(2, '0') + ':' + mm)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!value) return
|
||||
const clean = value.replace(/[^0-9:]/g, '')
|
||||
if (/^\d{1,2}:\d{2}$/.test(clean)) {
|
||||
const [hh, mm] = clean.split(':')
|
||||
const h = Math.min(23, Math.max(0, parseInt(hh)))
|
||||
const m = Math.min(59, Math.max(0, parseInt(mm)))
|
||||
onChange(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'))
|
||||
} else if (/^\d{3,4}$/.test(clean)) {
|
||||
const s = clean.padStart(4, '0')
|
||||
const h = Math.min(23, Math.max(0, parseInt(s.slice(0, 2))))
|
||||
const m = Math.min(59, Math.max(0, parseInt(s.slice(2))))
|
||||
onChange(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
||||
<div style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 0,
|
||||
borderRadius: 10, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-input)', overflow: 'hidden',
|
||||
transition: 'border-color 0.15s',
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
value={inputFocused ? value : formatDisplay(value, is12h)}
|
||||
onChange={handleInput}
|
||||
onFocus={() => setInputFocused(true)}
|
||||
onBlur={() => { setInputFocused(false); handleBlur() }}
|
||||
placeholder={is12h ? '2:30 PM' : placeholder}
|
||||
style={{
|
||||
flex: 1, border: 'none', outline: 'none', background: 'transparent',
|
||||
padding: '8px 10px 8px 14px', fontSize: 13, fontFamily: 'inherit',
|
||||
color: value ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
minWidth: 0,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: '8px 10px',
|
||||
display: 'flex', alignItems: 'center', color: 'var(--text-faint)',
|
||||
transition: 'color 0.15s', flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Clock size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed',
|
||||
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
|
||||
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
|
||||
zIndex: 99999,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 12, boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||
padding: 12, display: 'flex', alignItems: 'center', gap: 6,
|
||||
animation: 'selectIn 0.15s ease-out',
|
||||
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
|
||||
}}>
|
||||
{/* Stunden */}
|
||||
<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)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<div style={{
|
||||
width: 44, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
|
||||
background: 'var(--bg-hover)', borderRadius: 8,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{hour !== null ? (is12h ? String(hour === 0 ? 12 : hour > 12 ? hour - 12 : hour) : String(hour).padStart(2, '0')) : '--'}
|
||||
</div>
|
||||
<button type="button" onClick={decHour} style={btnStyle}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text-faint)', marginTop: -2 }}>:</span>
|
||||
|
||||
{/* Minuten */}
|
||||
<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)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<div style={{
|
||||
width: 44, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
|
||||
background: 'var(--bg-hover)', borderRadius: 8,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{minute !== null ? String(minute).padStart(2, '0') : '--'}
|
||||
</div>
|
||||
<button type="button" onClick={decMin} style={btnStyle}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AM/PM Toggle */}
|
||||
{is12h && hour !== null && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, marginLeft: 4 }}>
|
||||
<button type="button" onClick={() => { if (hour < 12) update(hour + 12, minute ?? 0); else update(hour - 12, minute ?? 0) }} style={btnStyle}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<div style={{
|
||||
width: 36, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 14, fontWeight: 700, color: 'var(--text-primary)',
|
||||
background: 'var(--bg-hover)', borderRadius: 8,
|
||||
}}>
|
||||
{hour >= 12 ? 'PM' : 'AM'}
|
||||
</div>
|
||||
<button type="button" onClick={() => { if (hour < 12) update(hour + 12, minute ?? 0); else update(hour - 12, minute ?? 0) }} style={btnStyle}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear */}
|
||||
{value && (
|
||||
<button type="button" onClick={() => { onChange(''); setOpen(false) }}
|
||||
style={{ ...btnStyle, marginLeft: 4, fontSize: 11, color: 'var(--text-faint)', padding: '4px 6px' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<style>{`@keyframes selectIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-2xl',
|
||||
'2xl': 'max-w-4xl',
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'md',
|
||||
footer,
|
||||
hideCloseButton = false,
|
||||
}) {
|
||||
const handleEsc = useCallback((e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isOpen, handleEsc])
|
||||
|
||||
const mouseDownTarget = useRef(null)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center px-4 modal-backdrop"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20 }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
|
||||
mouseDownTarget.current = null
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||
flex flex-col max-h-[90vh]
|
||||
animate-in fade-in zoom-in-95 duration-200
|
||||
`}
|
||||
style={{
|
||||
animation: 'modalIn 0.2s ease-out forwards',
|
||||
background: 'var(--bg-card)',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
||||
{!hideCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="p-6" style={{ borderTop: '1px solid var(--border-secondary)' }}>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { getCategoryIcon } from './categoryIcons'
|
||||
|
||||
const googlePhotoCache = new Map()
|
||||
|
||||
export default function PlaceAvatar({ place, size = 32, category }) {
|
||||
const [photoSrc, setPhotoSrc] = useState(place.image_url || null)
|
||||
|
||||
useEffect(() => {
|
||||
if (place.image_url) { setPhotoSrc(place.image_url); return }
|
||||
if (!place.google_place_id) return
|
||||
|
||||
if (googlePhotoCache.has(place.google_place_id)) {
|
||||
setPhotoSrc(googlePhotoCache.get(place.google_place_id))
|
||||
return
|
||||
}
|
||||
|
||||
mapsApi.placePhoto(place.google_place_id)
|
||||
.then(data => {
|
||||
if (data.photoUrl) {
|
||||
googlePhotoCache.set(place.google_place_id, data.photoUrl)
|
||||
setPhotoSrc(data.photoUrl)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [place.id, place.image_url, place.google_place_id])
|
||||
|
||||
const bgColor = category?.color || '#6366f1'
|
||||
const IconComp = getCategoryIcon(category?.icon)
|
||||
const iconSize = Math.round(size * 0.46)
|
||||
|
||||
const containerStyle = {
|
||||
width: size, height: size,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
backgroundColor: bgColor,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}
|
||||
|
||||
if (photoSrc) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<img
|
||||
src={photoSrc}
|
||||
alt={place.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => setPhotoSrc(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
|
||||
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
|
||||
|
||||
const ToastContext = createContext(null)
|
||||
|
||||
let toastIdCounter = 0
|
||||
|
||||
export function ToastContainer() {
|
||||
const [toasts, setToasts] = useState([])
|
||||
|
||||
const addToast = useCallback((message, type = 'info', duration = 3000) => {
|
||||
const id = ++toastIdCounter
|
||||
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, 300)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const removeToast = useCallback((id) => {
|
||||
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 = {
|
||||
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 = {
|
||||
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',
|
||||
info: 'bg-white border-l-4 border-blue-500',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`
|
||||
${bgColors[toast.type] || bgColors.info}
|
||||
${toast.removing ? 'toast-exit' : 'toast-enter'}
|
||||
flex items-start gap-3 p-4 rounded-lg shadow-lg pointer-events-auto
|
||||
min-w-0
|
||||
`}
|
||||
>
|
||||
{icons[toast.type] || icons.info}
|
||||
<p className="text-sm text-slate-700 flex-1 leading-relaxed">{toast.message}</p>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="text-slate-400 hover:text-slate-600 transition-colors flex-shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const useToast = () => {
|
||||
const show = useCallback((message, type, duration) => {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
export default useToast
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
MapPin, Building2, BedDouble, UtensilsCrossed, Landmark, ShoppingBag,
|
||||
Bus, Train, Car, Plane, Ship, Bike,
|
||||
Activity, Dumbbell, Mountain, Tent, Anchor,
|
||||
Coffee, Beer, Wine, Utensils,
|
||||
Camera, Music, Theater, Ticket,
|
||||
TreePine, Waves, Leaf, Flower2, Sun,
|
||||
Globe, Compass, Flag, Navigation, Map,
|
||||
Church, Library, Store, Home, Cross,
|
||||
Heart, Star, CreditCard, Wifi,
|
||||
Luggage, Backpack, Zap,
|
||||
} from 'lucide-react'
|
||||
|
||||
export const CATEGORY_ICON_MAP = {
|
||||
MapPin, Building2, BedDouble, UtensilsCrossed, Landmark, ShoppingBag,
|
||||
Bus, Train, Car, Plane, Ship, Bike,
|
||||
Activity, Dumbbell, Mountain, Tent, Anchor,
|
||||
Coffee, Beer, Wine, Utensils,
|
||||
Camera, Music, Theater, Ticket,
|
||||
TreePine, Waves, Leaf, Flower2, Sun,
|
||||
Globe, Compass, Flag, Navigation, Map,
|
||||
Church, Library, Store, Home, Cross,
|
||||
Heart, Star, CreditCard, Wifi,
|
||||
Luggage, Backpack, Zap,
|
||||
}
|
||||
|
||||
export const ICON_LABELS = {
|
||||
MapPin: 'Pin', Building2: 'Gebäude', BedDouble: 'Hotel', UtensilsCrossed: 'Restaurant',
|
||||
Landmark: 'Sehenswürdigkeit', ShoppingBag: 'Shopping', Bus: 'Bus', Train: 'Zug',
|
||||
Car: 'Auto', Plane: 'Flugzeug', Ship: 'Schiff', Bike: 'Fahrrad',
|
||||
Activity: 'Aktivität', Dumbbell: 'Fitness', Mountain: 'Berg', Tent: 'Camping',
|
||||
Anchor: 'Hafen', Coffee: 'Café', Beer: 'Bar', Wine: 'Wein', Utensils: 'Essen',
|
||||
Camera: 'Foto', Music: 'Musik', Theater: 'Theater', Ticket: 'Events',
|
||||
TreePine: 'Natur', Waves: 'Strand', Leaf: 'Grün', Flower2: 'Garten', Sun: 'Sonne',
|
||||
Globe: 'Welt', Compass: 'Erkundung', Flag: 'Flagge', Navigation: 'Navigation', Map: 'Karte',
|
||||
Church: 'Kirche', Library: 'Museum', Store: 'Markt', Home: 'Unterkunft', Cross: 'Medizin',
|
||||
Heart: 'Favorit', Star: 'Top', CreditCard: 'Bank', Wifi: 'Internet',
|
||||
Luggage: 'Gepäck', Backpack: 'Rucksack', Zap: 'Abenteuer',
|
||||
}
|
||||
|
||||
export function getCategoryIcon(iconName) {
|
||||
return CATEGORY_ICON_MAP[iconName] || MapPin
|
||||
}
|
||||
Reference in New Issue
Block a user