Merge branch 'main' into dev
This commit is contained in:
@@ -136,8 +136,21 @@ interface AddonRowProps {
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
|
||||
const nameKey = `admin.addons.catalog.${addon.id}.name`
|
||||
const descKey = `admin.addons.catalog.${addon.id}.description`
|
||||
const translatedName = t(nameKey)
|
||||
const translatedDescription = t(descKey)
|
||||
|
||||
return {
|
||||
name: translatedName !== nameKey ? translatedName : addon.name,
|
||||
description: translatedDescription !== descKey ? translatedDescription : addon.description,
|
||||
}
|
||||
}
|
||||
|
||||
function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||
const isComingSoon = false
|
||||
const label = getAddonLabel(t, addon)
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||
{/* Icon */}
|
||||
@@ -148,7 +161,7 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{addon.name}</span>
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{label.name}</span>
|
||||
{isComingSoon && (
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
|
||||
Coming Soon
|
||||
@@ -161,7 +174,7 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||
{addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{addon.description}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{label.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import apiClient from '../../api/client'
|
||||
|
||||
const REPO = 'mauriceboe/NOMAD'
|
||||
@@ -46,7 +46,7 @@ export default function GitHubPanel() {
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
||||
|
||||
@@ -23,9 +23,9 @@ const POPULAR_ZONES = [
|
||||
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
||||
]
|
||||
|
||||
function getTime(tz) {
|
||||
function getTime(tz, locale) {
|
||||
try {
|
||||
return new Date().toLocaleTimeString('de-DE', { timeZone: tz, hour: '2-digit', minute: '2-digit' })
|
||||
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit' })
|
||||
} catch { return '—' }
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ function getOffset(tz) {
|
||||
}
|
||||
|
||||
export default function TimezoneWidget() {
|
||||
const { t } = useTranslation()
|
||||
const { t, locale } = useTranslation()
|
||||
const [zones, setZones] = useState(() => {
|
||||
const saved = localStorage.getItem('dashboard_timezones')
|
||||
return saved ? JSON.parse(saved) : [
|
||||
@@ -51,6 +51,9 @@ export default function TimezoneWidget() {
|
||||
})
|
||||
const [now, setNow] = useState(Date.now())
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [customLabel, setCustomLabel] = useState('')
|
||||
const [customTz, setCustomTz] = useState('')
|
||||
const [customError, setCustomError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const i = setInterval(() => setNow(Date.now()), 10000)
|
||||
@@ -61,6 +64,20 @@ export default function TimezoneWidget() {
|
||||
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
|
||||
}, [zones])
|
||||
|
||||
const isValidTz = (tz: string) => {
|
||||
try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false }
|
||||
}
|
||||
|
||||
const addCustomZone = () => {
|
||||
const tz = customTz.trim()
|
||||
if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return }
|
||||
if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return }
|
||||
if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return }
|
||||
const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz
|
||||
setZones([...zones, { label, tz }])
|
||||
setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false)
|
||||
}
|
||||
|
||||
const addZone = (zone) => {
|
||||
if (!zones.find(z => z.tz === zone.tz)) {
|
||||
setZones([...zones, zone])
|
||||
@@ -70,7 +87,7 @@ export default function TimezoneWidget() {
|
||||
|
||||
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
|
||||
|
||||
const localTime = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
const localTime = new Date().toLocaleTimeString(locale, { 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)
|
||||
@@ -96,7 +113,7 @@ export default function TimezoneWidget() {
|
||||
{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-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale)}</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)' }}>
|
||||
@@ -108,7 +125,29 @@ export default function TimezoneWidget() {
|
||||
|
||||
{/* Add zone dropdown */}
|
||||
{showAdd && (
|
||||
<div className="mt-2 rounded-xl p-2 max-h-[200px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<div className="mt-2 rounded-xl p-2 max-h-[280px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
|
||||
{/* Custom timezone */}
|
||||
<div className="px-2 py-2 mb-2 rounded-lg" style={{ background: 'var(--bg-card)' }}>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezoneCustomTitle')}</p>
|
||||
<div className="space-y-1.5">
|
||||
<input value={customLabel} onChange={e => setCustomLabel(e.target.value)}
|
||||
placeholder={t('dashboard.timezoneCustomLabelPlaceholder')}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} />
|
||||
<input value={customTz} onChange={e => { setCustomTz(e.target.value); setCustomError('') }}
|
||||
placeholder={t('dashboard.timezoneCustomTzPlaceholder')}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} />
|
||||
{customError && <p className="text-[10px]" style={{ color: '#ef4444' }}>{customError}</p>}
|
||||
<button onClick={addCustomZone}
|
||||
className="w-full py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||
{t('dashboard.timezoneCustomAdd')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Popular zones */}
|
||||
{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"
|
||||
@@ -116,7 +155,7 @@ export default function TimezoneWidget() {
|
||||
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>
|
||||
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -86,6 +86,38 @@ const texts: Record<string, DemoTexts> = {
|
||||
selfHostLink: 'self-host it',
|
||||
close: 'Got it',
|
||||
},
|
||||
es: {
|
||||
titleBefore: 'Bienvenido a ',
|
||||
titleAfter: '',
|
||||
title: 'Bienvenido a la demo de TREK',
|
||||
description: 'Puedes ver, editar y crear viajes. Todos los cambios se restablecen automáticamente cada hora.',
|
||||
resetIn: 'Próximo reinicio en',
|
||||
minutes: 'minutos',
|
||||
uploadNote: 'Las subidas de archivos (fotos, documentos, portadas) están desactivadas en el modo demo.',
|
||||
fullVersionTitle: 'Además, en la versión completa:',
|
||||
features: [
|
||||
'Subida de archivos (fotos, documentos, portadas)',
|
||||
'Gestión de claves API (Google Maps, tiempo)',
|
||||
'Gestión de usuarios y permisos',
|
||||
'Copias de seguridad automáticas',
|
||||
'Gestión de addons (activar/desactivar)',
|
||||
'Inicio de sesión único OIDC / SSO',
|
||||
],
|
||||
addonsTitle: 'Complementos modulares (se pueden desactivar en la versión completa)',
|
||||
addons: [
|
||||
['Vacaciones', 'Planificador de vacaciones con calendario, festivos y fusión de usuarios'],
|
||||
['Atlas', 'Mapa del mundo con países visitados y estadísticas de viaje'],
|
||||
['Equipaje', 'Listas de comprobación para cada viaje'],
|
||||
['Presupuesto', 'Control de gastos con reparto'],
|
||||
['Documentos', 'Adjunta archivos a los viajes'],
|
||||
['Widgets', 'Conversor de divisas y zonas horarias'],
|
||||
],
|
||||
whatIs: '¿Qué es TREK?',
|
||||
whatIsDesc: 'Un planificador de viajes autohospedado con colaboración en tiempo real, mapas interactivos, inicio de sesión OIDC y modo oscuro.',
|
||||
selfHost: 'Código abierto — ',
|
||||
selfHostLink: 'alójalo tú mismo',
|
||||
close: 'Entendido',
|
||||
},
|
||||
}
|
||||
|
||||
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
|
||||
@@ -159,7 +191,7 @@ export default function DemoBanner(): React.ReactElement | null {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<Map size={14} style={{ color: '#111827' }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{language === 'de' ? 'Was ist ' : 'What is '}<img src="/text-dark.svg" alt="TREK" style={{ height: 13, marginRight: -2 }} />?
|
||||
{t.whatIs}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
|
||||
|
||||
@@ -67,6 +67,12 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
||||
}
|
||||
|
||||
const getAddonName = (addon: Addon): string => {
|
||||
const key = `admin.addons.catalog.${addon.id}.name`
|
||||
const translated = t(key)
|
||||
return translated !== key ? translated : addon.name
|
||||
}
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
|
||||
@@ -124,7 +130,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent' }}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span className="hidden md:inline">{addon.name}</span>
|
||||
<span className="hidden md:inline">{getAddonName(addon)}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -165,7 +165,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
|
||||
const chips = [
|
||||
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
|
||||
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString('de-DE')} EUR</span>` : '',
|
||||
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString(loc)} EUR</span>` : '',
|
||||
].filter(Boolean).join('')
|
||||
|
||||
return `
|
||||
@@ -377,7 +377,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
|
||||
</div>
|
||||
${totalCost > 0 ? `<div>
|
||||
<div class="cover-stat-num">${totalCost.toLocaleString('de-DE')}</div>
|
||||
<div class="cover-stat-num">${totalCost.toLocaleString(loc)}</div>
|
||||
<div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PhotoLightbox } from './PhotoLightbox'
|
||||
import { PhotoUpload } from './PhotoUpload'
|
||||
import { Upload, Camera } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import type { Photo, Place, Day } from '../../types'
|
||||
|
||||
interface PhotoGalleryProps {
|
||||
@@ -17,7 +17,7 @@ interface PhotoGalleryProps {
|
||||
}
|
||||
|
||||
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }: PhotoGalleryProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t, language } = useTranslation()
|
||||
const [lightboxIndex, setLightboxIndex] = useState(null)
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
const [filterDayId, setFilterDayId] = useState('')
|
||||
@@ -53,7 +53,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
||||
<div style={{ marginRight: 'auto' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>Fotos</h2>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: '#9ca3af' }}>
|
||||
{photos.length} Foto{photos.length !== 1 ? 's' : ''}
|
||||
{photos.length} {photos.length !== 1 ? 'Fotos' : 'Foto'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
||||
<option value="">{t('photos.allDays')}</option>
|
||||
{(days || []).map(day => (
|
||||
<option key={day.id} value={day.id}>
|
||||
Tag {day.day_number}{day.date ? ` · ${formatDate(day.date)}` : ''}
|
||||
{t('planner.dayN', { n: day.day_number })}{day.date ? ` · ${formatDate(day.date, getLocaleForLanguage(language))}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -84,7 +84,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Fotos hochladen
|
||||
{t('common.upload')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
||||
style={{ display: 'inline-flex', margin: '0 auto' }}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Fotos hochladen
|
||||
{t('common.upload')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -146,7 +146,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
||||
<Modal
|
||||
isOpen={showUpload}
|
||||
onClose={() => setShowUpload(false)}
|
||||
title="Fotos hochladen"
|
||||
title={t('common.upload')}
|
||||
size="lg"
|
||||
>
|
||||
<PhotoUpload
|
||||
@@ -211,7 +211,7 @@ function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
function formatDate(dateStr, locale) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
@@ -227,10 +227,10 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
function formatDate(dateStr, locale = 'en-US') {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
return new Date(dateStr).toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { weatherApi, accommodationsApi } from '../../api/client'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
||||
|
||||
const WEATHER_ICON_MAP = {
|
||||
@@ -53,7 +53,7 @@ interface DayDetailPanelProps {
|
||||
}
|
||||
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }: DayDetailPanelProps) {
|
||||
const { t, language } = useTranslation()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const fmtTime = (v) => formatTime12(v, is12h)
|
||||
@@ -138,7 +138,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
if (!day) return null
|
||||
|
||||
const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString(
|
||||
language === 'de' ? 'de-DE' : 'en-US',
|
||||
getLocaleForLanguage(language),
|
||||
{ weekday: 'long', day: 'numeric', month: 'long' }
|
||||
) : null
|
||||
|
||||
@@ -270,7 +270,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
</div>
|
||||
{r.reservation_time?.includes('T') && (
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{new Date(r.reservation_time).toLocaleTimeString(language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit', hour12: is12h })}
|
||||
{new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })}
|
||||
{r.reservation_end_time && ` – ${fmtTime(r.reservation_end_time)}`}
|
||||
</span>
|
||||
)}
|
||||
@@ -423,7 +423,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
||||
options={days.map((d, i) => ({
|
||||
value: d.id,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short' })}` : ''}`,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
|
||||
}))}
|
||||
size="sm"
|
||||
/>
|
||||
@@ -435,7 +435,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
||||
options={days.map((d, i) => ({
|
||||
value: d.id,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short' })}` : ''}`,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
|
||||
}))}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
@@ -281,6 +281,15 @@ export default function PlaceFormModal({
|
||||
step="any"
|
||||
value={form.lat}
|
||||
onChange={e => handleChange('lat', e.target.value)}
|
||||
onPaste={e => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
|
||||
if (match) {
|
||||
e.preventDefault()
|
||||
handleChange('lat', match[1])
|
||||
handleChange('lng', match[2])
|
||||
}
|
||||
}}
|
||||
placeholder={t('places.formLat')}
|
||||
className="form-input"
|
||||
/>
|
||||
|
||||
@@ -312,7 +312,7 @@ export default function PlaceInspector({
|
||||
icon={<Star size={12} fill="#facc15" color="#facc15" />}
|
||||
text={<>
|
||||
{googleDetails.rating.toFixed(1)}
|
||||
{googleDetails.rating_count ? <span style={{ opacity: 0.5 }}> ({googleDetails.rating_count.toLocaleString('de-DE')})</span> : ''}
|
||||
{googleDetails.rating_count ? <span style={{ opacity: 0.5 }}> ({googleDetails.rating_count.toLocaleString(locale)})</span> : ''}
|
||||
{shortReview && <span className="hidden md:inline" style={{ opacity: 0.6, fontWeight: 400, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> · „{shortReview.text}"</span>}
|
||||
</>}
|
||||
color="var(--text-secondary)" bg="var(--bg-hover)"
|
||||
|
||||
@@ -5,8 +5,10 @@ import type { HolidaysMap, VacayEntry } from '../../types'
|
||||
|
||||
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const WEEKDAYS_ES = ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sa', 'Do']
|
||||
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
||||
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
||||
const MONTHS_ES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']
|
||||
|
||||
interface VacayMonthCardProps {
|
||||
year: number
|
||||
@@ -25,8 +27,8 @@ export default function VacayMonthCard({
|
||||
onCellClick, companyMode, blockWeekends
|
||||
}: VacayMonthCardProps) {
|
||||
const { language } = useTranslation()
|
||||
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
|
||||
const monthNames = language === 'de' ? MONTHS_DE : MONTHS_EN
|
||||
const weekdays = language === 'de' ? WEEKDAYS_DE : language === 'es' ? WEEKDAYS_ES : WEEKDAYS_EN
|
||||
const monthNames = language === 'de' ? MONTHS_DE : language === 'es' ? MONTHS_ES : MONTHS_EN
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const firstDay = new Date(year, month, 1)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getIntlLanguage, useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import apiClient from '../../api/client'
|
||||
@@ -24,7 +24,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
useEffect(() => {
|
||||
apiClient.get('/addons/vacay/holidays/countries').then(r => {
|
||||
let displayNames
|
||||
try { displayNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ }
|
||||
try { displayNames = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }) } catch { /* */ }
|
||||
const list = r.data.map(c => ({
|
||||
value: c.countryCode,
|
||||
label: displayNames ? (displayNames.of(c.countryCode) || c.name) : c.name,
|
||||
@@ -49,7 +49,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
})
|
||||
if (allCounties.size > 0) {
|
||||
let subdivisionNames
|
||||
try { subdivisionNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ }
|
||||
try { subdivisionNames = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }) } catch { /* */ }
|
||||
const regionList = [...allCounties].sort().map(c => {
|
||||
let label = c.split('-')[1] || c
|
||||
// Try Intl for full subdivision name (not all browsers support subdivision codes)
|
||||
|
||||
@@ -2,10 +2,20 @@ import React, { createContext, useContext, useMemo, ReactNode } from 'react'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import de from './translations/de'
|
||||
import en from './translations/en'
|
||||
import es from './translations/es'
|
||||
|
||||
type TranslationStrings = Record<string, string>
|
||||
|
||||
const translations: Record<string, TranslationStrings> = { de, en }
|
||||
const translations: Record<string, TranslationStrings> = { de, en, es }
|
||||
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES' }
|
||||
|
||||
export function getLocaleForLanguage(language: string): string {
|
||||
return LOCALES[language] || LOCALES.en
|
||||
}
|
||||
|
||||
export function getIntlLanguage(language: string): string {
|
||||
return language === 'de' || language === 'es' ? language : 'en'
|
||||
}
|
||||
|
||||
interface TranslationContextValue {
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
@@ -13,18 +23,18 @@ interface TranslationContextValue {
|
||||
locale: string
|
||||
}
|
||||
|
||||
const TranslationContext = createContext<TranslationContextValue>({ t: (k: string) => k, language: 'de', locale: 'de-DE' })
|
||||
const TranslationContext = createContext<TranslationContextValue>({ t: (k: string) => k, language: 'en', locale: 'en-US' })
|
||||
|
||||
interface TranslationProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function TranslationProvider({ children }: TranslationProviderProps) {
|
||||
const language = useSettingsStore((s) => s.settings.language) || 'de'
|
||||
const language = useSettingsStore((s) => s.settings.language) || 'en'
|
||||
|
||||
const value = useMemo((): TranslationContextValue => {
|
||||
const strings = translations[language] || translations.de
|
||||
const fallback = translations.de
|
||||
const strings = translations[language] || translations.en
|
||||
const fallback = translations.en
|
||||
|
||||
function t(key: string, params?: Record<string, string | number>): string {
|
||||
let val: string = strings[key] ?? fallback[key] ?? key
|
||||
@@ -36,7 +46,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
|
||||
return val
|
||||
}
|
||||
|
||||
return { t, language, locale: language === 'en' ? 'en-US' : 'de-DE' }
|
||||
return { t, language, locale: getLocaleForLanguage(language) }
|
||||
}, [language])
|
||||
|
||||
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { TranslationProvider, useTranslation } from './TranslationContext'
|
||||
export { TranslationProvider, useTranslation, getLocaleForLanguage, getIntlLanguage } from './TranslationContext'
|
||||
|
||||
@@ -54,6 +54,13 @@ const de: Record<string, string> = {
|
||||
'dashboard.currency': 'Währung',
|
||||
'dashboard.timezone': 'Zeitzonen',
|
||||
'dashboard.localTime': 'Lokal',
|
||||
'dashboard.timezoneCustomTitle': 'Eigene Zeitzone',
|
||||
'dashboard.timezoneCustomLabelPlaceholder': 'Bezeichnung (optional)',
|
||||
'dashboard.timezoneCustomTzPlaceholder': 'z.B. America/New_York',
|
||||
'dashboard.timezoneCustomAdd': 'Hinzufügen',
|
||||
'dashboard.timezoneCustomErrorEmpty': 'Zeitzone eingeben',
|
||||
'dashboard.timezoneCustomErrorInvalid': 'Ungültige Zeitzone. Format: Europe/Berlin',
|
||||
'dashboard.timezoneCustomErrorDuplicate': 'Bereits hinzugefügt',
|
||||
'dashboard.emptyTitle': 'Noch keine Reisen',
|
||||
'dashboard.emptyText': 'Erstelle deine erste Reise und beginne mit der Planung von Orten, Tagesabläufen und Packlisten.',
|
||||
'dashboard.emptyButton': 'Erste Reise erstellen',
|
||||
|
||||
@@ -54,6 +54,13 @@ const en: Record<string, string> = {
|
||||
'dashboard.currency': 'Currency',
|
||||
'dashboard.timezone': 'Timezones',
|
||||
'dashboard.localTime': 'Local',
|
||||
'dashboard.timezoneCustomTitle': 'Custom Timezone',
|
||||
'dashboard.timezoneCustomLabelPlaceholder': 'Label (optional)',
|
||||
'dashboard.timezoneCustomTzPlaceholder': 'e.g. America/New_York',
|
||||
'dashboard.timezoneCustomAdd': 'Add',
|
||||
'dashboard.timezoneCustomErrorEmpty': 'Enter a timezone identifier',
|
||||
'dashboard.timezoneCustomErrorInvalid': 'Invalid timezone. Use format like Europe/Berlin',
|
||||
'dashboard.timezoneCustomErrorDuplicate': 'Already added',
|
||||
'dashboard.emptyTitle': 'No trips yet',
|
||||
'dashboard.emptyText': 'Create your first trip and start planning!',
|
||||
'dashboard.emptyButton': 'Create First Trip',
|
||||
|
||||
1120
client/src/i18n/translations/es.ts
Normal file
1120
client/src/i18n/translations/es.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import apiClient from '../api/client'
|
||||
@@ -100,7 +100,7 @@ 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' })
|
||||
const dn = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' })
|
||||
setResolver(() => (code: string) => { try { return dn.of(code) || code } catch { return code } })
|
||||
} catch { /* */ }
|
||||
}, [language])
|
||||
@@ -255,7 +255,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
const c = countryMap[a3]
|
||||
if (c) {
|
||||
const name = resolveName(c.code)
|
||||
const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { month: 'short', year: 'numeric' }) }
|
||||
const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(getLocaleForLanguage(language), { month: 'short', year: 'numeric' }) }
|
||||
const tooltipHtml = `
|
||||
<div style="display:flex;flex-direction:column;gap:8px;min-width:160px">
|
||||
<div style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;padding-bottom:6px;border-bottom:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}">${name}</div>
|
||||
@@ -515,4 +515,3 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,12 +53,12 @@ function getTripStatus(trip: DashboardTrip): string | null {
|
||||
return 'past'
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null | undefined, locale: string = 'de-DE'): string | null {
|
||||
function formatDate(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function formatDateShort(dateStr: string | null | undefined, locale: string = 'de-DE'): string | null {
|
||||
function formatDateShort(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
@@ -270,9 +270,14 @@ export default function LoginPage(): React.ReactElement {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
|
||||
|
||||
{/* Sprach-Toggle oben rechts */}
|
||||
{/* Language toggle */}
|
||||
<button
|
||||
onClick={() => setLanguageLocal(language === 'en' ? 'de' : 'en')}
|
||||
onClick={() => {
|
||||
const languages = ['en', 'es', 'de']
|
||||
const currentIndex = languages.indexOf(language)
|
||||
const nextLanguage = languages[(currentIndex + 1) % languages.length]
|
||||
setLanguageLocal(nextLanguage)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute', top: 16, right: 16, zIndex: 10,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
@@ -286,7 +291,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
|
||||
>
|
||||
<Globe size={14} />
|
||||
{language === 'en' ? 'EN' : 'DE'}
|
||||
{language.toUpperCase()}
|
||||
</button>
|
||||
|
||||
{/* Left — branding */}
|
||||
|
||||
@@ -269,6 +269,7 @@ export default function SettingsPage(): React.ReactElement {
|
||||
{[
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
|
||||
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
|
||||
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
|
||||
...(enabledAddons.collab ? [{ id: 'collab', label: 'Collab' }] : []),
|
||||
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name') }] : []),
|
||||
]
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function VacayPage(): React.ReactElement {
|
||||
<CalendarDays size={18} style={{ color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-bold" style={{ color: 'var(--text-primary)' }}>Vacay</h1>
|
||||
<h1 className="text-lg sm:text-xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.catalog.vacay.name')}</h1>
|
||||
<p className="text-xs hidden sm:block" style={{ color: 'var(--text-muted)' }}>{t('vacay.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user