Files
smart-home-tablet/components/TransportWidget.tsx
Cosmo 9ad758174d
All checks were successful
Deploy / deploy (push) Successful in 2m47s
style(home): drop weather-hint block; recolor trams 23 green, 27 blue, 39 red
Weather hint (оденьтесь потеплее / не забудьте зонт) was pushing the
home screen past one viewport on the tablet — removed the block and its
helper fn. New tram color palette per user preference.
2026-04-23 08:21:09 +00:00

240 lines
8.4 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 { 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: 'от центра' },
]
const ROUTES: { num: string; color: string; bg: string }[] = [
{ num: '23', color: '#34d399', bg: 'linear-gradient(135deg, #10b981, #059669)' },
{ num: '27', color: '#60a5fa', bg: 'linear-gradient(135deg, #3b82f6, #2563eb)' },
{ num: '39', color: '#f87171', bg: 'linear-gradient(135deg, #ef4444, #dc2626)' },
]
function formatMinutes(m: number): string {
if (m <= 0) return 'сейчас'
return `${m} мин`
}
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: 'rgba(255,255,255,0.02)',
border: '1px dashed rgba(255,255,255,0.06)',
color: 'var(--text-tertiary)', fontSize: 13, fontWeight: 500,
minHeight: 52,
}}></div>
)
}
const [first, ...rest] = sorted
const imminent = first.minutes <= 2
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 14px', borderRadius: 12,
background: imminent ? `${color}15` : 'rgba(255,255,255,0.03)',
border: `1px solid ${imminent ? color + '35' : 'rgba(255,255,255,0.06)'}`,
minHeight: 52,
transition: 'all 0.25s ease',
}}>
{/* Primary time */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, flexShrink: 0 }}>
<div style={{
fontSize: first.minutes <= 0 ? 16 : 24,
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: 11, color: 'var(--text-secondary)', fontWeight: 500 }}>мин</div>
)}
</div>
{/* Divider */}
{rest.length > 0 && (
<div style={{ width: 1, alignSelf: 'stretch', background: 'rgba(255,255,255,0.06)' }} />
)}
{/* Next arrivals */}
{rest.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 10, color: 'var(--text-tertiary)', fontWeight: 600,
textTransform: 'uppercase', letterSpacing: '0.06em',
}}>
затем
</div>
<div style={{
fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
}}>
{rest.map(r => formatMinutes(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 style={{
background: 'linear-gradient(135deg, rgba(99,102,241,0.08), rgba(236,72,153,0.04))',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)' as any,
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 20, padding: '16px 18px',
display: 'flex', flexDirection: 'column', gap: 14,
position: 'relative', overflow: 'hidden',
}}>
{/* Glow */}
<div style={{
position: 'absolute', top: -60, left: '50%', transform: 'translateX(-50%)',
width: 280, height: 120, borderRadius: '50%',
background: 'radial-gradient(ellipse, rgba(99,102,241,0.25) 0%, transparent 60%)',
opacity: 0.35, pointerEvents: 'none',
}} />
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, position: 'relative', zIndex: 1 }}>
<div style={{
width: 32, height: 32, borderRadius: 10,
background: 'rgba(255,255,255,0.06)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-primary)', flexShrink: 0,
}}>
<Train size={16} />
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.2px' }}>
Трамвай
</div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
Ул. Антонова-Овсеенко
</div>
</div>
</div>
{/* Column headers */}
<div style={{
display: 'grid',
gridTemplateColumns: '58px 1fr 1fr',
gap: 10,
position: 'relative', zIndex: 1,
}}>
<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 }}>
{d.sub}
</div>
</div>
</div>
))}
</div>
{/* Rows: one per route */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, position: 'relative', zIndex: 1 }}>
{ROUTES.map(route => (
<div key={route.num} style={{
display: 'grid',
gridTemplateColumns: '58px 1fr 1fr',
gap: 10,
alignItems: 'stretch',
}}>
{/* Route badge */}
<div style={{
background: route.bg,
boxShadow: `0 6px 16px ${route.color}35`,
borderRadius: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: 800, fontSize: 20,
letterSpacing: '-1px',
minHeight: 52,
}}>
{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.2 }}
>
<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', position: 'relative', zIndex: 1 }}>
Загрузка расписания...
</div>
)}
</div>
)
}