Files
smart-home-tablet/components/TimerModal.tsx
Cosmo e328055851
All checks were successful
Deploy / deploy (push) Successful in 3m8s
feat(design): FocusCard hero, CountdownCard, data-* palette, swipe, touch-targets
Big design pass across Home + tokens + components.

— globals.css: new data-* palette (cool/warm/hot/good/info/rose/violet/mood)
  with theme-aware variants, .grain overlay utility, .num-display
  typography helper, .hit-zone 44px wrapper, .eyebrow label, .focus-card
  base, focus-visible outline-offset 3px, space/touch scale vars.
— FocusCard.tsx: context engine — пять состояний (morning-outfit,
  tram-imminent, event-upcoming, countdown, bill-due, night, quiet).
  Auto-rotates by hour + live data. 96px display numbers, accent-mixed
  surfaces, grain overlay.
— CountdownCard.tsx + /api/countdowns: rotating 8s list, persistent
  /data/tablet-countdowns.json, full CRUD. Default seeded with Токио.
— HomeTab: replaced plain Weather hero with FocusCard, added Row 4
  with CountdownCard. Pulls trams + countdowns for the Focus context.
— Swipe between tabs: pointer-level detection on <main>, data-swipe-ignore
  bails out inside modals + note swipe-to-delete + voice overlay.
— Touch-target sweep: TopBar HA dot → 44px hit-zone, sensor chip 44px
  min-height, forecast day buttons 92px min, DeviceCard toggle 60x36,
  CalendarTab prev/next/close/list all 44x44, NotesTab buttons 44x44,
  TimerHomeWidget + 44x44, WeatherDayModal chevrons 48x48, close 48.
— Hardcoded hex → data-* tokens: TopBar sensors, TransportWidget routes
  (via color-mix), DeviceCard full rewrite (per-kind accent, glass
  removed in favor of color-mix surfaces + proper mock-state treatment),
  NotesTab palette refreshed to match dark theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:24:23 +00:00

