Some checks failed
Deploy / deploy (push) Has been cancelled
- таймеры: светящаяся градиентная иконка + голосовая подсказка вместо серого текста - Spotify: зелёный музыкальный орб + подсказка - прогноз: ячейка «сейчас» — яркий градиентный акцент с тенью
268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
'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)} />
|
||
</>
|
||
)
|
||
}
|