Files
smart-home-tablet/components/TransportWidget.tsx
Cosmo 95352356b7
Some checks failed
Deploy / deploy (push) Has been cancelled
refactor(transport): group by route with two direction columns
Restructures the tram widget: instead of one card per stop (showing all
routes at that stop), now one row per route (23, 27, 39) with two
columns — → Лента (в центр) and → Дыбенко (от центра). Each cell shows
the next arrival prominently plus the following 1-2 pickups inline.
2026-04-23 08:09:44 +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: '#60a5fa', bg: 'linear-gradient(135deg, #3b82f6, #2563eb)' },
{ num: '27', color: '#fbbf24', bg: 'linear-gradient(135deg, #f59e0b, #d97706)' },
{ num: '39', color: '#c084fc', bg: 'linear-gradient(135deg, #a855f7, #7c3aed)' },
]
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>
)
}