mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
384d583628
The biggest NOMAD update yet. Introduces a modular addon architecture and three major new features. Addon System: - Admin panel addon management with enable/disable toggles - Trip addons (Packing List, Budget, Documents) dynamically show/hide in trip tabs - Global addons appear in the main navigation for all users Vacay — Vacation Day Planner (Global Addon): - Monthly calendar view with international public holidays (100+ countries via Nager.Date API) - Company holidays with auto-cleanup of conflicting entries - User-based system: each NOMAD user is a person in the calendar - Fusion system: invite other users to share a combined calendar with real-time WebSocket sync - Vacation entitlement tracking with automatic carry-over to next year - Full settings: block weekends, public holidays, company holidays, carry-over toggle - Invite/accept/decline flow with forced confirmation modal - Color management per user with collision detection on fusion - Dissolve fusion with preserved entries Atlas — Travel World Map (Global Addon): - Fullscreen Leaflet world map with colored country polygons (GeoJSON) - Glass-effect bottom panel with stats, continent breakdown, streak tracking - Country tooltips with trip count, places visited, first/last visit dates - Liquid glass hover effect on the stats panel - Canvas renderer with tile preloading for maximum performance - Responsive: mobile stats bars, no zoom controls on touch Dashboard Widgets: - Currency converter with 50 currencies, CustomSelect dropdowns, localStorage persistence - Timezone widget with customizable city list, live updating clock - Per-user toggle via settings button, bottom sheet on mobile Admin Panel: - Consistent dark mode across all tabs (CSS variable overrides) - Online/offline status badges on user list via WebSocket - Unified heading sizes and subtitles across all sections - Responsive tab grid on mobile Mobile Improvements: - Vacay: slide-in sidebar drawer, floating toolbar, responsive calendar grid - Atlas: top/bottom glass stat bars, no popups - Trip Planner: fixed position content container prevents overscroll, portal-based sidebar buttons - Dashboard: fixed viewport container, mobile widget bottom sheet - Admin: responsive tab grid, compact buttons - Global: overscroll-behavior fixes, modal scroll containment Other: - Trip tab labels: Planung→Karte, Packliste→Liste, Buchungen→Buchung (DE mobile) - Reservation form responsive layout - Backup panel responsive buttons
127 lines
5.3 KiB
React
127 lines
5.3 KiB
React
import React, { useState, useEffect } from 'react'
|
|
import { Clock, Plus, X } from 'lucide-react'
|
|
import { useTranslation } from '../../i18n'
|
|
|
|
const POPULAR_ZONES = [
|
|
{ label: 'New York', tz: 'America/New_York' },
|
|
{ label: 'London', tz: 'Europe/London' },
|
|
{ label: 'Berlin', tz: 'Europe/Berlin' },
|
|
{ label: 'Paris', tz: 'Europe/Paris' },
|
|
{ label: 'Dubai', tz: 'Asia/Dubai' },
|
|
{ label: 'Mumbai', tz: 'Asia/Kolkata' },
|
|
{ label: 'Bangkok', tz: 'Asia/Bangkok' },
|
|
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
|
|
{ label: 'Sydney', tz: 'Australia/Sydney' },
|
|
{ label: 'Los Angeles', tz: 'America/Los_Angeles' },
|
|
{ label: 'Chicago', tz: 'America/Chicago' },
|
|
{ label: 'São Paulo', tz: 'America/Sao_Paulo' },
|
|
{ label: 'Istanbul', tz: 'Europe/Istanbul' },
|
|
{ label: 'Singapore', tz: 'Asia/Singapore' },
|
|
{ label: 'Hong Kong', tz: 'Asia/Hong_Kong' },
|
|
{ label: 'Seoul', tz: 'Asia/Seoul' },
|
|
{ label: 'Moscow', tz: 'Europe/Moscow' },
|
|
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
|
]
|
|
|
|
function getTime(tz) {
|
|
try {
|
|
return new Date().toLocaleTimeString('de-DE', { timeZone: tz, hour: '2-digit', minute: '2-digit' })
|
|
} catch { return '—' }
|
|
}
|
|
|
|
function getOffset(tz) {
|
|
try {
|
|
const now = new Date()
|
|
const local = new Date(now.toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }))
|
|
const remote = new Date(now.toLocaleString('en-US', { timeZone: tz }))
|
|
const diff = (remote - local) / 3600000
|
|
const sign = diff >= 0 ? '+' : ''
|
|
return `${sign}${diff}h`
|
|
} catch { return '' }
|
|
}
|
|
|
|
export default function TimezoneWidget() {
|
|
const { t } = useTranslation()
|
|
const [zones, setZones] = useState(() => {
|
|
const saved = localStorage.getItem('dashboard_timezones')
|
|
return saved ? JSON.parse(saved) : [
|
|
{ label: 'New York', tz: 'America/New_York' },
|
|
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
|
|
]
|
|
})
|
|
const [now, setNow] = useState(Date.now())
|
|
const [showAdd, setShowAdd] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const i = setInterval(() => setNow(Date.now()), 10000)
|
|
return () => clearInterval(i)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
|
|
}, [zones])
|
|
|
|
const addZone = (zone) => {
|
|
if (!zones.find(z => z.tz === zone.tz)) {
|
|
setZones([...zones, zone])
|
|
}
|
|
setShowAdd(false)
|
|
}
|
|
|
|
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
|
|
|
|
const localTime = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
|
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
|
|
// Show abbreviated timezone name (e.g. CET, CEST, EST)
|
|
const tzAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop()
|
|
|
|
return (
|
|
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezone')}</span>
|
|
<button onClick={() => setShowAdd(!showAdd)} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
|
|
<Plus size={12} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Local time */}
|
|
<div className="mb-3 pb-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
|
<p className="text-2xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>{localTime}</p>
|
|
<p className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{localZone} ({tzAbbr}) · {t('dashboard.localTime')}</p>
|
|
</div>
|
|
|
|
{/* Zone list */}
|
|
<div className="space-y-2">
|
|
{zones.map(z => (
|
|
<div key={z.tz} className="flex items-center justify-between group">
|
|
<div>
|
|
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz)}</p>
|
|
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
|
|
</div>
|
|
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
|
|
<X size={11} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Add zone dropdown */}
|
|
{showAdd && (
|
|
<div className="mt-2 rounded-xl p-2 max-h-[200px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
|
|
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
|
|
<button key={z.tz} onClick={() => addZone(z)}
|
|
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
|
|
style={{ color: 'var(--text-primary)' }}
|
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
|
<span className="font-medium">{z.label}</span>
|
|
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|