Files
smart-home-tablet/components/TransportWidget.tsx
Cosmo 0908ad93de
All checks were successful
Deploy / deploy (push) Successful in 2m40s
fix(home): prevent bento grid overflow on narrow viewports
CSS grid items default min-width to min-content; the tram widget 3-col
subgrid plus its баbadges and long затем text forced its cell wider
than 1.1fr, collapsing the outer layout on tablet. Fixes:

- outer bento row → gridTemplateColumns: minmax(0, 1fr) minmax(0, 1.1fr)
- events+notes row same treatment
- TransportWidget inner subgrids: 58px → 52px badge, 1fr → minmax(0, 1fr)
- Cell: minWidth: 0, overflow: hidden, затем text trimmed with ellipsis
  and short м suffix (5м instead of 5 мин)
- big number 32→28px, badge 22→20px to fit in denser columns
2026-04-23 08:46:00 +00:00

229 lines
8.1 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: '#10b981', bg: 'linear-gradient(135deg, #10b981, #059669)' },
{ num: '27', color: '#3b82f6', bg: 'linear-gradient(135deg, #3b82f6, #2563eb)' },
{ num: '39', color: '#ef4444', bg: 'linear-gradient(135deg, #ef4444, #dc2626)' },
]
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 ${route.color}55`,
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>
)
}