3 Commits

Author SHA1 Message Date
Lucas Oskorep
33c7a108e2 feat: finish readme and add swagger 2026-01-20 22:50:05 -05:00
Lucas Oskorep
dc8a2c9d3e feat: update readme with screenshot and fix end station bug 2026-01-20 22:45:43 -05:00
Lucas Oskorep
fb65b607af feat: update readme with screenshot and fix end station bug 2026-01-20 22:45:04 -05:00
7 changed files with 114 additions and 36 deletions

View File

@@ -3,14 +3,28 @@
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.
![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
podman or docker!
## Running the Docker Image
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. Swagger is hosted at /swagger
![swagger.png](attachments/swagger.png)
### Prerequisites
## Features
- Docker installed on your system
- **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 via Docker
### Quick Start with Docker
@@ -34,8 +48,9 @@ The application will be available at `http://localhost:8000`
### Environment Variables
| Variable | Description | Default | Required |
|-------------------|---------------------------------|---------|----------|
|-------------------|------------------------------------------------------------|---------|----------|
| `FRONTEND_ENABLE` | Enable/disable the web frontend | `true` | No |
| `SHOW_SWAGGER` | Enable/disable Swagger API documentation at `/swagger` | `false` | No |
### Running with Docker Compose

BIN
attachments/swagger.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
attachments/ui-example.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

14
main.py
View File

@@ -15,7 +15,15 @@ from mta_sign_server.config.router import router as config_router
load_dotenv()
app = FastAPI()
# Setup Swagger documentation
show_swagger = os.getenv("SHOW_SWAGGER", "false").lower() in ("true", "1", "yes")
swagger_config = {
"docs_url": "/swagger" if show_swagger else None,
"redoc_url": None,
"openapi_url": "/openapi.json" if show_swagger else None,
}
app = FastAPI(**swagger_config)
app.add_middleware(
CORSMiddleware,
allow_origins=['*']
@@ -49,6 +57,10 @@ if frontend_enabled and static_dir.exists():
@app.get("/{path:path}")
async def serve_spa(path: str):
"""Serve static files or fall back to index.html for SPA routing"""
# Exclude API and documentation routes from SPA fallback
if path.startswith(("api/", "swagger", "openapi", "redoc", "docs")):
return {"error": "Not found"}
file_path = static_dir / path
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)

View File

@@ -59,12 +59,12 @@ export default function StationSelector({ selectedStation, onSelect }: StationSe
return (
<div ref={dropdownRef} className="relative w-full">
<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)}
>
<input
type="text"
placeholder={selectedStation?.name || 'Search stations...'}
placeholder="Search stations..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
@@ -74,7 +74,7 @@ export default function StationSelector({ selectedStation, onSelect }: StationSe
e.stopPropagation()
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
className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}

View File

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

View File

@@ -57,20 +57,35 @@ export default function StationCard({ configId, initialConfig, availableLines, o
const fetchStationData = useCallback(async () => {
if (!selectedStation) return
try {
if (showNorth) {
try {
const response = await axios.post(`/api/mta/${selectedStation.id}N`)
setNorthData(response.data)
} catch (err) {
console.error('Error fetching north station data:', err)
setNorthData(null)
}
}
if (showSouth) {
try {
const response = await axios.post(`/api/mta/${selectedStation.id}S`)
setSouthData(response.data)
}
} catch (err) {
console.error('Error fetching station data:', err)
console.error('Error fetching south station data:', err)
setSouthData(null)
}
}
}, [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(() => {
if (selectedStation) {
fetchStationData()
@@ -149,6 +164,7 @@ export default function StationCard({ configId, initialConfig, availableLines, o
showSouth={showSouth}
northRoutes={northData ? filterRoutes(northData.routes || []) : []}
southRoutes={southData ? filterRoutes(southData.routes || []) : []}
availableDirections={availableDirections}
/>
</div>
)
@@ -179,11 +195,34 @@ function Header({
onToggleLine,
onRemove,
}: HeaderProps) {
const [showSearch, setShowSearch] = useState(false)
return (
<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 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') && (
@@ -227,14 +266,15 @@ function Header({
{onRemove && (
<button
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"
>
<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>
)}
</div>
</div>
</div>
)
}
@@ -244,9 +284,10 @@ interface ContentProps {
showSouth: boolean
northRoutes: 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) {
return (
<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 (
<div className="flex flex-col md:flex-row">
{showNorth && (
<div className={`flex-1 ${showSouth ? 'border-b md:border-b-0 md:border-r border-gray-700' : ''}`}>
<DirectionCard direction="North" routes={northRoutes} />
{renderNorth && (
<div className={`flex-1 ${renderSouth ? 'border-b md:border-b-0 md:border-r border-gray-700' : ''}`}>
<DirectionCard direction="North" routes={northRoutes} isEndOfLine={isEndOfLine} />
</div>
)}
{showSouth && (
{renderSouth && (
<div className="flex-1">
<DirectionCard direction="South" routes={southRoutes} />
<DirectionCard direction="South" routes={southRoutes} isEndOfLine={isEndOfLine} />
</div>
)}
</div>