v2.5.6: Open-Meteo weather, WebSocket fixes, admin improvements

- Replace OpenWeatherMap with Open-Meteo (no API key needed)
  - 16-day forecast (up from 5 days)
  - Historical climate averages as fallback beyond 16 days
  - Auto-upgrade from climate to real forecast when available
- Fix Vacay WebSocket sync across devices (socket-ID exclusion instead of user-ID)
- Add GitHub release history tab in admin panel
- Show cluster count "1" for single map markers when zoomed out
- Add weather info panel in admin settings (replaces OpenWeatherMap key input)
- Update i18n translations (DE + EN)
This commit is contained in:
Maurice
2026-03-24 10:02:03 +01:00
parent faa8c84655
commit e4607e426c
22 changed files with 631 additions and 327 deletions
+263
View File
@@ -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 -43
View File
@@ -1,9 +1,8 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useTranslation } from '../../i18n'
import { Plus, Trash2, Calculator, Wallet, ArrowRightLeft } from 'lucide-react'
import { Plus, Trash2, Calculator, Wallet } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { exchangeApi } from '../../api/client'
// ── Helpers ──────────────────────────────────────────────────────────────────
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
@@ -154,15 +153,6 @@ export default function BudgetPanel({ tripId }) {
const { t, locale } = useTranslation()
const [newCategoryName, setNewCategoryName] = useState('')
const currency = trip?.currency || 'EUR'
const [rates, setRates] = useState(null)
const [convertTo, setConvertTo] = useState(() => {
const saved = localStorage.getItem('budget_convert_to')
return saved || (currency === 'EUR' ? 'USD' : 'EUR')
})
useEffect(() => {
exchangeApi.getRates().then(setRates).catch(() => {})
}, [])
const fmt = (v, cur) => fmtNum(v, locale, cur)
@@ -361,38 +351,6 @@ export default function BudgetPanel({ tripId }) {
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
{/* Live exchange rate conversion */}
{rates && (() => {
const fromRate = currency === 'EUR' ? 1 : rates.rates?.[currency]
const toRate = convertTo === 'EUR' ? 1 : rates.rates?.[convertTo]
const converted = fromRate && toRate ? (grandTotal / fromRate) * toRate : null
return converted != null ? (
<div style={{ marginTop: 16, paddingTop: 14, borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
<ArrowRightLeft size={12} style={{ color: 'rgba(255,255,255,0.4)' }} />
<span style={{ fontSize: 10, color: 'rgba(255,255,255,0.4)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5 }}>{t('budget.converted')}</span>
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 20, fontWeight: 700 }}>
{Number(converted).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
<select
value={convertTo}
onChange={e => { setConvertTo(e.target.value); localStorage.setItem('budget_convert_to', e.target.value) }}
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 6, color: 'rgba(255,255,255,0.7)', fontSize: 12, fontWeight: 600, padding: '2px 4px', cursor: 'pointer', fontFamily: 'inherit' }}
>
{CURRENCIES.filter(c => c !== currency).map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div style={{ fontSize: 10, color: 'rgba(255,255,255,0.3)', marginTop: 4 }}>
1 {currency} = {((toRate / fromRate) || 0).toFixed(4)} {convertTo}
</div>
</div>
) : null
})()}
</div>
{pieSegments.length > 0 && (
+1
View File
@@ -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
@@ -855,6 +855,17 @@ export default function DayPlanSidebar({
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
{isSelected && getDayAssignments(day.id).length >= 2 && (
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', background: 'var(--bg-hover)', borderRadius: 8, padding: 2, gap: 2 }}>
{TRANSPORT_MODES.map(m => (
<button key={m.value} onClick={() => setTransportMode(m.value)} style={{
flex: 1, padding: '4px 0', fontSize: 11, fontWeight: transportMode === m.value ? 600 : 400,
background: transportMode === m.value ? 'var(--bg-card)' : 'transparent',
border: 'none', borderRadius: 6, cursor: 'pointer', color: transportMode === m.value ? 'var(--text-primary)' : 'var(--text-muted)',
boxShadow: transportMode === m.value ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
fontFamily: 'inherit',
}}>{m.label}</button>
))}
</div>
{routeInfo && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
+6 -61
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useState, useCallback, useRef } from 'react'
import React, { useMemo, useState, useCallback } from 'react'
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
import { isWeekend } from './holidays'
@@ -28,75 +28,22 @@ export default function VacayCalendar() {
const blockWeekends = plan?.block_weekends !== false
const companyHolidaysEnabled = plan?.company_holidays_enabled !== false
// Drag-to-paint state
const isDragging = useRef(false)
const dragAction = useRef(null) // 'add' or 'remove'
const dragProcessed = useRef(new Set())
const isDayBlocked = useCallback((dateStr) => {
if (holidays[dateStr]) return true
if (blockWeekends && isWeekend(dateStr)) return true
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr) && !companyMode) return true
return false
}, [holidays, blockWeekends, companyHolidaySet, companyHolidaysEnabled, companyMode])
const handleCellMouseDown = useCallback((dateStr) => {
if (isDayBlocked(dateStr) && !companyMode) return
isDragging.current = true
dragProcessed.current = new Set([dateStr])
if (companyMode) {
dragAction.current = companyHolidaySet.has(dateStr) ? 'remove' : 'add'
toggleCompanyHoliday(dateStr)
} else {
const hasEntry = (entryMap[dateStr] || []).some(e => e.user_id === (selectedUserId || undefined))
dragAction.current = hasEntry ? 'remove' : 'add'
toggleEntry(dateStr, selectedUserId || undefined)
}
}, [companyMode, isDayBlocked, toggleEntry, toggleCompanyHoliday, entryMap, companyHolidaySet, selectedUserId])
const handleCellMouseEnter = useCallback((dateStr) => {
if (!isDragging.current) return
if (dragProcessed.current.has(dateStr)) return
if (isDayBlocked(dateStr) && !companyMode) return
dragProcessed.current.add(dateStr)
if (companyMode) {
const isSet = companyHolidaySet.has(dateStr)
if ((dragAction.current === 'add' && !isSet) || (dragAction.current === 'remove' && isSet)) {
toggleCompanyHoliday(dateStr)
}
} else {
const hasEntry = (entryMap[dateStr] || []).some(e => e.user_id === (selectedUserId || undefined))
if ((dragAction.current === 'add' && !hasEntry) || (dragAction.current === 'remove' && hasEntry)) {
toggleEntry(dateStr, selectedUserId || undefined)
}
}
}, [companyMode, isDayBlocked, toggleEntry, toggleCompanyHoliday, entryMap, companyHolidaySet, selectedUserId])
const handleMouseUp = useCallback(() => {
isDragging.current = false
dragAction.current = null
dragProcessed.current.clear()
}, [])
// Also handle click for single taps (touch/accessibility)
const handleCellClick = useCallback(async (dateStr) => {
// Already handled by mousedown for mouse users, this is fallback for touch
if (isDragging.current) return
if (companyMode) {
if (!companyHolidaysEnabled) return
await toggleCompanyHoliday(dateStr)
return
}
if (isDayBlocked(dateStr)) return
if (holidays[dateStr]) return
if (blockWeekends && isWeekend(dateStr)) return
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
await toggleEntry(dateStr, selectedUserId || undefined)
}, [companyMode, toggleEntry, toggleCompanyHoliday, companyHolidaysEnabled, isDayBlocked, selectedUserId])
}, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
const selectedUser = users.find(u => u.id === selectedUserId)
return (
<div onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} style={{ userSelect: 'none' }}>
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{Array.from({ length: 12 }, (_, i) => (
<VacayMonthCard
@@ -108,8 +55,6 @@ export default function VacayCalendar() {
companyHolidaysEnabled={companyHolidaysEnabled}
entryMap={entryMap}
onCellClick={handleCellClick}
onCellMouseDown={handleCellMouseDown}
onCellMouseEnter={handleCellMouseEnter}
companyMode={companyMode}
blockWeekends={blockWeekends}
/>
@@ -9,7 +9,7 @@ const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli',
export default function VacayMonthCard({
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
onCellClick, onCellMouseDown, onCellMouseEnter, companyMode, blockWeekends
onCellClick, companyMode, blockWeekends
}) {
const { language } = useTranslation()
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
@@ -69,13 +69,9 @@ export default function VacayMonthCard({
borderRight: '1px solid var(--border-secondary)',
cursor: isBlocked ? 'default' : 'pointer',
}}
onMouseDown={(e) => { e.preventDefault(); onCellMouseDown?.(dateStr) }}
onMouseEnter={(e) => {
onCellMouseEnter?.(dateStr)
if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)'
}}
onClick={() => onCellClick(dateStr)}
onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }}
onTouchStart={() => onCellClick(dateStr)}
>
{holiday && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(239,68,68,0.12)' }} />}
{isCompany && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(245,158,11,0.15)' }} />}
+3 -23
View File
@@ -72,36 +72,16 @@ export default function VacayPersons() {
</div>
<div className="flex flex-col gap-0.5">
{users.length >= 2 && (
<div
onClick={() => setSelectedUserId('all')}
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group transition-all"
style={{
background: selectedUserId === 'all' ? 'var(--bg-hover)' : 'transparent',
border: selectedUserId === 'all' ? '1px solid var(--border-primary)' : '1px solid transparent',
cursor: 'pointer',
}}>
<div className="w-3.5 h-3.5 rounded-full shrink-0 flex items-center justify-center" style={{ background: 'var(--text-muted)' }}>
<span style={{ fontSize: 8, fontWeight: 700, color: 'var(--bg-card)', lineHeight: 1 }}>A</span>
</div>
<span className="text-xs font-medium flex-1 truncate" style={{ color: 'var(--text-primary)' }}>
{t('vacay.everyone')}
</span>
{selectedUserId === 'all' && (
<Check size={12} style={{ color: 'var(--text-primary)' }} />
)}
</div>
)}
{users.map(u => {
const isSelected = selectedUserId === u.id
return (
<div key={u.id}
onClick={() => { if (isFused || users.length >= 2) setSelectedUserId(u.id) }}
onClick={() => { if (isFused) setSelectedUserId(u.id) }}
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group transition-all"
style={{
background: isSelected ? 'var(--bg-hover)' : 'transparent',
border: isSelected ? '1px solid var(--border-primary)' : '1px solid transparent',
cursor: (isFused || users.length >= 2) ? 'pointer' : 'default',
cursor: isFused ? 'pointer' : 'default',
}}>
<button
onClick={(e) => { e.stopPropagation(); setColorEditUserId(u.id); setShowColorPicker(true) }}
@@ -113,7 +93,7 @@ export default function VacayPersons() {
{u.username}
{u.id === currentUser?.id && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>}
</span>
{isSelected && (isFused || users.length >= 2) && (
{isSelected && isFused && (
<Check size={12} style={{ color: 'var(--text-primary)' }} />
)}
</div>
@@ -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>
)