mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4607e426c | |||
| faa8c84655 | |||
| 88dca41ef7 | |||
| 33162123af | |||
| 10662e0b63 | |||
| 8286fa8591 |
@@ -43,7 +43,7 @@
|
||||
- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed)
|
||||
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
|
||||
- **Route Optimization** — Auto-optimize place order and export to Google Maps
|
||||
- **Weather Forecasts** — Current weather and 5-day forecasts with smart caching
|
||||
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
|
||||
|
||||
### Travel Management
|
||||
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
|
||||
@@ -71,7 +71,7 @@
|
||||
### Customization & Admin
|
||||
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
||||
- **Multilingual** — English and German (i18n)
|
||||
- **Admin Panel** — User management, global categories, addon management, API keys, and backups
|
||||
- **Admin Panel** — User management, global categories, addon management, API keys, backups, and GitHub release history
|
||||
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
||||
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
- **State**: Zustand
|
||||
- **Auth**: JWT + OIDC
|
||||
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
||||
- **Weather**: OpenWeatherMap API (optional)
|
||||
- **Weather**: Open-Meteo API (free, no key required)
|
||||
- **Icons**: lucide-react
|
||||
|
||||
## Quick Start
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nomad-client",
|
||||
"version": "2.5.2",
|
||||
"version": "2.5.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nomad-client",
|
||||
"version": "2.5.2",
|
||||
"version": "2.5.5",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nomad-client",
|
||||
"version": "2.5.4",
|
||||
"version": "2.5.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -167,7 +167,7 @@ export const reservationsApi = {
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
get: (lat, lng, date) => apiClient.get('/weather', { params: { lat, lng, date, units: 'metric' } }).then(r => r.data),
|
||||
get: (lat, lng, date) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
const REPO = 'mauriceboe/NOMAD'
|
||||
const PER_PAGE = 10
|
||||
|
||||
export default function GitHubPanel() {
|
||||
const { t, language } = useTranslation()
|
||||
const [releases, setReleases] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [expanded, setExpanded] = useState({})
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
const fetchReleases = async (pageNum = 1, append = false) => {
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=${PER_PAGE}&page=${pageNum}`)
|
||||
if (!res.ok) throw new Error(`GitHub API: ${res.status}`)
|
||||
const data = await res.json()
|
||||
setReleases(prev => append ? [...prev, ...data] : data)
|
||||
setHasMore(data.length === PER_PAGE)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchReleases(1).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
const next = page + 1
|
||||
setLoadingMore(true)
|
||||
await fetchReleases(next, true)
|
||||
setPage(next)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
setExpanded(prev => ({ ...prev, [id]: !prev[id] }))
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
||||
const renderBody = (body) => {
|
||||
if (!body) return null
|
||||
const lines = body.split('\n')
|
||||
const elements = []
|
||||
let listItems = []
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
elements.push(
|
||||
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
|
||||
{listItems.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
|
||||
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
listItems = []
|
||||
}
|
||||
}
|
||||
|
||||
const inlineFormat = (text) => {
|
||||
return text
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">$1</a>')
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) { flushList(); continue }
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList()
|
||||
elements.push(
|
||||
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{trimmed.slice(4)}
|
||||
</h4>
|
||||
)
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList()
|
||||
elements.push(
|
||||
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{trimmed.slice(3)}
|
||||
</h3>
|
||||
)
|
||||
} else if (/^[-*] /.test(trimmed)) {
|
||||
listItems.push(trimmed.slice(2))
|
||||
} else {
|
||||
flushList()
|
||||
elements.push(
|
||||
<p key={elements.length} className="text-xs my-1" style={{ color: 'var(--text-muted)' }}
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
flushList()
|
||||
return elements
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="p-8 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Header card */}
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<div>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.github.title')}</h2>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="px-5 py-4">
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
|
||||
|
||||
<div className="space-y-0">
|
||||
{releases.map((release, idx) => {
|
||||
const isLatest = idx === 0
|
||||
const isExpanded = expanded[release.id]
|
||||
|
||||
return (
|
||||
<div key={release.id} className="relative pl-8 pb-5">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className="absolute left-0 top-1 w-[23px] h-[23px] rounded-full flex items-center justify-center border-2"
|
||||
style={{
|
||||
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
|
||||
</div>
|
||||
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{release.tag_name}
|
||||
</span>
|
||||
{isLatest && (
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
||||
style={{ background: 'rgba(34,197,94,0.12)', color: '#16a34a' }}>
|
||||
{t('admin.github.latest')}
|
||||
</span>
|
||||
)}
|
||||
{release.prerelease && (
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
||||
style={{ background: 'rgba(245,158,11,0.12)', color: '#d97706' }}>
|
||||
{t('admin.github.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="text-xs font-medium mt-0.5" style={{ color: 'var(--text-muted)' }}>
|
||||
{release.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
||||
<Calendar size={10} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 p-3 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
{renderBody(release.body)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
>
|
||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -138,14 +139,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
{/* Lightbox */}
|
||||
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
|
||||
|
||||
{/* Datei-Vorschau Modal */}
|
||||
{previewFile && (
|
||||
{/* Datei-Vorschau Modal — portal to body to escape stacking context */}
|
||||
{previewFile && ReactDOM.createPortal(
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 8 }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setPreviewFile(null)}
|
||||
>
|
||||
<div
|
||||
style={{ width: '100%', maxWidth: 950, height: '95vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
||||
style={{ width: '100%', maxWidth: 950, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
@@ -176,7 +177,8 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</p>
|
||||
</object>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
|
||||
@@ -232,6 +232,7 @@ export function MapView({
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
singleMarkerMode
|
||||
iconCreateFunction={(cluster) => {
|
||||
const count = cluster.getChildCount()
|
||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||
|
||||
@@ -46,21 +46,35 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) {
|
||||
const cached = getWeatherCache(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
if (cached === null) setFailed(true)
|
||||
else setWeather(cached)
|
||||
// Climate data: use from cache but re-fetch in background to upgrade to forecast
|
||||
else if (cached.type === 'climate') {
|
||||
setWeather(cached)
|
||||
weatherApi.get(lat, lng, date)
|
||||
.then(data => {
|
||||
if (!data.error && data.temp !== undefined && data.type === 'forecast') {
|
||||
setWeatherCache(cacheKey, data)
|
||||
setWeather(data)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
return
|
||||
} else {
|
||||
setWeather(cached)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
weatherApi.get(lat, lng, date)
|
||||
.then(data => {
|
||||
if (data.error || data.temp === undefined) {
|
||||
setWeatherCache(cacheKey, null)
|
||||
setFailed(true)
|
||||
} else {
|
||||
setWeatherCache(cacheKey, data)
|
||||
setWeather(data)
|
||||
}
|
||||
})
|
||||
.catch(() => { setWeatherCache(cacheKey, null); setFailed(true) })
|
||||
.catch(() => { setFailed(true) })
|
||||
.finally(() => setLoading(false))
|
||||
}, [lat, lng, date])
|
||||
|
||||
@@ -83,20 +97,21 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) {
|
||||
const rawTemp = weather.temp
|
||||
const temp = rawTemp !== undefined ? Math.round(isFahrenheit ? rawTemp * 9/5 + 32 : rawTemp) : null
|
||||
const unit = isFahrenheit ? '°F' : '°C'
|
||||
const isClimate = weather.type === 'climate'
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: '#6b7280', ...fontStyle }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
|
||||
<WeatherIcon main={weather.main} size={12} />
|
||||
{temp !== null && <span>{temp}{unit}</span>}
|
||||
{temp !== null && <span>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: '#374151', background: 'rgba(0,0,0,0.04)', borderRadius: 8, padding: '5px 10px', ...fontStyle }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: isClimate ? '#71717a' : '#374151', background: 'rgba(0,0,0,0.04)', borderRadius: 8, padding: '5px 10px', ...fontStyle }}>
|
||||
<WeatherIcon main={weather.main} size={15} />
|
||||
{temp !== null && <span style={{ fontWeight: 500 }}>{temp}{unit}</span>}
|
||||
{temp !== null && <span style={{ fontWeight: 500 }}>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
|
||||
{weather.description && <span style={{ fontSize: 11, color: '#9ca3af', textTransform: 'capitalize' }}>{weather.description}</span>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -263,6 +263,31 @@ const de = {
|
||||
'admin.addons.toast.updated': 'Addon aktualisiert',
|
||||
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
|
||||
'admin.addons.noAddons': 'Keine Addons verfügbar',
|
||||
// Weather info
|
||||
'admin.weather.title': 'Wetterdaten',
|
||||
'admin.weather.badge': 'Seit 24. März 2026',
|
||||
'admin.weather.description': 'NOMAD nutzt Open-Meteo als Wetterdatenquelle. Open-Meteo ist ein kostenloser, quelloffener Wetterdienst — es wird kein API-Schlüssel benötigt.',
|
||||
'admin.weather.forecast': '16-Tage-Vorhersage',
|
||||
'admin.weather.forecastDesc': 'Statt bisher 5 Tage (OpenWeatherMap)',
|
||||
'admin.weather.climate': 'Historische Klimadaten',
|
||||
'admin.weather.climateDesc': 'Durchschnittswerte der letzten 85 Jahre für Tage jenseits der 16-Tage-Vorhersage',
|
||||
'admin.weather.requests': '10.000 Anfragen / Tag',
|
||||
'admin.weather.requestsDesc': 'Kostenlos, kein API-Schlüssel erforderlich',
|
||||
'admin.weather.locationHint': 'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.',
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
'admin.github.title': 'Update-Verlauf',
|
||||
'admin.github.subtitle': 'Neueste Updates von {repo}',
|
||||
'admin.github.latest': 'Aktuell',
|
||||
'admin.github.prerelease': 'Vorabversion',
|
||||
'admin.github.showDetails': 'Details anzeigen',
|
||||
'admin.github.hideDetails': 'Details ausblenden',
|
||||
'admin.github.loadMore': 'Mehr laden',
|
||||
'admin.github.loading': 'Wird geladen...',
|
||||
'admin.github.error': 'Releases konnten nicht geladen werden',
|
||||
'admin.github.by': 'von',
|
||||
|
||||
'admin.update.available': 'Update verfügbar',
|
||||
'admin.update.text': 'NOMAD {version} ist verfügbar. Du verwendest {current}.',
|
||||
'admin.update.button': 'Auf GitHub ansehen',
|
||||
|
||||
@@ -263,6 +263,31 @@ const en = {
|
||||
'admin.addons.toast.updated': 'Addon updated',
|
||||
'admin.addons.toast.error': 'Failed to update addon',
|
||||
'admin.addons.noAddons': 'No addons available',
|
||||
// Weather info
|
||||
'admin.weather.title': 'Weather Data',
|
||||
'admin.weather.badge': 'Since March 24, 2026',
|
||||
'admin.weather.description': 'NOMAD uses Open-Meteo as its weather data source. Open-Meteo is a free, open-source weather service — no API key required.',
|
||||
'admin.weather.forecast': '16-day forecast',
|
||||
'admin.weather.forecastDesc': 'Previously 5 days (OpenWeatherMap)',
|
||||
'admin.weather.climate': 'Historical climate data',
|
||||
'admin.weather.climateDesc': 'Averages from the last 85 years for days beyond the 16-day forecast',
|
||||
'admin.weather.requests': '10,000 requests / day',
|
||||
'admin.weather.requestsDesc': 'Free, no API key required',
|
||||
'admin.weather.locationHint': 'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.',
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
'admin.github.title': 'Release History',
|
||||
'admin.github.subtitle': 'Latest updates from {repo}',
|
||||
'admin.github.latest': 'Latest',
|
||||
'admin.github.prerelease': 'Pre-release',
|
||||
'admin.github.showDetails': 'Show details',
|
||||
'admin.github.hideDetails': 'Hide details',
|
||||
'admin.github.loadMore': 'Load more',
|
||||
'admin.github.loading': 'Loading...',
|
||||
'admin.github.error': 'Failed to load releases',
|
||||
'admin.github.by': 'by',
|
||||
|
||||
'admin.update.available': 'Update available',
|
||||
'admin.update.text': 'NOMAD {version} is available. You are running {current}.',
|
||||
'admin.update.button': 'View on GitHub',
|
||||
|
||||
@@ -9,8 +9,9 @@ import Modal from '../components/shared/Modal'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import CategoryManager from '../components/Admin/CategoryManager'
|
||||
import BackupPanel from '../components/Admin/BackupPanel'
|
||||
import GitHubPanel from '../components/Admin/GitHubPanel'
|
||||
import AddonManager from '../components/Admin/AddonManager'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun } from 'lucide-react'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
|
||||
export default function AdminPage() {
|
||||
@@ -23,6 +24,7 @@ export default function AdminPage() {
|
||||
{ id: 'addons', label: t('admin.tabs.addons') },
|
||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||
{ id: 'backup', label: t('admin.tabs.backup') },
|
||||
{ id: 'github', label: t('admin.tabs.github') },
|
||||
]
|
||||
|
||||
const [activeTab, setActiveTab] = useState('users')
|
||||
@@ -502,7 +504,7 @@ export default function AdminPage() {
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5">
|
||||
{t('admin.mapsKey')}
|
||||
<span style={{ fontSize: 10, fontWeight: 500, padding: '1px 7px', borderRadius: 99, background: '#dbeafe', color: '#1d4ed8' }}>{t('admin.recommended')}</span>
|
||||
<span className="text-[9px] font-medium px-1.5 py-px rounded-full bg-emerald-200 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200">{t('admin.recommended')}</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
@@ -551,54 +553,35 @@ export default function AdminPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OpenWeatherMap Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.weatherKey')}</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKeys.weather ? 'text' : 'password'}
|
||||
value={weatherKey}
|
||||
onChange={e => setWeatherKey(e.target.value)}
|
||||
placeholder={t('settings.keyPlaceholder')}
|
||||
className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleKey('weather')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showKeys.weather ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
{/* Open-Meteo Weather Info */}
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800 overflow-hidden">
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-emerald-500 flex items-center justify-center flex-shrink-0">
|
||||
<Sun className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.title')}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-emerald-200 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200">{t('admin.weather.badge')}</span>
|
||||
</div>
|
||||
<div className="px-4 pb-3">
|
||||
<p className="text-xs text-emerald-800 dark:text-emerald-300 leading-relaxed">{t('admin.weather.description')}</p>
|
||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-1.5 leading-relaxed">{t('admin.weather.locationHint')}</p>
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800">
|
||||
<p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.forecast')}</p>
|
||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.forecastDesc')}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800">
|
||||
<p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.climate')}</p>
|
||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.climateDesc')}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800">
|
||||
<p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.requests')}</p>
|
||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.requestsDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleValidateKey('weather')}
|
||||
disabled={!weatherKey || validating.weather}
|
||||
className="px-3 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{validating.weather ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : validation.weather === true ? (
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
||||
) : validation.weather === false ? (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
) : null}
|
||||
{t('admin.validateKey')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.weatherKeyHint')}</p>
|
||||
{validation.weather === true && (
|
||||
<p className="text-xs text-emerald-600 mt-1 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-emerald-500 rounded-full inline-block"></span>
|
||||
{t('admin.keyValid')}
|
||||
</p>
|
||||
)}
|
||||
{validation.weather === false && (
|
||||
<p className="text-xs text-red-500 mt-1 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full inline-block"></span>
|
||||
{t('admin.keyInvalid')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -682,6 +665,8 @@ export default function AdminPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'backup' && <BackupPanel />}
|
||||
|
||||
{activeTab === 'github' && <GitHubPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ export default function LoginPage() {
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<img src="/logo-light.svg" alt="NOMAD" style={{ height: 72 }} />
|
||||
<p style={{ margin: 0, fontSize: 20, color: 'rgba(255,255,255,0.6)', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>{t('login.tagline')}</p>
|
||||
<p style={{ margin: 0, fontSize: 20, color: 'rgba(255,255,255,0.6)', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase', whiteSpace: 'nowrap' }}>{t('login.tagline')}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -416,7 +416,7 @@ export default function LoginPage() {
|
||||
className="mobile-logo">
|
||||
<style>{`@media(min-width:1024px){.mobile-logo{display:none!important}}`}</style>
|
||||
<img src="/logo-dark.svg" alt="NOMAD" style={{ height: 48 }} />
|
||||
<p style={{ margin: 0, fontSize: 18, color: '#9ca3af', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>{t('login.tagline')}</p>
|
||||
<p style={{ margin: 0, fontSize: 16, color: '#9ca3af', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase', whiteSpace: 'nowrap' }}>{t('login.tagline')}</p>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
app:
|
||||
image: mauriceboe/nomad:latest
|
||||
image: mauriceboe/nomad:2.5.5
|
||||
container_name: nomad
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nomad-server",
|
||||
"version": "2.5.2",
|
||||
"version": "2.5.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nomad-server",
|
||||
"version": "2.5.2",
|
||||
"version": "2.5.5",
|
||||
"dependencies": {
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nomad-server",
|
||||
"version": "2.5.4",
|
||||
"version": "2.5.6",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
|
||||
+14
-14
@@ -9,8 +9,8 @@ const CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||
const router = express.Router();
|
||||
router.use(authenticate);
|
||||
|
||||
// Broadcast vacay updates to all users in the same plan
|
||||
function notifyPlanUsers(planId, excludeUserId, event = 'vacay:update') {
|
||||
// Broadcast vacay updates to all users in the same plan (exclude only the triggering socket, not the whole user)
|
||||
function notifyPlanUsers(planId, excludeSid, event = 'vacay:update') {
|
||||
try {
|
||||
const { broadcastToUser } = require('../websocket');
|
||||
const plan = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId);
|
||||
@@ -18,7 +18,7 @@ function notifyPlanUsers(planId, excludeUserId, event = 'vacay:update') {
|
||||
const userIds = [plan.owner_id];
|
||||
const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(planId);
|
||||
members.forEach(m => userIds.push(m.user_id));
|
||||
userIds.filter(id => id !== excludeUserId).forEach(id => broadcastToUser(id, { type: event }));
|
||||
userIds.forEach(id => broadcastToUser(id, { type: event }, excludeSid));
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ router.put('/plan', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
notifyPlanUsers(planId, req.user.id, 'vacay:settings');
|
||||
notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:settings');
|
||||
|
||||
const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
|
||||
res.json({
|
||||
@@ -213,7 +213,7 @@ router.put('/color', (req, res) => {
|
||||
INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color
|
||||
`).run(userId, planId, color || '#6366f1');
|
||||
notifyPlanUsers(planId, req.user.id, 'vacay:update');
|
||||
notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:update');
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -300,7 +300,7 @@ router.post('/invite/accept', (req, res) => {
|
||||
}
|
||||
|
||||
// Notify all plan users (not just owner)
|
||||
notifyPlanUsers(plan_id, req.user.id, 'vacay:accepted');
|
||||
notifyPlanUsers(plan_id, req.headers['x-socket-id'], 'vacay:accepted');
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
@@ -310,7 +310,7 @@ router.post('/invite/decline', (req, res) => {
|
||||
const { plan_id } = req.body;
|
||||
db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(plan_id, req.user.id);
|
||||
|
||||
notifyPlanUsers(plan_id, req.user.id, 'vacay:declined');
|
||||
notifyPlanUsers(plan_id, req.headers['x-socket-id'], 'vacay:declined');
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
@@ -417,7 +417,7 @@ router.post('/years', (req, res) => {
|
||||
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)').run(u.id, planId, year, carriedOver);
|
||||
}
|
||||
} catch { /* exists */ }
|
||||
notifyPlanUsers(planId, req.user.id, 'vacay:settings');
|
||||
notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:settings');
|
||||
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
|
||||
res.json({ years: years.map(y => y.year) });
|
||||
});
|
||||
@@ -428,7 +428,7 @@ router.delete('/years/:year', (req, res) => {
|
||||
db.prepare('DELETE FROM vacay_years WHERE plan_id = ? AND year = ?').run(planId, year);
|
||||
db.prepare("DELETE FROM vacay_entries WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
|
||||
db.prepare("DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
|
||||
notifyPlanUsers(planId, req.user.id, 'vacay:settings');
|
||||
notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:settings');
|
||||
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
|
||||
res.json({ years: years.map(y => y.year) });
|
||||
});
|
||||
@@ -466,11 +466,11 @@ router.post('/entries/toggle', (req, res) => {
|
||||
const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId);
|
||||
if (existing) {
|
||||
db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id);
|
||||
notifyPlanUsers(planId, req.user.id);
|
||||
notifyPlanUsers(planId, req.headers['x-socket-id']);
|
||||
res.json({ action: 'removed' });
|
||||
} else {
|
||||
db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, userId, date, '');
|
||||
notifyPlanUsers(planId, req.user.id);
|
||||
notifyPlanUsers(planId, req.headers['x-socket-id']);
|
||||
res.json({ action: 'added' });
|
||||
}
|
||||
});
|
||||
@@ -481,13 +481,13 @@ router.post('/entries/company-holiday', (req, res) => {
|
||||
const existing = db.prepare('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').get(planId, date);
|
||||
if (existing) {
|
||||
db.prepare('DELETE FROM vacay_company_holidays WHERE id = ?').run(existing.id);
|
||||
notifyPlanUsers(planId, req.user.id);
|
||||
notifyPlanUsers(planId, req.headers['x-socket-id']);
|
||||
res.json({ action: 'removed' });
|
||||
} else {
|
||||
db.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, date, note || '');
|
||||
// Remove any vacation entries on this date
|
||||
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date);
|
||||
notifyPlanUsers(planId, req.user.id);
|
||||
notifyPlanUsers(planId, req.headers['x-socket-id']);
|
||||
res.json({ action: 'added' });
|
||||
}
|
||||
});
|
||||
@@ -544,7 +544,7 @@ router.put('/stats/:year', (req, res) => {
|
||||
INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, 0)
|
||||
ON CONFLICT(user_id, plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days
|
||||
`).run(userId, planId, year, vacation_days);
|
||||
notifyPlanUsers(planId, req.user.id);
|
||||
notifyPlanUsers(planId, req.headers['x-socket-id']);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
||||
+203
-53
@@ -1,6 +1,5 @@
|
||||
const express = require('express');
|
||||
const fetch = require('node-fetch');
|
||||
const { db } = require('../db/database');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -10,11 +9,12 @@ const weatherCache = new Map();
|
||||
|
||||
const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour
|
||||
const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
|
||||
const TTL_CLIMATE_MS = 24 * 60 * 60 * 1000; // 24 hours (historical data doesn't change)
|
||||
|
||||
function cacheKey(lat, lng, date, units) {
|
||||
function cacheKey(lat, lng, date) {
|
||||
const rlat = parseFloat(lat).toFixed(2);
|
||||
const rlng = parseFloat(lng).toFixed(2);
|
||||
return `${rlat}_${rlng}_${date || 'current'}_${units}`;
|
||||
return `${rlat}_${rlng}_${date || 'current'}`;
|
||||
}
|
||||
|
||||
function getCached(key) {
|
||||
@@ -30,46 +30,123 @@ function getCached(key) {
|
||||
function setCache(key, data, ttlMs) {
|
||||
weatherCache.set(key, { data, expiresAt: Date.now() + ttlMs });
|
||||
}
|
||||
|
||||
// WMO weather code mapping → condition string used by client icon map
|
||||
const WMO_MAP = {
|
||||
0: 'Clear',
|
||||
1: 'Clear', // mainly clear
|
||||
2: 'Clouds', // partly cloudy
|
||||
3: 'Clouds', // overcast
|
||||
45: 'Fog',
|
||||
48: 'Fog',
|
||||
51: 'Drizzle',
|
||||
53: 'Drizzle',
|
||||
55: 'Drizzle',
|
||||
56: 'Drizzle', // freezing drizzle
|
||||
57: 'Drizzle',
|
||||
61: 'Rain',
|
||||
63: 'Rain',
|
||||
65: 'Rain', // heavy rain
|
||||
66: 'Rain', // freezing rain
|
||||
67: 'Rain',
|
||||
71: 'Snow',
|
||||
73: 'Snow',
|
||||
75: 'Snow',
|
||||
77: 'Snow', // snow grains
|
||||
80: 'Rain', // rain showers
|
||||
81: 'Rain',
|
||||
82: 'Rain',
|
||||
85: 'Snow', // snow showers
|
||||
86: 'Snow',
|
||||
95: 'Thunderstorm',
|
||||
96: 'Thunderstorm',
|
||||
99: 'Thunderstorm',
|
||||
};
|
||||
|
||||
const WMO_DESCRIPTION_DE = {
|
||||
0: 'Klar',
|
||||
1: 'Überwiegend klar',
|
||||
2: 'Teilweise bewölkt',
|
||||
3: 'Bewölkt',
|
||||
45: 'Nebel',
|
||||
48: 'Nebel mit Reif',
|
||||
51: 'Leichter Nieselregen',
|
||||
53: 'Nieselregen',
|
||||
55: 'Starker Nieselregen',
|
||||
56: 'Gefrierender Nieselregen',
|
||||
57: 'Starker gefr. Nieselregen',
|
||||
61: 'Leichter Regen',
|
||||
63: 'Regen',
|
||||
65: 'Starker Regen',
|
||||
66: 'Gefrierender Regen',
|
||||
67: 'Starker gefr. Regen',
|
||||
71: 'Leichter Schneefall',
|
||||
73: 'Schneefall',
|
||||
75: 'Starker Schneefall',
|
||||
77: 'Schneekörner',
|
||||
80: 'Leichte Regenschauer',
|
||||
81: 'Regenschauer',
|
||||
82: 'Starke Regenschauer',
|
||||
85: 'Leichte Schneeschauer',
|
||||
86: 'Starke Schneeschauer',
|
||||
95: 'Gewitter',
|
||||
96: 'Gewitter mit Hagel',
|
||||
99: 'Starkes Gewitter mit Hagel',
|
||||
};
|
||||
|
||||
const WMO_DESCRIPTION_EN = {
|
||||
0: 'Clear sky',
|
||||
1: 'Mainly clear',
|
||||
2: 'Partly cloudy',
|
||||
3: 'Overcast',
|
||||
45: 'Fog',
|
||||
48: 'Rime fog',
|
||||
51: 'Light drizzle',
|
||||
53: 'Drizzle',
|
||||
55: 'Heavy drizzle',
|
||||
56: 'Freezing drizzle',
|
||||
57: 'Heavy freezing drizzle',
|
||||
61: 'Light rain',
|
||||
63: 'Rain',
|
||||
65: 'Heavy rain',
|
||||
66: 'Freezing rain',
|
||||
67: 'Heavy freezing rain',
|
||||
71: 'Light snowfall',
|
||||
73: 'Snowfall',
|
||||
75: 'Heavy snowfall',
|
||||
77: 'Snow grains',
|
||||
80: 'Light rain showers',
|
||||
81: 'Rain showers',
|
||||
82: 'Heavy rain showers',
|
||||
85: 'Light snow showers',
|
||||
86: 'Heavy snow showers',
|
||||
95: 'Thunderstorm',
|
||||
96: 'Thunderstorm with hail',
|
||||
99: 'Severe thunderstorm with hail',
|
||||
};
|
||||
|
||||
// Estimate weather condition from average temperature + precipitation
|
||||
function estimateCondition(tempAvg, precipMm) {
|
||||
if (precipMm > 5) return tempAvg <= 0 ? 'Snow' : 'Rain';
|
||||
if (precipMm > 1) return tempAvg <= 0 ? 'Snow' : 'Drizzle';
|
||||
if (precipMm > 0.3) return 'Clouds';
|
||||
return tempAvg > 15 ? 'Clear' : 'Clouds';
|
||||
}
|
||||
// -------------------------------------------------------
|
||||
|
||||
function formatItem(item) {
|
||||
return {
|
||||
temp: Math.round(item.main.temp),
|
||||
feels_like: Math.round(item.main.feels_like),
|
||||
humidity: item.main.humidity,
|
||||
main: item.weather[0]?.main || '',
|
||||
description: item.weather[0]?.description || '',
|
||||
icon: item.weather[0]?.icon || '',
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/weather?lat=&lng=&date=&units=metric
|
||||
// GET /api/weather?lat=&lng=&date=&lang=de
|
||||
router.get('/', authenticate, async (req, res) => {
|
||||
const { lat, lng, date, units = 'metric' } = req.query;
|
||||
const { lat, lng, date, lang = 'de' } = req.query;
|
||||
|
||||
if (!lat || !lng) {
|
||||
return res.status(400).json({ error: 'Breiten- und Längengrad sind erforderlich' });
|
||||
}
|
||||
|
||||
// User's own key, or fall back to admin's key
|
||||
let key = null;
|
||||
const user = db.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(req.user.id);
|
||||
if (user?.openweather_api_key) {
|
||||
key = user.openweather_api_key;
|
||||
} else {
|
||||
const admin = db.prepare("SELECT openweather_api_key FROM users WHERE role = 'admin' AND openweather_api_key IS NOT NULL AND openweather_api_key != '' LIMIT 1").get();
|
||||
key = admin?.openweather_api_key || null;
|
||||
}
|
||||
if (!key) {
|
||||
return res.status(400).json({ error: 'Kein API-Schlüssel konfiguriert' });
|
||||
}
|
||||
|
||||
const ck = cacheKey(lat, lng, date, units);
|
||||
const ck = cacheKey(lat, lng, date);
|
||||
|
||||
try {
|
||||
// If a date is requested, try the 5-day forecast first
|
||||
// ── Forecast for a specific date ──
|
||||
if (date) {
|
||||
// Check cache
|
||||
const cached = getCached(ck);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
@@ -77,49 +154,122 @@ router.get('/', authenticate, async (req, res) => {
|
||||
const now = new Date();
|
||||
const diffDays = (targetDate - now) / (1000 * 60 * 60 * 24);
|
||||
|
||||
// Within 5-day forecast window
|
||||
if (diffDays >= -1 && diffDays <= 5) {
|
||||
const url = `https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lng}&appid=${key}&units=${units}&lang=de`;
|
||||
// Within 16-day forecast window → real forecast
|
||||
if (diffDays >= -1 && diffDays <= 16) {
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto&forecast_days=16`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json({ error: data.message || 'OpenWeatherMap API Fehler' });
|
||||
if (!response.ok || data.error) {
|
||||
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API Fehler' });
|
||||
}
|
||||
|
||||
const filtered = (data.list || []).filter(item => {
|
||||
const itemDate = new Date(item.dt * 1000);
|
||||
return itemDate.toDateString() === targetDate.toDateString();
|
||||
});
|
||||
const dateStr = targetDate.toISOString().slice(0, 10);
|
||||
const idx = (data.daily?.time || []).indexOf(dateStr);
|
||||
|
||||
if (idx !== -1) {
|
||||
const code = data.daily.weathercode[idx];
|
||||
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
|
||||
|
||||
const result = {
|
||||
temp: Math.round((data.daily.temperature_2m_max[idx] + data.daily.temperature_2m_min[idx]) / 2),
|
||||
temp_max: Math.round(data.daily.temperature_2m_max[idx]),
|
||||
temp_min: Math.round(data.daily.temperature_2m_min[idx]),
|
||||
main: WMO_MAP[code] || 'Clouds',
|
||||
description: descriptions[code] || '',
|
||||
type: 'forecast',
|
||||
};
|
||||
|
||||
if (filtered.length > 0) {
|
||||
const midday = filtered.find(item => {
|
||||
const hour = new Date(item.dt * 1000).getHours();
|
||||
return hour >= 11 && hour <= 14;
|
||||
}) || filtered[0];
|
||||
const result = formatItem(midday);
|
||||
setCache(ck, result, TTL_FORECAST_MS);
|
||||
return res.json(result);
|
||||
}
|
||||
// Forecast didn't include this date — fall through to climate
|
||||
}
|
||||
|
||||
// Outside forecast window — no data available
|
||||
// Beyond forecast range or forecast gap → historical climate average
|
||||
if (diffDays > -1) {
|
||||
const month = targetDate.getMonth() + 1;
|
||||
const day = targetDate.getDate();
|
||||
// Query a 5-day window around the target date for smoother averages (using last year as reference)
|
||||
const refYear = targetDate.getFullYear() - 1;
|
||||
const startDate = new Date(refYear, month - 1, day - 2);
|
||||
const endDate = new Date(refYear, month - 1, day + 2);
|
||||
const startStr = startDate.toISOString().slice(0, 10);
|
||||
const endStr = endDate.toISOString().slice(0, 10);
|
||||
|
||||
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${startStr}&end_date=${endStr}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo Climate API Fehler' });
|
||||
}
|
||||
|
||||
const daily = data.daily;
|
||||
if (!daily || !daily.time || daily.time.length === 0) {
|
||||
return res.json({ error: 'no_forecast' });
|
||||
}
|
||||
|
||||
// Average across the window
|
||||
let sumMax = 0, sumMin = 0, sumPrecip = 0, count = 0;
|
||||
for (let i = 0; i < daily.time.length; i++) {
|
||||
if (daily.temperature_2m_max[i] != null && daily.temperature_2m_min[i] != null) {
|
||||
sumMax += daily.temperature_2m_max[i];
|
||||
sumMin += daily.temperature_2m_min[i];
|
||||
sumPrecip += daily.precipitation_sum[i] || 0;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
return res.json({ error: 'no_forecast' });
|
||||
}
|
||||
|
||||
const avgMax = sumMax / count;
|
||||
const avgMin = sumMin / count;
|
||||
const avgTemp = (avgMax + avgMin) / 2;
|
||||
const avgPrecip = sumPrecip / count;
|
||||
const main = estimateCondition(avgTemp, avgPrecip);
|
||||
|
||||
const result = {
|
||||
temp: Math.round(avgTemp),
|
||||
temp_max: Math.round(avgMax),
|
||||
temp_min: Math.round(avgMin),
|
||||
main,
|
||||
description: '',
|
||||
type: 'climate',
|
||||
};
|
||||
|
||||
setCache(ck, result, TTL_CLIMATE_MS);
|
||||
return res.json(result);
|
||||
}
|
||||
|
||||
// Past dates beyond yesterday
|
||||
return res.json({ error: 'no_forecast' });
|
||||
}
|
||||
|
||||
// No date — return current weather
|
||||
// ── Current weather (no date) ──
|
||||
const cached = getCached(ck);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lng}&appid=${key}&units=${units}&lang=de`;
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,weathercode&timezone=auto`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json({ error: data.message || 'OpenWeatherMap API Fehler' });
|
||||
if (!response.ok || data.error) {
|
||||
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API Fehler' });
|
||||
}
|
||||
|
||||
const result = formatItem(data);
|
||||
const code = data.current.weathercode;
|
||||
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
|
||||
|
||||
const result = {
|
||||
temp: Math.round(data.current.temperature_2m),
|
||||
main: WMO_MAP[code] || 'Clouds',
|
||||
description: descriptions[code] || '',
|
||||
type: 'current',
|
||||
};
|
||||
|
||||
setCache(ck, result, TTL_CURRENT_MS);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
|
||||
@@ -141,10 +141,12 @@ function broadcast(tripId, eventType, payload, excludeSid) {
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastToUser(userId, payload) {
|
||||
function broadcastToUser(userId, payload, excludeSid) {
|
||||
if (!wss) return;
|
||||
const excludeNum = excludeSid ? Number(excludeSid) : null;
|
||||
for (const ws of wss.clients) {
|
||||
if (ws.readyState !== 1) continue;
|
||||
if (excludeNum && socketId.get(ws) === excludeNum) continue;
|
||||
const user = socketUser.get(ws);
|
||||
if (user && user.id === userId) {
|
||||
ws.send(JSON.stringify(payload));
|
||||
|
||||
Reference in New Issue
Block a user