Files
smart-home-tablet/components/TransportWidget.tsx
Cosmo 0523482aa1
All checks were successful
Deploy / deploy (push) Successful in 3m10s
feat(home): tram arrival widget for Ул. Антонова-Овсеенко
Adds a live transit widget on the home screen showing upcoming trams
at both directions of the stop: toward Новочеркасская (stopID 16226)
and toward пр. Большевиков (stopID 16354).

- /api/transport proxies the СПб ORGP endpoint /stop/{id}/arriving
  (DataTables POST format, JSON response with route number + minutes).
  No auth required, free.
- TransportWidget renders two glassmorphism cards with route badges,
  minutes-to-arrival, wheelchair indicator; imminent (<=2 min) arrivals
  get a colored highlight. Filters to trams 23/27/39; refreshes every 30s.
- Route colors: 23 blue, 27 amber, 39 purple.
2026-04-23 08:05:15 +00:00

228 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, ArrowLeft, Accessibility, Train } from 'lucide-react'
interface Arrival {
route: string
minutes: number
park: string
wheelchair: boolean
}
interface Direction {
stopId: string
title: string
subtitle: string
icon: 'right' | 'left'
accent: string
glow: string
}
const DIRECTIONS: Direction[] = [
{
stopId: '16226',
title: 'В центр',
subtitle: 'к м. Новочеркасская',
icon: 'right',
accent: 'linear-gradient(135deg, rgba(16,185,129,0.14), rgba(6,182,212,0.06))',
glow: 'rgba(16,185,129,0.35)',
},
{
stopId: '16354',
title: 'Из центра',
subtitle: 'к пр. Большевиков',
icon: 'left',
accent: 'linear-gradient(135deg, rgba(236,72,153,0.14), rgba(245,158,11,0.06))',
glow: 'rgba(236,72,153,0.35)',
},
]
const ALLOWED_ROUTES = new Set(['23', '27', '39'])
const ROUTE_STYLE: Record<string, { color: string; bg: string }> = {
'23': { color: '#60a5fa', bg: 'linear-gradient(135deg, #3b82f6, #2563eb)' },
'27': { color: '#fbbf24', bg: 'linear-gradient(135deg, #f59e0b, #d97706)' },
'39': { color: '#c084fc', bg: 'linear-gradient(135deg, #a855f7, #7c3aed)' },
}
function formatMinutes(m: number): { big: string; unit: string } {
if (m <= 0) return { big: 'сейчас', unit: '' }
if (m === 1) return { big: '1', unit: 'мин' }
return { big: String(m), unit: 'мин' }
}
function DirectionCard({ dir, arrivals, loading }: { dir: Direction; arrivals: Arrival[]; loading: boolean }) {
const sorted = [...arrivals].sort((a, b) => a.minutes - b.minutes).slice(0, 3)
const ArrowIcon = dir.icon === 'right' ? ArrowRight : ArrowLeft
return (
<div style={{
background: dir.accent,
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: 12,
position: 'relative', overflow: 'hidden',
minHeight: 180,
}}>
{/* Glow */}
<div style={{
position: 'absolute', top: -40, right: -40,
width: 140, height: 140, borderRadius: '50%',
background: `radial-gradient(circle, ${dir.glow} 0%, transparent 70%)`,
opacity: 0.5, pointerEvents: 'none',
}} />
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, position: 'relative', zIndex: 1 }}>
<div style={{
width: 34, height: 34, borderRadius: 11,
background: 'rgba(255,255,255,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-primary)', flexShrink: 0,
}}>
<ArrowIcon size={16} />
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.2px' }}>
{dir.title}
</div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{dir.subtitle}
</div>
</div>
</div>
{/* Arrivals */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1, position: 'relative', zIndex: 1 }}>
{loading && arrivals.length === 0 ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, color: 'var(--text-tertiary)', fontSize: 12 }}>
Загрузка...
</div>
) : sorted.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', flex: 1, color: 'var(--text-tertiary)', gap: 6 }}>
<Train size={22} style={{ opacity: 0.4 }} />
<div style={{ fontSize: 12 }}>Пока нет данных</div>
</div>
) : (
<AnimatePresence mode="popLayout">
{sorted.map((arr, idx) => {
const style = ROUTE_STYLE[arr.route] || { color: '#9ca3af', bg: 'linear-gradient(135deg, #6b7280, #4b5563)' }
const time = formatMinutes(arr.minutes)
const isImminent = arr.minutes <= 2
return (
<motion.div
key={`${arr.route}-${arr.park}-${idx}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.25, delay: idx * 0.04 }}
style={{
display: 'flex', alignItems: 'center', gap: 11,
padding: '8px 10px', borderRadius: 13,
background: isImminent ? `${style.color}15` : 'rgba(255,255,255,0.025)',
border: `1px solid ${isImminent ? style.color + '35' : 'rgba(255,255,255,0.05)'}`,
}}
>
{/* Route badge */}
<div style={{
width: 34, height: 34, borderRadius: 10,
background: style.bg,
boxShadow: `0 4px 12px ${style.color}30`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: 800, fontSize: 14,
flexShrink: 0, letterSpacing: '-0.5px',
}}>
{arr.route}
</div>
{/* Label */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', fontWeight: 500, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
Трамвай
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 1 }}>
{arr.park}
</div>
</div>
{/* Time */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, flexShrink: 0 }}>
<div style={{
fontSize: time.big === 'сейчас' ? 15 : 22,
fontWeight: 800,
color: isImminent ? style.color : 'var(--text-primary)',
lineHeight: 1, letterSpacing: '-1px',
fontVariantNumeric: 'tabular-nums',
}}>
{time.big}
</div>
{time.unit && (
<div style={{ fontSize: 11, color: 'var(--text-secondary)', fontWeight: 500 }}>
{time.unit}
</div>
)}
</div>
{/* Wheelchair */}
{arr.wheelchair && (
<Accessibility size={12} color="var(--text-tertiary)" style={{ flexShrink: 0 }} />
)}
</motion.div>
)
})}
</AnimatePresence>
)}
</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.filter(a => ALLOWED_ROUTES.has(a.route))
}
setData(map)
} finally {
if (!cancelled) setLoading(false)
}
}
load()
const t = setInterval(load, 30_000)
return () => { cancelled = true; clearInterval(t) }
}, [])
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
{DIRECTIONS.map(d => (
<DirectionCard
key={d.stopId}
dir={d}
arrivals={data[d.stopId] || []}
loading={loading}
/>
))}
</div>
)
}