Files
smart-home-tablet/components/TransportWidget.tsx
Cosmo 121bf30ab1
All checks were successful
Deploy / deploy (push) Successful in 2m43s
redesign: bento home + semantic tokens + solid cards
- introduces semantic CSS tokens (--surface-1/2/3, --border-subtle/strong,
  --hairline, --shadow-sm/md/lg/xl) with distinct dark and light values;
  fixes broken light theme caused by hardcoded rgba(255,255,255,X)
- drops glassmorphism on cards — solid var(--surface-1) with 1px border
  and layered shadows; glass kept only for aurora page background
- introduces .card/.card-raised/.card-hero utility classes
- Home page restructured into a bento grid:
  * greeting row with inline day/date
  * hero weather (64px number, large icon, ощущается/влажность/ветер)
    next to the tram widget (1fr 1.1fr)
  * forecast as a single hairline-separated band (no per-day cards)
  * events+notes in a 2-column grid; events card combines today and
    tomorrow with a divider; notes card styled via surface tokens
- TransportWidget repainted to use tokens, larger numbers (32px for the
  next arrival), imminent highlight uses color-mix against surface-2
2026-04-23 08:30:03 +00:00

227 lines
7.9 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: 12,
padding: '10px 14px', 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,
transition: 'all 0.3s ease',
}}>
{/* Primary time — big */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, flexShrink: 0 }}>
<div style={{
fontSize: first.minutes <= 0 ? 18 : 32,
fontWeight: 800, letterSpacing: '-1.5px', 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: 13, color: 'var(--text-secondary)', fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
}}>
{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,
height: '100%',
}}>
{/* 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: '58px 1fr 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: '58px 1fr 1fr',
gap: 10,
alignItems: 'stretch',
}}>
<div style={{
background: route.bg,
boxShadow: `0 6px 16px -4px ${route.color}55`,
borderRadius: 13,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: 800, fontSize: 22,
letterSpacing: '-1.5px',
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>
)
}