399 lines
13 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 { X, AlarmClock, Trash2 } from 'lucide-react'
interface Timer {
id: string
label: string
startedAt: string
endsAt: string
}
type Mode =
| { type: 'control'; timer: Timer }
| { type: 'create' }
export interface TimerModalProps {
mode: Mode | null
onClose: () => void
}
function formatBig(ms: number): string {
if (ms <= 0) return '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 `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
return `${m}:${s.toString().padStart(2, '0')}`
}
async function postTimer(body: any) {
return fetch('/api/voice/timer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
credentials: 'include',
})
}
const ADJUST_OPTIONS = [
{ label: '5 мин', delta: -300 },
{ label: '1 мин', delta: -60 },
{ label: '10 сек', delta: -10 },
{ label: '+10 сек', delta: 10 },
{ label: '+1 мин', delta: 60 },
{ label: '+5 мин', delta: 300 },
]
const PRESETS = [
{ label: '1 мин', seconds: 60 },
{ label: '3 мин', seconds: 180 },
{ label: '5 мин', seconds: 300 },
{ label: '10 мин', seconds: 600 },
{ label: '15 мин', seconds: 900 },
{ label: '30 мин', seconds: 1800 },
{ label: '1 час', seconds: 3600 },
]
const DEFAULT_LABELS = ['Таймер', 'Чайник', 'Паста', 'Яйца', 'Разогрев', 'Стирка', 'Напомни']
export default function TimerModal({ mode, onClose }: TimerModalProps) {
return (
<AnimatePresence>
{mode && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
data-swipe-ignore
style={{
position: 'fixed', inset: 0, zIndex: 250,
background: 'rgba(5, 5, 15, 0.72)',
backdropFilter: 'blur(18px)',
WebkitBackdropFilter: 'blur(18px)' as any,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.94, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 4 }}
transition={{ type: 'spring', stiffness: 360, damping: 30 }}
onClick={e => e.stopPropagation()}
style={{
width: '100%', maxWidth: 520,
background: 'var(--surface-1)',
border: '1px solid var(--border-subtle)',
borderRadius: 28,
boxShadow: 'var(--shadow-xl)',
padding: 28,
display: 'flex', flexDirection: 'column', gap: 22,
position: 'relative',
}}
>
<button
onClick={onClose}
aria-label="Закрыть"
style={{
position: 'absolute', top: 14, right: 14,
width: 38, height: 38, borderRadius: 12,
background: 'var(--surface-2)',
border: '1px solid var(--border-subtle)',
color: 'var(--text-secondary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
}}
>
<X size={17} />
</button>
{mode.type === 'control' ? (
<ControlView timer={mode.timer} onClose={onClose} />
) : (
<CreateView onClose={onClose} />
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
function ControlView({ timer, onClose }: { timer: Timer; onClose: () => void }) {
const [tick, setTick] = useState(0)
useEffect(() => {
const t = setInterval(() => setTick(x => x + 1), 500)
return () => clearInterval(t)
}, [])
// tick is just to force re-render
const now = Date.now()
const end = new Date(timer.endsAt).getTime()
const start = new Date(timer.startedAt).getTime()
const remain = Math.max(0, end - now)
const total = Math.max(1, end - start)
const progress = Math.max(0, Math.min(1, 1 - remain / total))
const expired = remain <= 0
const imminent = !expired && remain < 10_000
const accent = expired ? '#f87171' : imminent ? '#fb923c' : '#818cf8'
const handleAdjust = (delta: number) => {
postTimer({ action: 'adjust', id: timer.id, delta_seconds: delta })
}
const handleCancel = async () => {
await postTimer({ action: 'cancel', id: timer.id })
onClose()
}
return (
<>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 40, height: 40, borderRadius: 13,
background: `color-mix(in srgb, ${accent} 15%, var(--surface-2))`,
border: `1px solid ${accent}44`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: accent, flexShrink: 0,
}}>
<AlarmClock size={18} />
</div>
<div style={{ minWidth: 0 }}>
<div style={{
fontSize: 10, color: 'var(--text-tertiary)', fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.12em',
}}>
{expired ? 'Прозвенел' : 'Таймер'}
</div>
<div style={{
fontSize: 17, fontWeight: 700, color: 'var(--text-primary)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{timer.label}
</div>
</div>
</div>
{/* Big countdown */}
<div style={{
textAlign: 'center',
padding: '8px 0',
}}>
<div style={{
fontSize: 84, fontWeight: 800,
color: expired ? accent : 'var(--text-primary)',
letterSpacing: '-4px', lineHeight: 1,
fontVariantNumeric: 'tabular-nums',
}}>
{formatBig(remain)}
</div>
{/* Progress track */}
<div style={{
marginTop: 16, height: 4, borderRadius: 2,
background: 'var(--surface-2)', overflow: 'hidden',
}}>
<div style={{
height: '100%', width: `${progress * 100}%`,
background: accent,
borderRadius: 2,
transition: 'width 0.5s linear',
}} />
</div>
</div>
{/* Adjust grid 3x2 */}
{!expired && (
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10,
}}>
{ADJUST_OPTIONS.map((opt) => {
const negative = opt.delta < 0
return (
<button
key={opt.label}
onClick={() => handleAdjust(opt.delta)}
style={{
padding: '16px 8px', borderRadius: 16,
background: 'var(--surface-2)',
border: '1px solid var(--border-subtle)',
color: negative ? 'var(--text-secondary)' : 'var(--text-primary)',
fontSize: 15, fontWeight: 700,
cursor: 'pointer',
minHeight: 56,
transition: 'background 0.15s ease, transform 0.1s ease',
}}
onMouseDown={e => (e.currentTarget.style.transform = 'scale(0.97)')}
onMouseUp={e => (e.currentTarget.style.transform = '')}
onMouseLeave={e => (e.currentTarget.style.transform = '')}
>
{opt.label}
</button>
)
})}
</div>
)}
{/* Cancel — destructive */}
<button
onClick={handleCancel}
style={{
padding: '16px', borderRadius: 16,
background: expired
? 'linear-gradient(135deg, rgba(239,68,68,0.22), rgba(239,68,68,0.12))'
: 'rgba(239,68,68,0.08)',
border: `1px solid rgba(239,68,68,${expired ? 0.4 : 0.2})`,
color: expired ? '#fca5a5' : '#f87171',
fontSize: 15, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
cursor: 'pointer',
minHeight: 56,
}}
>
<Trash2 size={16} /> {expired ? 'Остановить' : 'Удалить таймер'}
</button>
</>
)
}
function CreateView({ onClose }: { onClose: () => void }) {
const [seconds, setSeconds] = useState(300) // 5 min default
const [label, setLabel] = useState('Таймер')
const [submitting, setSubmitting] = useState(false)
const submit = async () => {
if (submitting) return
setSubmitting(true)
try {
await postTimer({
action: 'start',
seconds,
label: (label.trim() || 'Таймер').slice(0, 80),
agent: 'cosmo',
})
onClose()
} finally {
setSubmitting(false)
}
}
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 40, height: 40, borderRadius: 13,
background: 'color-mix(in srgb, #818cf8 15%, var(--surface-2))',
border: '1px solid #818cf844',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#818cf8', flexShrink: 0,
}}>
<AlarmClock size={18} />
</div>
<div>
<div style={{
fontSize: 10, color: 'var(--text-tertiary)', fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.12em',
}}>
Новый таймер
</div>
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--text-primary)' }}>
Выбери длительность
</div>
</div>
</div>
{/* Preset grid */}
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8,
}}>
{PRESETS.map(p => {
const active = seconds === p.seconds
return (
<button
key={p.seconds}
onClick={() => setSeconds(p.seconds)}
style={{
padding: '14px 4px', borderRadius: 14,
background: active ? 'color-mix(in srgb, #818cf8 18%, var(--surface-2))' : 'var(--surface-2)',
border: `1px solid ${active ? '#818cf866' : 'var(--border-subtle)'}`,
color: active ? '#a5b4fc' : 'var(--text-primary)',
fontSize: 14, fontWeight: 700,
cursor: 'pointer',
minHeight: 52,
}}
>
{p.label}
</button>
)
})}
</div>
{/* Label selector */}
<div>
<div style={{
fontSize: 10, color: 'var(--text-tertiary)', fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: 8,
}}>
Название
</div>
<input
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="Таймер"
maxLength={80}
style={{
width: '100%', padding: '14px 16px', borderRadius: 14,
background: 'var(--surface-2)',
border: '1px solid var(--border-subtle)',
color: 'var(--text-primary)',
fontSize: 15, fontFamily: 'inherit', outline: 'none',
boxSizing: 'border-box' as any,
}}
/>
<div style={{
display: 'flex', gap: 6, marginTop: 8, flexWrap: 'wrap',
}}>
{DEFAULT_LABELS.map(l => (
<button
key={l}
onClick={() => setLabel(l)}
style={{
padding: '6px 12px', borderRadius: 10,
background: label === l ? 'color-mix(in srgb, #818cf8 14%, var(--surface-2))' : 'var(--surface-2)',
border: '1px solid var(--border-subtle)',
color: label === l ? '#a5b4fc' : 'var(--text-secondary)',
fontSize: 12, fontWeight: 600,
cursor: 'pointer',
}}
>
{l}
</button>
))}
</div>
</div>
{/* Start button */}
<button
onClick={submit}
disabled={submitting || seconds < 1}
style={{
padding: '18px', borderRadius: 16,
background: 'linear-gradient(135deg, rgba(99,102,241,0.35), rgba(139,92,246,0.28))',
border: '1px solid rgba(129,140,248,0.45)',
color: '#e0e7ff', fontSize: 16, fontWeight: 800,
cursor: submitting ? 'wait' : 'pointer',
minHeight: 58, letterSpacing: '-0.2px',
boxShadow: '0 8px 24px -8px rgba(99,102,241,0.45)',
}}
>
{submitting ? 'Запускаю...' : `Запустить таймер на ${PRESETS.find(p => p.seconds === seconds)?.label || seconds + ' сек'}`}
</button>
</>
)
}