feat: event editing, light/dark theme, device animations, 7-day forecast
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
This commit is contained in:
@@ -255,13 +255,28 @@ function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string;
|
||||
}
|
||||
|
||||
// ————— Event Detail Modal —————
|
||||
function EventDetailModal({ event, onClose, onDelete }: {
|
||||
function EventDetailModal({ event, onClose, onDelete, onUpdate }: {
|
||||
event: CalendarEvent
|
||||
onClose: () => void
|
||||
onDelete: (e: CalendarEvent) => Promise<void>
|
||||
onUpdate: (old: CalendarEvent, updated: Partial<CalendarEvent>) => Promise<void>
|
||||
}) {
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editTitle, setEditTitle] = useState(event.title)
|
||||
const [editDate, setEditDate] = useState(
|
||||
event.start.includes('T') ? event.start.split('T')[0] : event.start
|
||||
)
|
||||
const [editStartTime, setEditStartTime] = useState(
|
||||
event.start.includes('T') ? formatTime(event.start) : '10:00'
|
||||
)
|
||||
const [editEndTime, setEditEndTime] = useState(
|
||||
event.end.includes('T') ? formatTime(event.end) : '11:00'
|
||||
)
|
||||
const [editAllDay, setEditAllDay] = useState(event.allDay)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState('')
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true)
|
||||
@@ -269,151 +284,254 @@ function EventDetailModal({ event, onClose, onDelete }: {
|
||||
setDeleting(false)
|
||||
}
|
||||
|
||||
const startDate = new Date(event.start)
|
||||
const endDate = new Date(event.end)
|
||||
const handleSave = async () => {
|
||||
if (!editTitle.trim()) { setSaveError('Введите название'); return }
|
||||
setSaving(true); setSaveError('')
|
||||
try {
|
||||
await onUpdate(event, {
|
||||
title: editTitle.trim(),
|
||||
start: editAllDay ? editDate : `${editDate}T${editStartTime}`,
|
||||
end: editAllDay ? editDate : `${editDate}T${editEndTime}`,
|
||||
allDay: editAllDay,
|
||||
})
|
||||
} catch (e: any) {
|
||||
setSaveError(e.message || 'Ошибка сохранения')
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(12px)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={onClose}>
|
||||
<div style={{
|
||||
background: 'rgba(18,18,35,0.95)', backdropFilter: 'blur(40px)',
|
||||
border: '1px solid rgba(255,255,255,0.08)', borderRadius: 28,
|
||||
padding: 0, width: 480, maxWidth: '95vw',
|
||||
boxShadow: '0 25px 80px rgba(0,0,0,0.6)', overflow: 'hidden',
|
||||
background: 'rgba(16,16,30,0.97)', backdropFilter: 'blur(40px)',
|
||||
border: '1px solid rgba(255,255,255,0.07)', borderRadius: 28,
|
||||
width: 480, maxWidth: '95vw', overflow: 'hidden',
|
||||
boxShadow: '0 30px 90px rgba(0,0,0,0.6)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
|
||||
{/* Colored header band */}
|
||||
{/* Colored header */}
|
||||
<div style={{
|
||||
background: `linear-gradient(135deg, ${event.color}25, ${event.color}10)`,
|
||||
borderBottom: `1px solid ${event.color}20`,
|
||||
padding: '28px 32px 24px',
|
||||
background: `linear-gradient(135deg, ${event.color}20, ${event.color}08)`,
|
||||
borderBottom: `1px solid ${event.color}15`,
|
||||
padding: '24px 28px 20px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
|
||||
lineHeight: 1.3, marginBottom: 8,
|
||||
}}>
|
||||
{event.title}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
width: 8, height: 8, borderRadius: '50%', background: event.color, flexShrink: 0,
|
||||
boxShadow: `0 0 8px ${event.color}60`,
|
||||
}} />
|
||||
<span style={{ fontSize: 14, color: event.color, fontWeight: 600 }}>{event.ownerName}</span>
|
||||
{editing ? (
|
||||
<input
|
||||
value={editTitle} onChange={e => setEditTitle(e.target.value)}
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%', padding: '10px 14px', borderRadius: 12,
|
||||
background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: 'var(--text-primary)', fontSize: 18, fontWeight: 700,
|
||||
outline: 'none', fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.3 }}>
|
||||
{event.title}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: event.color, boxShadow: `0 0 8px ${event.color}60` }} />
|
||||
<span style={{ fontSize: 13, color: event.color, fontWeight: 600 }}>{event.ownerName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{
|
||||
color: 'var(--text-secondary)', padding: 8, borderRadius: 12,
|
||||
background: 'rgba(255,255,255,0.06)', flexShrink: 0, marginLeft: 12,
|
||||
}}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 12, flexShrink: 0 }}>
|
||||
{!editing && (
|
||||
<button onClick={() => setEditing(true)} style={{
|
||||
padding: '8px 14px', borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
color: 'var(--text-secondary)', fontSize: 12, fontWeight: 600,
|
||||
}}>
|
||||
Изменить
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} style={{
|
||||
width: 32, height: 32, borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
}}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ padding: '24px 32px 28px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ padding: '20px 28px 24px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
|
||||
{/* Date & Time */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 14,
|
||||
padding: '16px 18px', borderRadius: 16,
|
||||
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 42, height: 42, borderRadius: 14,
|
||||
background: `${event.color}15`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<CalendarDays size={20} color={event.color} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)', textTransform: 'capitalize' }}>
|
||||
{formatFullDate(event.start)}
|
||||
{editing ? (
|
||||
<>
|
||||
{/* Date */}
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>Дата</div>
|
||||
<input type="date" value={editDate} onChange={e => setEditDate(e.target.value)} style={{
|
||||
width: '100%', padding: '12px 16px', borderRadius: 12,
|
||||
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)',
|
||||
color: 'var(--text-primary)', fontSize: 15, outline: 'none', fontFamily: 'inherit',
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-secondary)', marginTop: 4 }}>
|
||||
{event.allDay
|
||||
? 'Весь день'
|
||||
: `${formatTime(event.start)} — ${formatTime(event.end)}`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
{event.location && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '16px 18px', borderRadius: 16,
|
||||
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 42, height: 42, borderRadius: 14,
|
||||
background: 'rgba(251,146,60,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
{/* All day toggle */}
|
||||
<button onClick={() => setEditAllDay(v => !v)} style={{
|
||||
width: '100%', padding: '12px 16px', borderRadius: 12,
|
||||
background: editAllDay ? `${event.color}10` : 'rgba(255,255,255,0.025)',
|
||||
border: `1px solid ${editAllDay ? event.color + '25' : 'rgba(255,255,255,0.06)'}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}}>
|
||||
<MapPin size={20} color="#fb923c" />
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-primary)', fontWeight: 500 }}>{event.location}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{event.description && (
|
||||
<div style={{
|
||||
padding: '16px 18px', borderRadius: 16,
|
||||
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||
<AlignLeft size={15} color="var(--text-secondary)" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Описание</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7,
|
||||
whiteSpace: 'pre-wrap', fontWeight: 400,
|
||||
}}>
|
||||
{event.description}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: 16, marginTop: 4 }}>
|
||||
{!confirmDelete ? (
|
||||
<button onClick={() => setConfirmDelete(true)} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
padding: '13px 18px', borderRadius: 14, width: '100%',
|
||||
background: 'rgba(239,68,68,0.06)', border: '1px solid rgba(239,68,68,0.15)',
|
||||
color: '#f87171', fontSize: 14, fontWeight: 600,
|
||||
transition: 'all 0.25s ease',
|
||||
}}>
|
||||
<Trash2 size={16} /> Удалить событие
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={handleDelete} disabled={deleting} style={{
|
||||
flex: 1, padding: '13px 18px', borderRadius: 14,
|
||||
background: deleting ? 'rgba(239,68,68,0.08)' : 'rgba(239,68,68,0.15)',
|
||||
border: '1px solid rgba(239,68,68,0.3)',
|
||||
color: '#f87171', fontSize: 14, fontWeight: 600,
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: editAllDay ? event.color : 'var(--text-secondary)' }}>Весь день</span>
|
||||
<div style={{
|
||||
width: 40, height: 22, borderRadius: 11,
|
||||
background: editAllDay ? event.color : 'rgba(255,255,255,0.1)',
|
||||
position: 'relative', transition: 'background 0.2s ease',
|
||||
}}>
|
||||
{deleting ? 'Удаление...' : 'Да, удалить'}
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: '50%', background: '#fff',
|
||||
position: 'absolute', top: 2, left: editAllDay ? 20 : 2,
|
||||
transition: 'left 0.2s ease', boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
||||
}} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Time */}
|
||||
{!editAllDay && (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>Время</div>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)',
|
||||
borderRadius: 12, overflow: 'hidden',
|
||||
}}>
|
||||
<input type="time" value={editStartTime} onChange={e => setEditStartTime(e.target.value)} style={{
|
||||
flex: 1, padding: '12px 16px', border: 'none', background: 'transparent',
|
||||
color: 'var(--text-primary)', fontSize: 16, fontWeight: 600,
|
||||
fontFamily: 'inherit', outline: 'none', textAlign: 'center',
|
||||
}} />
|
||||
<div style={{
|
||||
padding: '0 10px', color: 'var(--text-tertiary)', fontSize: 12,
|
||||
borderLeft: '1px solid rgba(255,255,255,0.06)',
|
||||
borderRight: '1px solid rgba(255,255,255,0.06)',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
height: '100%', display: 'flex', alignItems: 'center',
|
||||
}}>до</div>
|
||||
<input type="time" value={editEndTime} onChange={e => setEditEndTime(e.target.value)} style={{
|
||||
flex: 1, padding: '12px 16px', border: 'none', background: 'transparent',
|
||||
color: 'var(--text-primary)', fontSize: 16, fontWeight: 600,
|
||||
fontFamily: 'inherit', outline: 'none', textAlign: 'center',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<div style={{ color: '#f87171', fontSize: 13, padding: '8px 12px', borderRadius: 10, background: 'rgba(239,68,68,0.08)' }}>{saveError}</div>
|
||||
)}
|
||||
|
||||
{/* Save / Cancel */}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<button onClick={handleSave} disabled={saving} style={{
|
||||
flex: 1, padding: '13px', borderRadius: 14,
|
||||
background: `linear-gradient(135deg, ${event.color}45, ${event.color}30)`,
|
||||
border: `1px solid ${event.color}35`,
|
||||
color: '#c7d2fe', fontSize: 14, fontWeight: 700,
|
||||
}}>
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(false)} style={{
|
||||
flex: 1, padding: '13px 18px', borderRadius: 14,
|
||||
<button onClick={() => { setEditing(false); setEditTitle(event.title) }} style={{
|
||||
padding: '13px 20px', borderRadius: 14,
|
||||
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)',
|
||||
color: 'var(--text-secondary)', fontSize: 14, fontWeight: 600,
|
||||
}}>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* View mode */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '14px 16px', borderRadius: 14,
|
||||
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<CalendarDays size={18} color={event.color} />
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', textTransform: 'capitalize' }}>
|
||||
{formatFullDate(event.start)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 3 }}>
|
||||
{event.allDay ? 'Весь день' : `${formatTime(event.start)} — ${formatTime(event.end)}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event.location && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '14px 16px', borderRadius: 14,
|
||||
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<MapPin size={18} color="#fb923c" />
|
||||
<div style={{ fontSize: 14, color: 'var(--text-primary)', fontWeight: 500 }}>{event.location}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.description && (
|
||||
<div style={{
|
||||
padding: '14px 16px', borderRadius: 14,
|
||||
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<AlignLeft size={14} color="var(--text-secondary)" />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-secondary)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Описание</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>{event.description}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: 14, marginTop: 4 }}>
|
||||
{!confirmDelete ? (
|
||||
<button onClick={() => setConfirmDelete(true)} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
padding: '12px', borderRadius: 14, width: '100%',
|
||||
background: 'rgba(239,68,68,0.06)', border: '1px solid rgba(239,68,68,0.15)',
|
||||
color: '#f87171', fontSize: 13, fontWeight: 600,
|
||||
}}>
|
||||
<Trash2 size={15} /> Удалить
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={handleDelete} disabled={deleting} style={{
|
||||
flex: 1, padding: '12px', borderRadius: 14,
|
||||
background: deleting ? 'rgba(239,68,68,0.06)' : 'rgba(239,68,68,0.12)',
|
||||
border: '1px solid rgba(239,68,68,0.25)',
|
||||
color: '#f87171', fontSize: 13, fontWeight: 600,
|
||||
}}>
|
||||
{deleting ? 'Удаление...' : 'Да, удалить'}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(false)} style={{
|
||||
flex: 1, padding: '12px', borderRadius: 14,
|
||||
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)',
|
||||
color: 'var(--text-secondary)', fontSize: 13, fontWeight: 600,
|
||||
}}>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ————— Day Events Popover —————
|
||||
function DayEventsModal({ day, month, year, events, onClose, onSelect }: {
|
||||
day: number; month: number; year: number
|
||||
@@ -734,7 +852,7 @@ export default function CalendarTab() {
|
||||
|
||||
{/* Modals */}
|
||||
{selectedEvent && (
|
||||
<EventDetailModal event={selectedEvent} onClose={() => setSelectedEvent(null)} onDelete={deleteEvent} />
|
||||
<EventDetailModal event={selectedEvent} onClose={() => setSelectedEvent(null)} onDelete={deleteEvent} onUpdate={updateEvent} />
|
||||
)}
|
||||
|
||||
{dayPopover && (
|
||||
|
||||
@@ -111,13 +111,15 @@ export default function DeviceCard({
|
||||
|
||||
{/* Top: icon + toggle */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', position: 'relative', zIndex: 1 }}>
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: 16,
|
||||
background: isOn ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.05)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: isOn ? '0 4px 16px rgba(0,0,0,0.1)' : 'none',
|
||||
}}>
|
||||
<div
|
||||
className={isOn ? (id.includes('air_purifier') ? 'fan-spinning' : id.includes('light') ? 'light-on-pulse' : 'device-active-breathe') : ''}
|
||||
style={{
|
||||
width: 48, height: 48, borderRadius: 16,
|
||||
background: isOn ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.05)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: isOn ? '0 4px 16px rgba(0,0,0,0.1)' : 'none',
|
||||
}}>
|
||||
{getDeviceIcon(id, isOn)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ export default function TopBar({ weather, sensors, haConnected }: TopBarProps) {
|
||||
fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em', fontWeight: 600, marginBottom: 14,
|
||||
}}>
|
||||
Прогноз на ближайшие дни
|
||||
Прогноз на неделю
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{weather.forecast.map(day => {
|
||||
|
||||
Reference in New Issue
Block a user