All checks were successful
Deploy / deploy (push) Successful in 3m8s
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>
231 lines
8.5 KiB
TypeScript
231 lines
8.5 KiB
TypeScript
'use client'
|
||
import { useEffect, useState } from 'react'
|
||
import { motion, AnimatePresence } from 'framer-motion'
|
||
import { ArrowRight, Train } from 'lucide-react'
|
||
|
||
interface Arrival {
|
||
route: string
|
||
minutes: number
|
||
park: string
|
||
wheelchair: boolean
|
||
}
|
||
|
||
interface Direction {
|
||
stopId: string
|
||
short: string
|
||
sub: string
|
||
}
|
||
|
||
const DIRECTIONS: Direction[] = [
|
||
{ stopId: '16226', short: 'Лента', sub: 'в центр' },
|
||
{ stopId: '16354', short: 'Дыбенко', sub: 'от центра' },
|
||
]
|
||
|
||
// Маршрут-специфичные цвета — соответствуют реальной окраске маршрутов
|
||
// (но в правильной семантике: good=23, info=27, danger=39)
|
||
const ROUTES: { num: string; color: string; bg: string }[] = [
|
||
{ num: '23', color: 'var(--data-good)', bg: 'linear-gradient(135deg, var(--data-good), color-mix(in srgb, var(--data-good) 75%, black))' },
|
||
{ num: '27', color: 'var(--data-info)', bg: 'linear-gradient(135deg, var(--data-info), color-mix(in srgb, var(--data-info) 75%, black))' },
|
||
{ num: '39', color: 'var(--data-danger)', bg: 'linear-gradient(135deg, var(--data-danger), color-mix(in srgb, var(--data-danger) 75%, black))' },
|
||
]
|
||
|
||
function Cell({ arrivals, color }: { arrivals: Arrival[]; color: string }) {
|
||
const sorted = [...arrivals].sort((a, b) => a.minutes - b.minutes).slice(0, 3)
|
||
if (sorted.length === 0) {
|
||
return (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
padding: '10px 14px', borderRadius: 12,
|
||
background: 'var(--surface-2)',
|
||
border: '1px dashed var(--border-subtle)',
|
||
color: 'var(--text-tertiary)', fontSize: 13, fontWeight: 500,
|
||
minHeight: 56,
|
||
}}>—</div>
|
||
)
|
||
}
|
||
const [first, ...rest] = sorted
|
||
const imminent = first.minutes <= 2
|
||
return (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 10,
|
||
padding: '10px 12px', borderRadius: 12,
|
||
background: imminent ? `color-mix(in srgb, ${color} 10%, var(--surface-2))` : 'var(--surface-2)',
|
||
border: `1px solid ${imminent ? color : 'var(--border-subtle)'}`,
|
||
minHeight: 56, minWidth: 0, overflow: 'hidden',
|
||
transition: 'all 0.3s ease',
|
||
}}>
|
||
{/* Primary time — big */}
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 3, flexShrink: 0 }}>
|
||
<div style={{
|
||
fontSize: first.minutes <= 0 ? 16 : 28,
|
||
fontWeight: 800, letterSpacing: '-1px', lineHeight: 1,
|
||
color: imminent ? color : 'var(--text-primary)',
|
||
fontVariantNumeric: 'tabular-nums',
|
||
}}>
|
||
{first.minutes <= 0 ? 'сейчас' : first.minutes}
|
||
</div>
|
||
{first.minutes > 0 && (
|
||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600 }}>мин</div>
|
||
)}
|
||
</div>
|
||
|
||
{rest.length > 0 && (
|
||
<>
|
||
<div style={{ width: 1, alignSelf: 'stretch', background: 'var(--hairline)', margin: '4px 0' }} />
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
fontSize: 9, color: 'var(--text-tertiary)', fontWeight: 700,
|
||
textTransform: 'uppercase', letterSpacing: '0.08em',
|
||
}}>
|
||
затем
|
||
</div>
|
||
<div style={{
|
||
fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600,
|
||
fontVariantNumeric: 'tabular-nums',
|
||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||
}}>
|
||
{rest.map(r => r.minutes <= 0 ? 'сейчас' : `${r.minutes}м`).join(' · ')}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function TransportWidget() {
|
||
const [data, setData] = useState<Record<string, Arrival[]>>({})
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
const load = async () => {
|
||
try {
|
||
const results = await Promise.all(
|
||
DIRECTIONS.map(d =>
|
||
fetch(`/api/transport?stopId=${d.stopId}`)
|
||
.then(r => r.json())
|
||
.then(j => ({ stopId: d.stopId, arrivals: (j.arrivals as Arrival[]) || [] }))
|
||
.catch(() => ({ stopId: d.stopId, arrivals: [] as Arrival[] }))
|
||
)
|
||
)
|
||
if (cancelled) return
|
||
const map: Record<string, Arrival[]> = {}
|
||
for (const r of results) map[r.stopId] = r.arrivals
|
||
setData(map)
|
||
} finally {
|
||
if (!cancelled) setLoading(false)
|
||
}
|
||
}
|
||
load()
|
||
const t = setInterval(load, 30_000)
|
||
return () => { cancelled = true; clearInterval(t) }
|
||
}, [])
|
||
|
||
return (
|
||
<div className="card-hero" style={{
|
||
padding: '18px 20px',
|
||
display: 'flex', flexDirection: 'column', gap: 14,
|
||
minWidth: 0,
|
||
}}>
|
||
{/* Header */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<div style={{
|
||
width: 36, height: 36, borderRadius: 11,
|
||
background: 'var(--surface-2)',
|
||
border: '1px solid var(--border-subtle)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
color: 'var(--text-secondary)', flexShrink: 0,
|
||
}}>
|
||
<Train size={17} />
|
||
</div>
|
||
<div style={{ minWidth: 0, flex: 1 }}>
|
||
<div style={{
|
||
fontSize: 10, color: 'var(--text-tertiary)', fontWeight: 700,
|
||
textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 2,
|
||
}}>
|
||
Трамвай
|
||
</div>
|
||
<div style={{
|
||
fontSize: 14, fontWeight: 700, color: 'var(--text-primary)',
|
||
letterSpacing: '-0.2px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||
}}>
|
||
Ул. Антонова-Овсеенко
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Column headers */}
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '52px minmax(0, 1fr) minmax(0, 1fr)',
|
||
gap: 10,
|
||
paddingBottom: 6,
|
||
borderBottom: '1px solid var(--hairline)',
|
||
}}>
|
||
<div />
|
||
{DIRECTIONS.map(d => (
|
||
<div key={d.stopId} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 4px' }}>
|
||
<ArrowRight size={12} color="var(--text-tertiary)" style={{ flexShrink: 0 }} />
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{d.short}
|
||
</div>
|
||
<div style={{ fontSize: 10, color: 'var(--text-tertiary)', marginTop: 1, fontWeight: 500 }}>
|
||
{d.sub}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Rows */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1 }}>
|
||
{ROUTES.map(route => (
|
||
<div key={route.num} style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '52px minmax(0, 1fr) minmax(0, 1fr)',
|
||
gap: 10,
|
||
alignItems: 'stretch',
|
||
minWidth: 0,
|
||
}}>
|
||
<div style={{
|
||
background: route.bg,
|
||
boxShadow: `0 6px 16px -4px color-mix(in srgb, ${route.color} 35%, transparent)`,
|
||
borderRadius: 12,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
color: 'white', fontWeight: 800, fontSize: 20,
|
||
letterSpacing: '-1px',
|
||
minHeight: 56,
|
||
}}>
|
||
{route.num}
|
||
</div>
|
||
|
||
{DIRECTIONS.map(d => {
|
||
const arrivals = (data[d.stopId] || []).filter(a => a.route === route.num)
|
||
return (
|
||
<AnimatePresence key={d.stopId} mode="wait">
|
||
<motion.div
|
||
key={`${route.num}-${d.stopId}-${arrivals.map(a => a.minutes).join(',')}`}
|
||
initial={{ opacity: 0, y: 4 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.25 }}
|
||
>
|
||
<Cell arrivals={arrivals} color={route.color} />
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
)
|
||
})}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{loading && Object.keys(data).length === 0 && (
|
||
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', textAlign: 'center', marginTop: 'auto' }}>
|
||
Загрузка расписания...
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|