feat: update readme with screenshot and fix end station bug

This commit is contained in:
Lucas Oskorep
2026-01-20 22:45:04 -05:00
parent 1e420b9375
commit fb65b607af
5 changed files with 97 additions and 29 deletions

View File

@@ -3,9 +3,26 @@
This project gives you a docker image that you can run locally to pull MTA data and self-host a webpage that looks very This project gives you a docker image that you can run locally to pull MTA data and self-host a webpage that looks very
similar to the classic MTA Signs found in the metro. similar to the classic MTA Signs found in the metro.
![ui-example.png](attachments/ui-example.png)
Initially designed to run directly on a raspberry pi, it has been containerized and now runs anywhere you can install Initially designed to run directly on a raspberry pi, it has been containerized and now runs anywhere you can install
podman or docker! podman or docker!
Additionally, this can be run without the frontend in case you just wanted a slightly more sane way to query MTA's data
sources compared to their stock APIs.
## Features
- **Real-time MTA Data**: Live train arrival times for NYC subway
stations - [available for free here](https://api.mta.info/#/subwayRealTimeFeeds)
- **Configurable Stations**: Monitor multiple stations simultaneously with separate cards
- **Line Filtering**: Show/hide specific transit lines for each station
- **Direction Selection**: Toggle between North/South bound trains independently
- **Responsive Design**: Works on desktop, tablet, and mobile displays
- **Configuration Persistence**: Save your selected stations and preferences to localStorage or export/import them via
JSON
- **Docker Ready**: Easy deployment with Docker or Docker Compose
## Running the Docker Image ## Running the Docker Image
### Prerequisites ### Prerequisites

BIN
attachments/ui-example.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

View File

@@ -59,12 +59,12 @@ export default function StationSelector({ selectedStation, onSelect }: StationSe
return ( return (
<div ref={dropdownRef} className="relative w-full"> <div ref={dropdownRef} className="relative w-full">
<div <div
className="flex items-center gap-2 bg-gray-700 px-3 py-2 cursor-pointer" className="flex items-center gap-2 bg-transparent cursor-pointer"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
> >
<input <input
type="text" type="text"
placeholder={selectedStation?.name || 'Search stations...'} placeholder="Search stations..."
value={search} value={search}
onChange={(e) => { onChange={(e) => {
setSearch(e.target.value) setSearch(e.target.value)
@@ -74,7 +74,7 @@ export default function StationSelector({ selectedStation, onSelect }: StationSe
e.stopPropagation() e.stopPropagation()
setIsOpen(true) setIsOpen(true)
}} }}
className="flex-1 bg-transparent text-white placeholder-gray-400 outline-none text-sm md:text-base lg:text-lg" className="flex-1 bg-transparent text-white placeholder-gray-400 outline-none text-sm md:text-base"
/> />
<ChevronDownIcon <ChevronDownIcon
className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}

View File

@@ -10,16 +10,19 @@ interface Route {
interface DirectionCardProps { interface DirectionCardProps {
direction: 'North' | 'South' direction: 'North' | 'South'
routes: Route[] routes: Route[]
isEndOfLine?: boolean
} }
const DirectionCard = ({ direction, routes }: DirectionCardProps) => { const DirectionCard = ({ direction, routes, isEndOfLine = false }: DirectionCardProps) => {
return ( return (
<div className="bg-gray-800 overflow-hidden flex-1"> <div className="bg-gray-800 overflow-hidden flex-1">
<div className="bg-gray-700 px-3 py-2 md:px-4 md:py-3 lg:px-6 lg:py-4"> {!isEndOfLine && (
<h2 className="text-xl md:text-2xl lg:text-3xl xl:text-4xl font-bold text-white"> <div className="bg-gray-700 px-3 py-2 md:px-4 md:py-3 lg:px-6 lg:py-4">
{direction} <h2 className="text-xl md:text-2xl lg:text-3xl xl:text-4xl font-bold text-white">
</h2> {direction}
</div> </h2>
</div>
)}
<div className="divide-y divide-gray-700"> <div className="divide-y divide-gray-700">
{routes && routes.length > 0 ? ( {routes && routes.length > 0 ? (
routes.map((route, index) => ( routes.map((route, index) => (

View File

@@ -57,20 +57,35 @@ export default function StationCard({ configId, initialConfig, availableLines, o
const fetchStationData = useCallback(async () => { const fetchStationData = useCallback(async () => {
if (!selectedStation) return if (!selectedStation) return
try { if (showNorth) {
if (showNorth) { try {
const response = await axios.post(`/api/mta/${selectedStation.id}N`) const response = await axios.post(`/api/mta/${selectedStation.id}N`)
setNorthData(response.data) setNorthData(response.data)
} catch (err) {
console.error('Error fetching north station data:', err)
setNorthData(null)
} }
if (showSouth) { }
if (showSouth) {
try {
const response = await axios.post(`/api/mta/${selectedStation.id}S`) const response = await axios.post(`/api/mta/${selectedStation.id}S`)
setSouthData(response.data) setSouthData(response.data)
} catch (err) {
console.error('Error fetching south station data:', err)
setSouthData(null)
} }
} catch (err) {
console.error('Error fetching station data:', err)
} }
}, [selectedStation, showNorth, showSouth]) }, [selectedStation, showNorth, showSouth])
// Clear old data when station changes to prevent showing stale data from previous station
useEffect(() => {
if (selectedStation) {
setNorthData(null)
setSouthData(null)
}
}, [selectedStation?.id])
useEffect(() => { useEffect(() => {
if (selectedStation) { if (selectedStation) {
fetchStationData() fetchStationData()
@@ -149,6 +164,7 @@ export default function StationCard({ configId, initialConfig, availableLines, o
showSouth={showSouth} showSouth={showSouth}
northRoutes={northData ? filterRoutes(northData.routes || []) : []} northRoutes={northData ? filterRoutes(northData.routes || []) : []}
southRoutes={southData ? filterRoutes(southData.routes || []) : []} southRoutes={southData ? filterRoutes(southData.routes || []) : []}
availableDirections={availableDirections}
/> />
</div> </div>
) )
@@ -179,14 +195,37 @@ function Header({
onToggleLine, onToggleLine,
onRemove, onRemove,
}: HeaderProps) { }: HeaderProps) {
return ( const [showSearch, setShowSearch] = useState(false)
<div className="bg-gray-700 px-3 py-2 md:px-4 md:py-3 flex items-center gap-2 md:gap-4">
<div className="flex-1">
<StationSelector selectedStation={selectedStation} onSelect={onSelectStation} />
</div>
<div className="flex items-center gap-2 md:gap-3 shrink-0"> return (
{availableDirections.has('North') && ( <div className="bg-gray-700">
{/* Station Name Title and Search Row */}
<div className="px-3 py-2 md:px-4 md:py-3 lg:px-6 lg:py-4 flex items-center gap-2 md:gap-4">
{selectedStation && (
<h1 className="text-xl md:text-2xl lg:text-3xl xl:text-4xl font-bold text-white flex-1">
{selectedStation.name}
</h1>
)}
{/* Search Button */}
{showSearch ? (
<div className="w-48">
<StationSelector selectedStation={selectedStation} onSelect={(station) => {
onSelectStation(station)
setShowSearch(false)
}} />
</div>
) : (
<button
onClick={() => setShowSearch(true)}
className="px-3 py-1 md:px-4 md:py-2 bg-gray-600 hover:bg-gray-500 text-white rounded transition-colors text-sm md:text-base shrink-0"
>
Search
</button>
)}
<div className="flex items-center gap-2 md:gap-3 shrink-0">
{availableDirections.has('North') && (
<label className="flex items-center gap-1 cursor-pointer"> <label className="flex items-center gap-1 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@@ -227,12 +266,13 @@ function Header({
{onRemove && ( {onRemove && (
<button <button
onClick={onRemove} onClick={onRemove}
className="p-2 rounded-lg bg-red-600 hover:bg-red-500 transition-colors shrink-0" className="group p-2 rounded-lg bg-gray-700 hover:bg-red-600 transition-colors shrink-0"
title="Remove station" title="Remove station"
> >
<XMarkIcon className="w-4 h-4 md:w-5 md:h-5 text-white" /> <XMarkIcon className="w-4 h-4 md:w-5 md:h-5 text-gray-400 group-hover:text-white transition-colors" />
</button> </button>
)} )}
</div>
</div> </div>
</div> </div>
) )
@@ -244,9 +284,10 @@ interface ContentProps {
showSouth: boolean showSouth: boolean
northRoutes: Route[] northRoutes: Route[]
southRoutes: Route[] southRoutes: Route[]
availableDirections: Set<string>
} }
function Content({ selectedStation, showNorth, showSouth, northRoutes, southRoutes }: ContentProps) { function Content({ selectedStation, showNorth, showSouth, northRoutes, southRoutes, availableDirections }: ContentProps) {
if (!selectedStation) { if (!selectedStation) {
return ( return (
<div className="p-8 text-center text-gray-400"> <div className="p-8 text-center text-gray-400">
@@ -255,16 +296,23 @@ function Content({ selectedStation, showNorth, showSouth, northRoutes, southRout
) )
} }
// Determine if this is an end-of-line station (only one direction available)
const isEndOfLine = availableDirections.size === 1
// Only render directions that are both available AND selected
const renderNorth = showNorth && availableDirections.has('North')
const renderSouth = showSouth && availableDirections.has('South')
return ( return (
<div className="flex flex-col md:flex-row"> <div className="flex flex-col md:flex-row">
{showNorth && ( {renderNorth && (
<div className={`flex-1 ${showSouth ? 'border-b md:border-b-0 md:border-r border-gray-700' : ''}`}> <div className={`flex-1 ${renderSouth ? 'border-b md:border-b-0 md:border-r border-gray-700' : ''}`}>
<DirectionCard direction="North" routes={northRoutes} /> <DirectionCard direction="North" routes={northRoutes} isEndOfLine={isEndOfLine} />
</div> </div>
)} )}
{showSouth && ( {renderSouth && (
<div className="flex-1"> <div className="flex-1">
<DirectionCard direction="South" routes={southRoutes} /> <DirectionCard direction="South" routes={southRoutes} isEndOfLine={isEndOfLine} />
</div> </div>
)} )}
</div> </div>