feat: premium UI redesign — glassmorphism, gradient accents, ambient background
All checks were successful
Deploy / deploy (push) Successful in 2m40s
All checks were successful
Deploy / deploy (push) Successful in 2m40s
This commit is contained in:
@@ -27,18 +27,24 @@ function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string;
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const inputStyle = {
|
||||
padding: '12px 16px',
|
||||
borderRadius: 14,
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
fontFamily: 'inherit',
|
||||
transition: 'border-color 0.2s ease',
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!title.trim()) { setError('Введите название'); return }
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const body = {
|
||||
title: title.trim(),
|
||||
date,
|
||||
startTime: allDay ? null : startTime,
|
||||
endTime: allDay ? null : endTime,
|
||||
allDay,
|
||||
}
|
||||
const body = { title: title.trim(), date, startTime: allDay ? null : startTime, endTime: allDay ? null : endTime, allDay }
|
||||
const r = await fetch('/api/calendar', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
const d = await r.json()
|
||||
if (d.error) throw new Error(d.error)
|
||||
@@ -50,31 +56,20 @@ function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={onClose}>
|
||||
<div style={{ background: 'var(--bg)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 20, padding: 24, maxWidth: 340, width: '100%' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<span style={{ fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>Новое событие</span>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer' }}><X size={18} /></button>
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)', 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: 24, padding: 28, maxWidth: 360, width: '100%', boxShadow: '0 25px 60px rgba(0,0,0,0.5)' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 24 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>Новое событие</span>
|
||||
<button onClick={onClose} style={{ color: 'var(--text-secondary)', padding: 4 }}><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="Название события"
|
||||
autoFocus
|
||||
style={{ padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', fontSize: 14, outline: 'none' }}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
style={{ padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', fontSize: 14, outline: 'none' }}
|
||||
/>
|
||||
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="Название события" autoFocus style={inputStyle} />
|
||||
<input type="date" value={date} onChange={e => setDate(e.target.value)} style={inputStyle} />
|
||||
{!allDay && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input type="time" value={startTime} onChange={e => setStartTime(e.target.value)} style={{ flex: 1, padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', fontSize: 14, outline: 'none' }} />
|
||||
<input type="time" value={endTime} onChange={e => setEndTime(e.target.value)} style={{ flex: 1, padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', fontSize: 14, outline: 'none' }} />
|
||||
<input type="time" value={startTime} onChange={e => setStartTime(e.target.value)} style={{ ...inputStyle, flex: 1 }} />
|
||||
<input type="time" value={endTime} onChange={e => setEndTime(e.target.value)} style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
)}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', fontSize: 13, cursor: 'pointer' }}>
|
||||
@@ -85,7 +80,17 @@ function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string;
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
style={{ padding: '11px', borderRadius: 12, background: saving ? 'rgba(99,102,241,0.3)' : 'rgba(99,102,241,0.5)', border: '1px solid rgba(99,102,241,0.5)', color: '#a5b4fc', fontSize: 14, fontWeight: 600, cursor: saving ? 'default' : 'pointer', touchAction: 'manipulation' }}
|
||||
style={{
|
||||
padding: '13px',
|
||||
borderRadius: 14,
|
||||
background: saving ? 'rgba(99,102,241,0.2)' : 'linear-gradient(135deg, rgba(99,102,241,0.4), rgba(139,92,246,0.3))',
|
||||
border: '1px solid rgba(129,140,248,0.3)',
|
||||
color: '#a5b4fc',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: saving ? 'default' : 'pointer',
|
||||
transition: 'all 0.25s ease',
|
||||
}}
|
||||
>
|
||||
{saving ? 'Сохранение...' : 'Создать событие'}
|
||||
</button>
|
||||
@@ -102,9 +107,9 @@ export default function CalendarTab() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [addDate, setAddDate] = useState<string>('')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [addDate, setAddDate] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
@@ -114,7 +119,6 @@ export default function CalendarTab() {
|
||||
.catch(() => setLoading(false))
|
||||
}, [year, month])
|
||||
|
||||
|
||||
const deleteEvent = async (event: CalendarEvent) => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
@@ -131,15 +135,12 @@ export default function CalendarTab() {
|
||||
}
|
||||
}
|
||||
|
||||
// Upcoming events (next 30 days)
|
||||
const upcoming = events
|
||||
.filter(e => new Date(e.start) >= new Date())
|
||||
.slice(0, 6)
|
||||
|
||||
// Build calendar grid
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const lastDay = new Date(year, month + 1, 0)
|
||||
// Monday-based week: 0=Mon, 6=Sun
|
||||
const startOffset = (firstDay.getDay() + 6) % 7
|
||||
const totalCells = Math.ceil((startOffset + lastDay.getDate()) / 7) * 7
|
||||
const cells: (number | null)[] = []
|
||||
@@ -161,25 +162,46 @@ export default function CalendarTab() {
|
||||
const today = new Date()
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden', padding: '12px 16px 16px', gap: 16 }}>
|
||||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden', padding: '16px 24px 24px', gap: 20 }}>
|
||||
{/* Main calendar grid */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button onClick={prevMonth} style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<button onClick={prevMonth} style={{
|
||||
width: 36, height: 36, borderRadius: 12,
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
color: 'var(--text-primary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
}}>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', minWidth: 160, textAlign: 'center' }}>
|
||||
<span style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', minWidth: 180, textAlign: 'center' }}>
|
||||
{MONTHS[month]} {year}
|
||||
</span>
|
||||
<button onClick={nextMonth} style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<button onClick={nextMonth} style={{
|
||||
width: 36, height: 36, borderRadius: 12,
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
color: 'var(--text-primary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
}}>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setAddDate(today.toISOString().split('T')[0]); setShowAddModal(true) }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 10, background: 'rgba(99,102,241,0.2)', border: '1px solid rgba(99,102,241,0.4)', color: '#a5b4fc', fontSize: 13, fontWeight: 600, cursor: 'pointer', touchAction: 'manipulation' }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 18px', borderRadius: 14,
|
||||
background: 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15))',
|
||||
border: '1px solid rgba(129,140,248,0.25)',
|
||||
color: '#a5b4fc', fontSize: 13, fontWeight: 600,
|
||||
transition: 'all 0.25s ease',
|
||||
}}
|
||||
>
|
||||
<Plus size={15} />
|
||||
Событие
|
||||
@@ -187,18 +209,24 @@ export default function CalendarTab() {
|
||||
</div>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', marginBottom: 4 }}>
|
||||
{WEEKDAYS.map(d => (
|
||||
<div key={d} style={{ textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', padding: '4px 0' }}>{d}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', marginBottom: 6 }}>
|
||||
{WEEKDAYS.map((d, i) => (
|
||||
<div key={d} style={{
|
||||
textAlign: 'center', fontSize: 11, fontWeight: 600,
|
||||
color: i >= 5 ? 'rgba(248,113,113,0.5)' : 'var(--text-secondary)',
|
||||
textTransform: 'uppercase', padding: '6px 0', letterSpacing: '0.05em',
|
||||
}}>{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar cells */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', flex: 1, gap: 2 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', flex: 1, gap: 3 }}>
|
||||
{cells.map((day, idx) => {
|
||||
if (!day) return <div key={idx} />
|
||||
const dayEvents = getEventsForDay(day)
|
||||
const isToday = today.getFullYear() === year && today.getMonth() === month && today.getDate() === day
|
||||
const dayOfWeek = (startOffset + day - 1) % 7
|
||||
const isWeekend = dayOfWeek >= 5
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
@@ -210,34 +238,40 @@ export default function CalendarTab() {
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
padding: '4px 3px',
|
||||
background: isToday ? 'rgba(99,102,241,0.12)' : 'rgba(255,255,255,0.02)',
|
||||
border: isToday ? '1px solid rgba(99,102,241,0.35)' : '1px solid transparent',
|
||||
borderRadius: 12,
|
||||
padding: '5px 4px',
|
||||
background: isToday
|
||||
? 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.08))'
|
||||
: 'rgba(255,255,255,0.015)',
|
||||
border: isToday
|
||||
? '1px solid rgba(129,140,248,0.3)'
|
||||
: '1px solid transparent',
|
||||
cursor: 'pointer',
|
||||
minHeight: 52,
|
||||
minHeight: 56,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
touchAction: 'manipulation',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, fontWeight: isToday ? 700 : 400, color: isToday ? '#a5b4fc' : 'var(--text-secondary)', textAlign: 'right', paddingRight: 3 }}>{day}</span>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
fontWeight: isToday ? 700 : 500,
|
||||
color: isToday ? '#a5b4fc' : isWeekend ? 'rgba(248,113,113,0.6)' : 'var(--text-secondary)',
|
||||
textAlign: 'right', paddingRight: 4,
|
||||
}}>{day}</span>
|
||||
{dayEvents.slice(0, 2).map(e => (
|
||||
<div
|
||||
key={e.id}
|
||||
onClick={ev => { ev.stopPropagation(); setSelectedEvent(e) }}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
background: e.color + '33',
|
||||
border: `1px solid ${e.color}55`,
|
||||
fontSize: 10, fontWeight: 600,
|
||||
background: e.color + '1a',
|
||||
border: `1px solid ${e.color}30`,
|
||||
color: e.color,
|
||||
borderRadius: 4,
|
||||
padding: '1px 4px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
borderRadius: 6,
|
||||
padding: '2px 5px',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
@@ -254,10 +288,18 @@ export default function CalendarTab() {
|
||||
</div>
|
||||
|
||||
{/* Right panel: upcoming events */}
|
||||
<div style={{ width: 200, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 4 }}>Ближайшие</div>
|
||||
<div style={{
|
||||
width: 220, flexShrink: 0,
|
||||
display: 'flex', flexDirection: 'column', gap: 10,
|
||||
overflowY: 'auto',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
borderRadius: 22,
|
||||
padding: '20px 16px',
|
||||
border: '1px solid rgba(255,255,255,0.04)',
|
||||
}}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>Ближайшие</div>
|
||||
{upcoming.length === 0 && !loading && (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Нет событий</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', padding: '20px 0', textAlign: 'center' }}>Нет событий</div>
|
||||
)}
|
||||
{upcoming.map(e => {
|
||||
const d = new Date(e.start)
|
||||
@@ -266,25 +308,29 @@ export default function CalendarTab() {
|
||||
key={e.id}
|
||||
onClick={() => setSelectedEvent(e)}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
padding: '10px 12px',
|
||||
background: e.color + '18',
|
||||
border: `1px solid ${e.color}33`,
|
||||
borderRadius: 16,
|
||||
padding: '14px 14px',
|
||||
background: `${e.color}0c`,
|
||||
border: `1px solid ${e.color}1a`,
|
||||
cursor: 'pointer',
|
||||
touchAction: 'manipulation',
|
||||
transition: 'all 0.25s ease',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 8, background: e.color + '33', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 10,
|
||||
background: `${e.color}1a`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: e.color }}>{d.getDate()}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: e.color, fontWeight: 600 }}>{MONTHS[d.getMonth()].slice(0, 3)}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.title}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 2 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.title}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 4 }}>
|
||||
{e.allDay ? 'Весь день' : d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: e.color, marginTop: 3, fontWeight: 500 }}>{e.ownerName}</div>
|
||||
<div style={{ fontSize: 10, color: e.color, marginTop: 4, fontWeight: 500 }}>{e.ownerName}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -292,38 +338,66 @@ export default function CalendarTab() {
|
||||
|
||||
{/* Event detail modal */}
|
||||
{selectedEvent && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={() => { setSelectedEvent(null); setConfirmDelete(false) }}>
|
||||
<div style={{ background: 'var(--bg)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 20, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.5)' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
||||
<div style={{ width: 4, height: 40, borderRadius: 2, background: selectedEvent.color, marginRight: 12, flexShrink: 0, marginTop: 2 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{selectedEvent.title}</div>
|
||||
<div style={{ fontSize: 12, color: selectedEvent.color, fontWeight: 500, marginTop: 2 }}>{selectedEvent.ownerName}</div>
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={() => { setSelectedEvent(null); setConfirmDelete(false) }}>
|
||||
<div style={{
|
||||
background: 'rgba(18,18,35,0.95)',
|
||||
backdropFilter: 'blur(40px)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: 24, padding: 28, maxWidth: 380, width: '100%',
|
||||
boxShadow: '0 25px 60px rgba(0,0,0,0.5)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', gap: 14 }}>
|
||||
<div style={{ width: 4, borderRadius: 3, background: selectedEvent.color, alignSelf: 'stretch', minHeight: 44, flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{selectedEvent.title}</div>
|
||||
<div style={{ fontSize: 13, color: selectedEvent.color, fontWeight: 500, marginTop: 3 }}>{selectedEvent.ownerName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setSelectedEvent(null)} style={{ background: 'none', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer', padding: 4 }}><X size={18} /></button>
|
||||
<button onClick={() => { setSelectedEvent(null); setConfirmDelete(false) }} style={{ color: 'var(--text-secondary)', padding: 4 }}><X size={18} /></button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', fontSize: 13 }}>
|
||||
<Clock size={14} />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '10px 14px', borderRadius: 12,
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
color: 'var(--text-secondary)', fontSize: 13,
|
||||
}}>
|
||||
<Clock size={15} />
|
||||
{selectedEvent.allDay
|
||||
? 'Весь день'
|
||||
: `${new Date(selectedEvent.start).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} — ${new Date(selectedEvent.end).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`
|
||||
}
|
||||
</div>
|
||||
{selectedEvent.location && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', fontSize: 13 }}>
|
||||
<MapPin size={14} /> {selectedEvent.location}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '10px 14px', borderRadius: 12,
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
color: 'var(--text-secondary)', fontSize: 13,
|
||||
}}>
|
||||
<MapPin size={15} /> {selectedEvent.location}
|
||||
</div>
|
||||
)}
|
||||
{selectedEvent.description && (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4, lineHeight: 1.5 }}>{selectedEvent.description}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4, lineHeight: 1.6 }}>{selectedEvent.description}</div>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
<div style={{ marginTop: 8, borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 12 }}>
|
||||
<div style={{ marginTop: 8, borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 14 }}>
|
||||
{!confirmDelete ? (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 10, background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', color: '#f87171', fontSize: 13, fontWeight: 600, cursor: 'pointer', touchAction: 'manipulation', width: '100%', justifyContent: 'center' }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 14px', borderRadius: 12,
|
||||
background: 'rgba(239,68,68,0.08)',
|
||||
border: '1px solid rgba(239,68,68,0.2)',
|
||||
color: '#f87171', fontSize: 13, fontWeight: 600,
|
||||
width: '100%', justifyContent: 'center',
|
||||
transition: 'all 0.25s ease',
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Удалить событие
|
||||
@@ -333,13 +407,26 @@ export default function CalendarTab() {
|
||||
<button
|
||||
onClick={() => deleteEvent(selectedEvent)}
|
||||
disabled={deleting}
|
||||
style={{ flex: 1, padding: '8px 14px', borderRadius: 10, background: deleting ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.25)', border: '1px solid rgba(239,68,68,0.5)', color: '#f87171', fontSize: 13, fontWeight: 600, cursor: deleting ? 'default' : 'pointer', touchAction: 'manipulation' }}
|
||||
style={{
|
||||
flex: 1, padding: '10px 14px', borderRadius: 12,
|
||||
background: deleting ? 'rgba(239,68,68,0.1)' : 'rgba(239,68,68,0.2)',
|
||||
border: '1px solid rgba(239,68,68,0.35)',
|
||||
color: '#f87171', fontSize: 13, fontWeight: 600,
|
||||
cursor: deleting ? 'default' : 'pointer',
|
||||
transition: 'all 0.25s ease',
|
||||
}}
|
||||
>
|
||||
{deleting ? 'Удаление...' : 'Да, удалить'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
style={{ flex: 1, padding: '8px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-secondary)', fontSize: 13, fontWeight: 600, cursor: 'pointer', touchAction: 'manipulation' }}
|
||||
style={{
|
||||
flex: 1, padding: '10px 14px', borderRadius: 12,
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
color: 'var(--text-secondary)', fontSize: 13, fontWeight: 600,
|
||||
transition: 'all 0.25s ease',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
@@ -351,7 +438,6 @@ export default function CalendarTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add event modal */}
|
||||
{showAddModal && (
|
||||
<AddEventModal
|
||||
defaultDate={addDate}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Fan, Lightbulb, Tv, Snowflake, Power } from 'lucide-react'
|
||||
|
||||
interface DeviceCardProps {
|
||||
id: string
|
||||
@@ -13,6 +14,32 @@ interface DeviceCardProps {
|
||||
extraInfo?: string
|
||||
}
|
||||
|
||||
function getDeviceIcon(id: string, isOn: boolean) {
|
||||
const color = isOn ? '#a5b4fc' : 'rgba(255,255,255,0.35)'
|
||||
const size = 24
|
||||
if (id.includes('air_purifier')) return <Fan size={size} color={color} />
|
||||
if (id.includes('light')) return <Lightbulb size={size} color={isOn ? '#fbbf24' : color} />
|
||||
if (id.includes('tv')) return <Tv size={size} color={color} />
|
||||
if (id.includes('ac')) return <Snowflake size={size} color={isOn ? '#22d3ee' : color} />
|
||||
return <Power size={size} color={color} />
|
||||
}
|
||||
|
||||
function getDeviceGradient(id: string): string {
|
||||
if (id.includes('air_purifier')) return 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.12))'
|
||||
if (id.includes('light')) return 'linear-gradient(135deg, rgba(251,191,36,0.18), rgba(245,158,11,0.1))'
|
||||
if (id.includes('tv')) return 'linear-gradient(135deg, rgba(59,130,246,0.18), rgba(99,102,241,0.1))'
|
||||
if (id.includes('ac')) return 'linear-gradient(135deg, rgba(34,211,238,0.18), rgba(6,182,212,0.1))'
|
||||
return 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.08))'
|
||||
}
|
||||
|
||||
function getDeviceBorder(id: string): string {
|
||||
if (id.includes('air_purifier')) return 'rgba(129,140,248,0.25)'
|
||||
if (id.includes('light')) return 'rgba(251,191,36,0.25)'
|
||||
if (id.includes('tv')) return 'rgba(99,102,241,0.25)'
|
||||
if (id.includes('ac')) return 'rgba(34,211,238,0.25)'
|
||||
return 'rgba(129,140,248,0.2)'
|
||||
}
|
||||
|
||||
export default function DeviceCard({
|
||||
id, name, icon, entityId, domain, initialState, isMock = false, extraInfo,
|
||||
}: DeviceCardProps) {
|
||||
@@ -20,7 +47,6 @@ export default function DeviceCard({
|
||||
const [synced, setSynced] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Sync from HA only once — when real data first arrives (not mock default)
|
||||
useEffect(() => {
|
||||
if (!synced && !isMock) {
|
||||
setIsOn(initialState)
|
||||
@@ -30,7 +56,7 @@ export default function DeviceCard({
|
||||
|
||||
const toggle = async () => {
|
||||
const next = !isOn
|
||||
setIsOn(next) // optimistic
|
||||
setIsOn(next)
|
||||
|
||||
if (!isMock && entityId && domain) {
|
||||
setLoading(true)
|
||||
@@ -52,69 +78,98 @@ export default function DeviceCard({
|
||||
}
|
||||
}
|
||||
|
||||
const accent = '#00d4ff'
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: isOn ? 'rgba(0,212,255,0.07)' : 'rgba(255,255,255,0.04)',
|
||||
border: isOn ? '1px solid rgba(0,212,255,0.25)' : '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: 18,
|
||||
padding: '18px 16px 16px',
|
||||
minHeight: 140,
|
||||
background: isOn ? getDeviceGradient(id) : 'rgba(255,255,255,0.03)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
border: isOn ? `1px solid ${getDeviceBorder(id)}` : '1px solid rgba(255,255,255,0.06)',
|
||||
borderRadius: 22,
|
||||
padding: '20px 18px 18px',
|
||||
minHeight: 150,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
transition: 'background 0.25s ease, border-color 0.25s ease',
|
||||
boxShadow: isOn ? '0 0 24px rgba(0,212,255,0.06)' : 'none',
|
||||
transition: 'all 0.35s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: isOn ? '0 8px 32px rgba(99,102,241,0.1)' : '0 2px 8px rgba(0,0,0,0.1)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Top: icon + toggle */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
{/* Subtle glow effect when on */}
|
||||
{isOn && (
|
||||
<div style={{
|
||||
width: 42, height: 42, borderRadius: 12,
|
||||
background: isOn ? 'rgba(0,212,255,0.15)' : 'rgba(255,255,255,0.06)',
|
||||
position: 'absolute',
|
||||
top: -30,
|
||||
right: -30,
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(129,140,248,0.15) 0%, transparent 70%)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
)}
|
||||
|
||||
{/* 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',
|
||||
fontSize: 20,
|
||||
transition: 'background 0.25s ease',
|
||||
boxShadow: isOn ? '0 0 16px rgba(0,212,255,0.25)' : 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: isOn ? '0 4px 16px rgba(0,0,0,0.1)' : 'none',
|
||||
}}>
|
||||
{icon}
|
||||
{getDeviceIcon(id, isOn)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: 52, height: 30, borderRadius: 15,
|
||||
background: isOn ? `linear-gradient(90deg, #00b4d8, #00d4ff)` : 'rgba(255,255,255,0.1)',
|
||||
width: 54, height: 30, borderRadius: 15,
|
||||
background: isOn
|
||||
? 'linear-gradient(90deg, #6366f1, #8b5cf6)'
|
||||
: 'rgba(255,255,255,0.08)',
|
||||
position: 'relative', border: 'none', cursor: 'pointer',
|
||||
flexShrink: 0, touchAction: 'manipulation',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
transition: 'background 0.25s ease',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.3s ease',
|
||||
opacity: loading ? 0.6 : 1,
|
||||
boxShadow: isOn ? '0 0 10px rgba(0,212,255,0.4)' : 'none',
|
||||
boxShadow: isOn ? '0 0 16px rgba(99,102,241,0.3)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', top: 4,
|
||||
left: isOn ? 26 : 4,
|
||||
left: isOn ? 28 : 4,
|
||||
width: 22, height: 22, borderRadius: '50%',
|
||||
background: '#fff',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.3)',
|
||||
transition: 'left 0.22s ease',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.25)',
|
||||
transition: 'left 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
display: 'block',
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bottom: name + status */}
|
||||
<div>
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3 }}>
|
||||
{name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: isOn ? accent : 'var(--text-secondary)', marginTop: 3 }}>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: isOn ? '#a5b4fc' : 'var(--text-secondary)',
|
||||
marginTop: 4,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{isOn ? 'Включён' : 'Выключен'}
|
||||
{extraInfo && isOn ? ` · ${extraInfo}` : ''}
|
||||
</div>
|
||||
{isMock && (
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
color: 'var(--text-tertiary)',
|
||||
marginTop: 2,
|
||||
fontStyle: 'italic',
|
||||
}}>demo</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -18,8 +18,8 @@ export default function RoomTabs({ rooms, active, onChange }: RoomTabsProps) {
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
padding: '12px 20px',
|
||||
gap: 8,
|
||||
padding: '14px 24px',
|
||||
overflowX: 'auto',
|
||||
flexShrink: 0,
|
||||
WebkitOverflowScrolling: 'touch' as any,
|
||||
@@ -35,35 +35,39 @@ export default function RoomTabs({ rooms, active, onChange }: RoomTabsProps) {
|
||||
onClick={() => onChange(room.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
padding: '10px 18px',
|
||||
borderRadius: 14,
|
||||
background: isActive ? 'rgba(0,212,255,0.12)' : 'rgba(255,255,255,0.04)',
|
||||
border: isActive ? '1px solid rgba(0,212,255,0.3)' : '1px solid rgba(255,255,255,0.06)',
|
||||
minWidth: 90,
|
||||
gap: 8,
|
||||
padding: '10px 20px',
|
||||
borderRadius: 16,
|
||||
background: isActive
|
||||
? 'linear-gradient(135deg, rgba(99,102,241,0.18), rgba(139,92,246,0.12))'
|
||||
: 'rgba(255,255,255,0.03)',
|
||||
border: isActive
|
||||
? '1px solid rgba(129,140,248,0.25)'
|
||||
: '1px solid rgba(255,255,255,0.05)',
|
||||
minWidth: 'fit-content',
|
||||
flexShrink: 0,
|
||||
touchAction: 'manipulation',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
transition: 'all 0.2s ease',
|
||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: isActive ? '0 4px 16px rgba(99,102,241,0.1)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 18 }}>{room.emoji}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
color: isActive ? 'var(--accent)' : 'var(--text-primary)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{room.name}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-secondary)' }}>
|
||||
{room.deviceCount} {room.deviceCount === 1 ? 'устройство' : room.deviceCount >= 2 && room.deviceCount <= 4 ? 'устройства' : 'устройств'}
|
||||
<span style={{ fontSize: 18 }}>{room.emoji}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
color: isActive ? '#a5b4fc' : 'var(--text-primary)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{room.name}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
color: isActive ? 'rgba(165,180,252,0.6)' : 'var(--text-tertiary)',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{room.deviceCount}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -20,19 +20,19 @@ export default function Sidebar({ active, onChange }: SidebarProps) {
|
||||
return (
|
||||
<nav
|
||||
style={{
|
||||
width: 72,
|
||||
minWidth: 72,
|
||||
width: 78,
|
||||
minWidth: 78,
|
||||
height: '100dvh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
paddingTop: 20,
|
||||
paddingBottom: 20,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
borderRight: '1px solid rgba(255,255,255,0.05)',
|
||||
gap: 8,
|
||||
paddingTop: 24,
|
||||
paddingBottom: 24,
|
||||
background: 'rgba(12, 12, 24, 0.6)',
|
||||
backdropFilter: 'blur(30px)',
|
||||
WebkitBackdropFilter: 'blur(30px)',
|
||||
borderRight: '1px solid rgba(255,255,255,0.04)',
|
||||
gap: 6,
|
||||
flexShrink: 0,
|
||||
zIndex: 10,
|
||||
}}
|
||||
@@ -40,19 +40,20 @@ export default function Sidebar({ active, onChange }: SidebarProps) {
|
||||
{/* Logo */}
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 14,
|
||||
background: 'linear-gradient(135deg, #00d4ff22, #00d4ff44)',
|
||||
border: '1px solid rgba(0,212,255,0.3)',
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 16,
|
||||
background: 'linear-gradient(135deg, rgba(99,102,241,0.25), rgba(139,92,246,0.25))',
|
||||
border: '1px solid rgba(129,140,248,0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 24,
|
||||
marginBottom: 28,
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 0 20px rgba(99,102,241,0.15)',
|
||||
}}
|
||||
>
|
||||
<Home size={20} color="#00d4ff" />
|
||||
<Home size={22} color="#818cf8" />
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
@@ -64,19 +65,38 @@ export default function Sidebar({ active, onChange }: SidebarProps) {
|
||||
onClick={() => onChange(id)}
|
||||
title={label}
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 14,
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: isActive ? 'rgba(0,212,255,0.15)' : 'transparent',
|
||||
border: isActive ? '1px solid rgba(0,212,255,0.25)' : '1px solid transparent',
|
||||
transition: 'all 0.2s ease',
|
||||
gap: 4,
|
||||
background: isActive
|
||||
? 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15))'
|
||||
: 'transparent',
|
||||
border: isActive
|
||||
? '1px solid rgba(129,140,248,0.25)'
|
||||
: '1px solid transparent',
|
||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
flexShrink: 0,
|
||||
boxShadow: isActive ? '0 0 20px rgba(99,102,241,0.1)' : 'none',
|
||||
}}
|
||||
>
|
||||
<Icon size={22} color={isActive ? '#00d4ff' : 'rgba(255,255,255,0.4)'} />
|
||||
<Icon
|
||||
size={20}
|
||||
color={isActive ? '#a5b4fc' : 'rgba(255,255,255,0.3)'}
|
||||
strokeWidth={isActive ? 2.2 : 1.8}
|
||||
/>
|
||||
<span style={{
|
||||
fontSize: 9,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
color: isActive ? '#a5b4fc' : 'rgba(255,255,255,0.25)',
|
||||
letterSpacing: '0.02em',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Cloud, Droplets, Wind } from 'lucide-react'
|
||||
import { Droplets, Wind, Thermometer, X } from 'lucide-react'
|
||||
|
||||
interface WeatherData {
|
||||
temp: string
|
||||
@@ -23,7 +23,7 @@ interface TopBarProps {
|
||||
sensors: SensorData | null
|
||||
}
|
||||
|
||||
function getWeatherEmoji(desc: string): string {
|
||||
function getWeatherIcon(desc: string): string {
|
||||
const d = desc?.toLowerCase() || ''
|
||||
if (d.includes('ясно') || d.includes('солнеч')) return '☀️'
|
||||
if (d.includes('облач')) return '⛅'
|
||||
@@ -39,7 +39,10 @@ function formatTime(date: Date): string {
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
const weekday = date.toLocaleDateString('ru-RU', { weekday: 'long' })
|
||||
const day = date.getDate()
|
||||
const month = date.toLocaleDateString('ru-RU', { month: 'long' })
|
||||
return `${weekday}, ${day} ${month}`
|
||||
}
|
||||
|
||||
export default function TopBar({ weather, sensors }: TopBarProps) {
|
||||
@@ -55,60 +58,67 @@ export default function TopBar({ weather, sensors }: TopBarProps) {
|
||||
<>
|
||||
<header
|
||||
style={{
|
||||
height: 64,
|
||||
height: 72,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 20px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
padding: '0 24px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
background: 'transparent',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
{/* Left: time + date */}
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
|
||||
<span style={{ fontSize: 22, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.5px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12 }}>
|
||||
<span style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
letterSpacing: '-1px',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{formatTime(time)}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<span style={{
|
||||
fontSize: 14,
|
||||
color: 'var(--text-secondary)',
|
||||
fontWeight: 400,
|
||||
textTransform: 'capitalize',
|
||||
}}>
|
||||
{formatDate(time)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right: sensors + weather */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{/* Room sensors */}
|
||||
{sensors && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>🌡️</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: 'var(--text-primary)' }}>
|
||||
{sensors.temperature}°
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Droplets size={13} color="rgba(255,255,255,0.4)" />
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: 'var(--text-primary)' }}>
|
||||
{sensors.humidity}%
|
||||
</span>
|
||||
</div>
|
||||
{sensors.pm25 !== undefined && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Wind size={13} color="rgba(255,255,255,0.4)" />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
PM2.5: {sensors.pm25}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '8px 14px',
|
||||
borderRadius: 14,
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<Thermometer size={14} color="rgba(255,255,255,0.35)" />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginRight: 8 }}>
|
||||
{sensors.temperature}°
|
||||
</span>
|
||||
<Droplets size={14} color="rgba(255,255,255,0.35)" />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginRight: 8 }}>
|
||||
{sensors.humidity}%
|
||||
</span>
|
||||
<Wind size={14} color="rgba(255,255,255,0.35)" />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
{sensors.pm25}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
{sensors && weather && (
|
||||
<div style={{ width: 1, height: 24, background: 'rgba(255,255,255,0.08)' }} />
|
||||
)}
|
||||
|
||||
{/* Weather widget */}
|
||||
{weather && (
|
||||
<button
|
||||
@@ -116,19 +126,18 @@ export default function TopBar({ weather, sensors }: TopBarProps) {
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 12px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
gap: 8,
|
||||
padding: '8px 16px',
|
||||
borderRadius: 14,
|
||||
background: 'linear-gradient(135deg, rgba(99,102,241,0.1), rgba(139,92,246,0.08))',
|
||||
border: '1px solid rgba(129,140,248,0.15)',
|
||||
color: 'var(--text-primary)',
|
||||
touchAction: 'manipulation',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
transition: 'all 0.25s ease',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16 }}>{getWeatherEmoji(weather.desc)}</span>
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>{weather.temp}°</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{weather.desc}</span>
|
||||
<span style={{ fontSize: 20 }}>{getWeatherIcon(weather.desc)}</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 700 }}>{weather.temp}°</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{weather.desc}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,7 +151,7 @@ export default function TopBar({ weather, sensors }: TopBarProps) {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -152,75 +161,84 @@ export default function TopBar({ weather, sensors }: TopBarProps) {
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
background: '#13131f',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 20,
|
||||
padding: 28,
|
||||
minWidth: 320,
|
||||
maxWidth: 400,
|
||||
background: 'rgba(18, 18, 35, 0.95)',
|
||||
backdropFilter: 'blur(40px)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: 24,
|
||||
padding: 32,
|
||||
minWidth: 360,
|
||||
maxWidth: 420,
|
||||
boxShadow: '0 25px 60px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
|
||||
<span style={{ fontSize: 40 }}>{getWeatherEmoji(weather.desc)}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 36, fontWeight: 700, lineHeight: 1 }}>{weather.temp}°C</div>
|
||||
<div style={{ fontSize: 15, color: 'var(--text-secondary)', marginTop: 2 }}>{weather.desc}</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<span style={{ fontSize: 48 }}>{getWeatherIcon(weather.desc)}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 40, fontWeight: 800, lineHeight: 1, letterSpacing: '-2px' }}>{weather.temp}°</div>
|
||||
<div style={{ fontSize: 15, color: 'var(--text-secondary)', marginTop: 4, fontWeight: 500 }}>{weather.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setShowModal(false)} style={{ color: 'var(--text-secondary)', padding: 4 }}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<Droplets size={13} style={{ marginRight: 4 }} />
|
||||
Влажность: {weather.humidity}%
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<Wind size={13} style={{ marginRight: 4 }} />
|
||||
Ветер: {weather.windSpeed} км/ч
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
Ощущается: {weather.feelsLike}°
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: 10,
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
{[
|
||||
{ icon: <Droplets size={16} />, label: 'Влажность', value: `${weather.humidity}%` },
|
||||
{ icon: <Wind size={16} />, label: 'Ветер', value: `${weather.windSpeed} км/ч` },
|
||||
{ icon: <Thermometer size={16} />, label: 'Ощущается', value: `${weather.feelsLike}°` },
|
||||
].map(item => (
|
||||
<div key={item.label} style={{
|
||||
padding: '14px 12px',
|
||||
borderRadius: 16,
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ color: 'var(--text-secondary)', marginBottom: 8, display: 'flex', justifyContent: 'center' }}>{item.icon}</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{item.value}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 4 }}>{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{weather.forecast && weather.forecast.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 12 }}>
|
||||
<>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600, marginBottom: 14 }}>
|
||||
Прогноз
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{weather.forecast.map(day => {
|
||||
const d = new Date(day.date)
|
||||
const label = d.toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
return (
|
||||
<div key={day.date} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 80 }}>{label}</span>
|
||||
<span style={{ fontSize: 16 }}>{getWeatherEmoji(day.desc)}</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{day.desc}</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}>
|
||||
{day.maxTemp}° / {day.minTemp}°
|
||||
<div key={day.date} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 12,
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100, textTransform: 'capitalize' }}>{label}</span>
|
||||
<span style={{ fontSize: 18 }}>{getWeatherIcon(day.desc)}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', minWidth: 80, textAlign: 'center' }}>{day.desc}</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{day.maxTemp}° <span style={{ color: 'var(--text-secondary)', fontWeight: 400 }}>/ {day.minTemp}°</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
style={{
|
||||
marginTop: 20,
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 14,
|
||||
touchAction: 'manipulation',
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user