Compare commits
5 Commits
feat/refac
...
fb65b607af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb65b607af | ||
|
|
1e420b9375 | ||
|
|
d97a9ef01c | ||
|
|
be2b2ecbac | ||
|
|
e8ab6cf69d |
@@ -1 +1 @@
|
||||
../../.github/workflows/build.yml
|
||||
.github/workflows/build.yml
|
||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -4,13 +4,11 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
|
||||
88
README.md
88
README.md
@@ -1,3 +1,87 @@
|
||||
# pi-mta-sign
|
||||
# Pi MTA Sign!
|
||||
|
||||
Code and documentation for project for turning a raspberry pi into your very own MTA subway sign.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
Initially designed to run directly on a raspberry pi, it has been containerized and now runs anywhere you can install
|
||||
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
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker installed on your system
|
||||
|
||||
### Quick Start with Docker
|
||||
|
||||
Pull the latest image from GitHub Container Registry:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/lucasoskorep/pi-mta-sign:latest
|
||||
```
|
||||
|
||||
Run the container:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 8000:8000 \
|
||||
--name pi-mta-sign \
|
||||
ghcr.io/lucasoskorep/pi-mta-sign:latest
|
||||
```
|
||||
|
||||
The application will be available at `http://localhost:8000`
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|-------------------|---------------------------------|---------|----------|
|
||||
| `FRONTEND_ENABLE` | Enable/disable the web frontend | `true` | No |
|
||||
|
||||
### Running with Docker Compose
|
||||
|
||||
For a more convenient setup, use the provided docker-compose configuration:
|
||||
|
||||
```bash
|
||||
# Copy the example docker-compose.yaml from the docker folder
|
||||
cp docker/docker-compose.example.yaml docker-compose.yaml
|
||||
|
||||
# Run with docker-compose
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
See `docker/docker-compose.example.yaml` for a complete example configuration.
|
||||
|
||||
### Local Development
|
||||
|
||||
To develop on this app locally you can clone the repo and then in the repo root run the following
|
||||
|
||||
Tools needed:
|
||||
|
||||
- [just](https://github.com/casey/just)
|
||||
- [uv](https://github.com/astral-sh/uv)
|
||||
- [fnm](https://github.com/Schniz/fnm)
|
||||
|
||||
Once you have all the required tooling all you need to do is
|
||||
|
||||
```bash
|
||||
just init # downloads python
|
||||
|
||||
just dev # this will spin up the nextjs frontend and fastapi backend in hot reload mode
|
||||
```
|
||||
|
||||
BIN
attachments/ui-example.png
Normal file
BIN
attachments/ui-example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
11
docker/docker-compose.example.yaml
Normal file
11
docker/docker-compose.example.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
pi-mta-sign:
|
||||
image: ghcr.io/lucasoskorep/pi-mta-sign:latest
|
||||
container_name: pi-mta-sign
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
# OPTIONAL: Enable or disable the web frontend (default: true)
|
||||
FRONTEND_ENABLE: "true"
|
||||
5
justfile
5
justfile
@@ -5,9 +5,7 @@ default:
|
||||
# Setup Python project
|
||||
init:
|
||||
uv sync
|
||||
|
||||
# Setup frontend project
|
||||
init-ui:
|
||||
fnm use
|
||||
cd mta-sign-ui && pnpm install
|
||||
|
||||
# Build frontend and run FastAPI serving static files
|
||||
@@ -21,7 +19,6 @@ dev:
|
||||
echo "Starting FastAPI backend on :8000..."
|
||||
uv run python main.py &
|
||||
BACKEND_PID=$!
|
||||
sleep 2
|
||||
echo "Starting Next.js dev server on :3000..."
|
||||
cd mta-sign-ui && pnpm dev &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
@@ -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' : ''}`}
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
{!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) => (
|
||||
|
||||
@@ -57,20 +57,35 @@ export default function StationCard({ configId, initialConfig, availableLines, o
|
||||
const fetchStationData = useCallback(async () => {
|
||||
if (!selectedStation) return
|
||||
|
||||
try {
|
||||
if (showNorth) {
|
||||
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) {
|
||||
}
|
||||
|
||||
if (showSouth) {
|
||||
try {
|
||||
const response = await axios.post(`/api/mta/${selectedStation.id}S`)
|
||||
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])
|
||||
|
||||
// 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,14 +195,37 @@ function Header({
|
||||
onToggleLine,
|
||||
onRemove,
|
||||
}: HeaderProps) {
|
||||
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>
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
|
||||
<div className="flex items-center gap-2 md:gap-3 shrink-0">
|
||||
{availableDirections.has('North') && (
|
||||
return (
|
||||
<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">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -227,12 +266,13 @@ 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>
|
||||
|
||||
Reference in New Issue
Block a user