feat(home): tram arrival widget for Ул. Антонова-Овсеенко
All checks were successful
Deploy / deploy (push) Successful in 3m10s
All checks were successful
Deploy / deploy (push) Successful in 3m10s
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.
This commit is contained in:
67
app/api/transport/route.ts
Normal file
67
app/api/transport/route.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const ORGP_BASE = 'https://transport.orgp.spb.ru'
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const stopId = searchParams.get('stopId')
|
||||||
|
if (!stopId || !/^\d+$/.test(stopId)) {
|
||||||
|
return NextResponse.json({ error: 'stopId required (digits)' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
sEcho: '1',
|
||||||
|
iColumns: '5',
|
||||||
|
sColumns: 'index,routeNumber,timeToArrive,parkNumber,wheelchair',
|
||||||
|
iDisplayStart: '0',
|
||||||
|
iDisplayLength: '-1',
|
||||||
|
sNames: 'index,routeNumber,timeToArrive,parkNumber,wheelchair',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(
|
||||||
|
`${ORGP_BASE}/Portal/transport/stop/${encodeURIComponent(stopId)}/arriving`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||||
|
},
|
||||||
|
body: body.toString(),
|
||||||
|
cache: 'no-store',
|
||||||
|
// @ts-ignore next-internal option for timeout
|
||||||
|
next: { revalidate: 0 },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!upstream.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `upstream_${upstream.status}`, arrivals: [] },
|
||||||
|
{ status: 502 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await upstream.json()
|
||||||
|
const arrivals = Array.isArray(data?.aaData)
|
||||||
|
? data.aaData.map((row: any[]) => ({
|
||||||
|
route: String(row[1] ?? ''),
|
||||||
|
minutes: Number(row[2] ?? 0),
|
||||||
|
park: String(row[3] ?? ''),
|
||||||
|
wheelchair: Boolean(row[4]),
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
stopId,
|
||||||
|
arrivals,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e?.message || 'fetch_failed', arrivals: [] },
|
||||||
|
{ status: 502 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import RoomTabs from '@/components/RoomTabs'
|
|||||||
import DeviceCard from '@/components/DeviceCard'
|
import DeviceCard from '@/components/DeviceCard'
|
||||||
import CalendarTab from '@/components/CalendarTab'
|
import CalendarTab from '@/components/CalendarTab'
|
||||||
import NotesTab from '@/components/NotesTab'
|
import NotesTab from '@/components/NotesTab'
|
||||||
|
import TransportWidget from '@/components/TransportWidget'
|
||||||
import WeatherAnimation from '@/components/WeatherAnimation'
|
import WeatherAnimation from '@/components/WeatherAnimation'
|
||||||
|
|
||||||
type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
|
type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
|
||||||
@@ -502,6 +503,9 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Transport: tram arrivals at Ул. Антонова-Овсеенко, both directions */}
|
||||||
|
<TransportWidget />
|
||||||
|
|
||||||
{/* Two columns: Events + Notes */}
|
{/* Two columns: Events + Notes */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, flex: 1, minHeight: 0 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, flex: 1, minHeight: 0 }}>
|
||||||
|
|
||||||
|
|||||||
227
components/TransportWidget.tsx
Normal file
227
components/TransportWidget.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user