Files
smart-home-tablet/components/TimerHomeWidget.tsx
d.klimov 2755980165
Some checks failed
Deploy / deploy (push) Has been cancelled
design: красивые пустые состояния таймеров/Spotify, акцентный прогноз
- таймеры: светящаяся градиентная иконка + голосовая подсказка вместо серого текста
- Spotify: зелёный музыкальный орб + подсказка
- прогноз: ячейка «сейчас» — яркий градиентный акцент с тенью
2026-07-22 23:25:21 +03:00

268 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { AlarmClock, Plus, Timer as TimerIcon } from 'lucide-react'
import TimerModal, { type TimerModalProps } from './TimerModal'
interface Timer {
id: string
label: string
startedAt: string
endsAt: string
agent?: 'cosmo' | 'lusya'
}
function formatRemaining(ms: number): { big: string; small?: string } {
if (ms <= 0) return { big: '0:00' }
const total = Math.ceil(ms / 1000)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
if (h > 0) {
return { big: `${h}:${m.toString().padStart(2, '0')}`, small: `${s.toString().padStart(2, '0')}` }
}
return { big: `${m}:${s.toString().padStart(2, '0')}` }
}
export default function TimerHomeWidget() {
const [timers, setTimers] = useState<Timer[]>([])
const [, setTick] = useState(0)
const [modal, setModal] = useState<TimerModalProps['mode']>(null)
const fetchTimers = async () => {
try {
const r = await fetch('/api/voice/timer')
if (!r.ok) return
const d = await r.json()
setTimers(d.timers || [])
} catch {}
}
useEffect(() => {
let es: EventSource | null = null
let retry: ReturnType<typeof setTimeout> | null = null
let closed = false
const connect = () => {
es = new EventSource('/api/voice/stream')
es.onmessage = (e) => {
try {
const evt = JSON.parse(e.data)
if (evt.event === 'timer_start' || evt.event === 'timer_cancel') {
fetchTimers()
}
} catch {}
}
es.onerror = () => {
if (closed) return
es?.close()
retry = setTimeout(connect, 3000)
}
}
fetchTimers()
connect()
return () => {
closed = true
if (retry) clearTimeout(retry)
es?.close()
}
}, [])
useEffect(() => {
const t = setInterval(() => setTick(x => x + 1), 500)
return () => clearInterval(t)
}, [])
const now = Date.now()
const active = timers
.filter(t => new Date(t.endsAt).getTime() > now - 30_000)
.sort((a, b) => new Date(a.endsAt).getTime() - new Date(b.endsAt).getTime())
// Keep reference to the currently-open timer updated as it ticks down
// (so modal shows fresh countdown without reopening)
const modalTimer =
modal?.type === 'control' ? active.find(t => t.id === modal.timer.id) || modal.timer : null
const resolvedMode: TimerModalProps['mode'] = modal
? modal.type === 'create'
? modal
: modalTimer
? { type: 'control', timer: modalTimer }
: null // timer исчез (отменили / прозвенел >30с) — закроем
: null
useEffect(() => {
// Если таймер пропал из active, автоматом закрыть модалку
if (modal?.type === 'control' && !modalTimer) {
setModal(null)
}
}, [modal, modalTimer])
return (
<>
<div className="card" style={{
padding: '18px 20px',
display: 'flex', flexDirection: 'column', gap: 14,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<TimerIcon size={13} color="var(--text-secondary)" />
<span style={{
fontSize: 10, color: 'var(--text-secondary)',
textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700,
}}>
Таймеры
</span>
{active.length > 0 && (
<span style={{
fontSize: 10, color: 'var(--text-tertiary)',
background: 'var(--surface-2)', padding: '2px 7px', borderRadius: 8,
fontWeight: 700, fontVariantNumeric: 'tabular-nums',
}}>
{active.length}
</span>
)}
</div>
<button
onClick={() => setModal({ type: 'create' })}
aria-label="Новый таймер"
style={{
width: 44, height: 44, borderRadius: 14,
background: 'color-mix(in srgb, var(--accent) 14%, var(--surface-2))',
border: '1px solid color-mix(in srgb, var(--accent) 30%, var(--border-subtle))',
color: 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
}}
>
<Plus size={18} />
</button>
</div>
{active.length === 0 ? (
<button
onClick={() => setModal({ type: 'create' })}
style={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
padding: '24px 0', gap: 16,
color: 'var(--text-secondary)',
background: 'transparent', border: 'none', cursor: 'pointer',
}}
>
<div style={{ position: 'relative', width: 92, height: 92, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{
position: 'absolute', inset: 0, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(139,120,252,0.4), transparent 70%)', filter: 'blur(8px)',
}} />
<div style={{
position: 'absolute', inset: 14, borderRadius: '50%',
background: 'linear-gradient(135deg, rgba(109,94,252,0.28), rgba(46,230,246,0.16))',
border: '1px solid rgba(160,168,255,0.32)',
}} />
<AlarmClock size={36} color="#a5b0ff" style={{ position: 'relative' }} />
</div>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>Нет активных таймеров</div>
<div style={{
fontSize: 12, color: 'var(--text-secondary)', textAlign: 'center', lineHeight: 1.7,
padding: '10px 16px', borderRadius: 12,
background: 'rgba(255,255,255,0.04)', border: '1px solid var(--border-subtle)',
}}>
Скажи «Эй, космо, поставь таймер на 5 минут»<br />или нажми <span style={{ color: 'var(--accent)', fontWeight: 800 }}>+</span>
</div>
</button>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<AnimatePresence mode="popLayout">
{active.map(t => {
const end = new Date(t.endsAt).getTime()
const start = new Date(t.startedAt).getTime()
const total = Math.max(1, end - start)
const remain = Math.max(0, end - now)
const progress = Math.max(0, Math.min(1, 1 - remain / total))
const imminent = remain > 0 && remain < 10_000
const expired = remain <= 0
const time = formatRemaining(remain)
const accent = expired ? '#f87171' : imminent ? '#fb923c' : '#818cf8'
const accentBg = expired
? 'linear-gradient(135deg, rgba(239,68,68,0.14), rgba(239,68,68,0.06))'
: imminent
? 'linear-gradient(135deg, rgba(251,146,60,0.14), rgba(251,146,60,0.06))'
: 'var(--surface-2)'
return (
<motion.button
key={t.id}
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 40 }}
transition={{ duration: 0.25 }}
onClick={() => setModal({ type: 'control', timer: t })}
style={{
position: 'relative', overflow: 'hidden',
padding: '16px 18px', borderRadius: 16,
background: accentBg,
border: `1px solid ${expired || imminent ? accent + '55' : 'var(--border-subtle)'}`,
display: 'flex', alignItems: 'center', gap: 14,
cursor: 'pointer', textAlign: 'left',
minHeight: 68,
width: '100%',
}}
>
<div style={{
position: 'absolute', bottom: 0, left: 0,
height: 3, width: `${progress * 100}%`,
background: accent,
transition: 'width 0.5s linear',
opacity: expired ? 0.5 : 1,
}} />
{/* Big countdown */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 2, minWidth: 108 }}>
<div style={{
fontSize: 34, fontWeight: 800,
color: expired ? accent : 'var(--text-primary)',
letterSpacing: '-1.5px', lineHeight: 1,
fontVariantNumeric: 'tabular-nums',
}}>
{time.big}
</div>
{time.small && (
<div style={{
fontSize: 16, color: 'var(--text-secondary)',
fontWeight: 600, fontVariantNumeric: 'tabular-nums',
}}>
:{time.small}
</div>
)}
</div>
{/* Label */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14, fontWeight: 700,
color: 'var(--text-primary)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{t.label}
</div>
<div style={{
fontSize: 11, color: 'var(--text-tertiary)',
marginTop: 2,
}}>
{expired ? 'прозвенел — тап чтобы закрыть' : 'тап чтобы изменить'}
</div>
</div>
</motion.button>
)
})}
</AnimatePresence>
</div>
)}
</div>
<TimerModal mode={resolvedMode} onClose={() => setModal(null)} />
</>
)
}