feat: rework to use next and host from single dockerfile

This commit is contained in:
Lucas Oskorep
2026-01-20 21:48:33 -05:00
parent dae0625278
commit 28a84293c9
44 changed files with 7592 additions and 10664 deletions

View File

@@ -1,2 +0,0 @@
nodeLinker: node-modules

View File

@@ -2,26 +2,11 @@
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
html {
background-color: #1f2937;
color: #fff;
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
@apply bg-gray-900 text-white;
}

View File

@@ -1,8 +1,5 @@
import './globals.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Pi MTA Sign!',
@@ -16,7 +13,7 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<body>{children}</body>
</html>
)
}

View File

@@ -1,92 +1,175 @@
import Image from 'next/image'
import Header from "@/components/header";
import Station from "@/components/trains/station";
'use client'
import { useState, useEffect, useCallback } from 'react'
import { PlusIcon } from '@heroicons/react/24/outline'
import Header from '@/components/Header'
import StationCard from '@/components/trains/StationCard'
import { AppConfig, StationConfig, CONFIG_VERSION } from '@/types/config'
import axios from 'axios'
const generateId = () => Math.random().toString(36).substring(2, 11)
const DEFAULT_CONFIGS: StationConfig[] = [
{ id: generateId(), stationId: '127', stationName: 'Times Sq-42 St', showNorth: true, showSouth: true, selectedLines: [] },
{ id: generateId(), stationId: 'A27', stationName: 'Times Sq-42 St', showNorth: true, showSouth: true, selectedLines: [] },
]
const STORAGE_KEY = 'mta-sign-config'
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between">
<Header></Header>
<Station></Station>
const [stationConfigs, setStationConfigs] = useState<StationConfig[]>(DEFAULT_CONFIGS)
const [availableLines, setAvailableLines] = useState<string[]>([])
const [startTime, setStartTime] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<string | null>(null)
const [isLoaded, setIsLoaded] = useState(false)
<div className="relative flex place-items-center before:absolute before:h-[300px] before:w-[480px] before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-[240px] after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-blue-700 before:dark:opacity-10 after:dark:from-sky-900 after:dark:via-[#0141ff] after:dark:opacity-40 before:lg:h-[360px]">
<Image
className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
src="/next.svg"
alt="Next.js Logo"
width={180}
height={37}
priority
/>
</div>
// Load config from localStorage on mount
useEffect(() => {
if (typeof window === 'undefined') return
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const config = JSON.parse(saved)
if (config.stations && Array.isArray(config.stations)) {
// Ensure all stations have IDs
const stationsWithIds = config.stations.map((s: any) => ({
...s,
id: s.id || generateId(),
}))
setStationConfigs(stationsWithIds)
}
}
} catch (err) {
console.error('Error loading config from localStorage:', err)
}
setIsLoaded(true)
}, [])
<div className="mb-32 grid text-center lg:mb-0 lg:grid-cols-4 lg:text-left">
<a
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className={`mb-3 text-2xl font-semibold`}>
Docs{' '}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
Find in-depth information about Next.js features and API.
</p>
</a>
useEffect(() => {
axios.get('/api/lines')
.then(response => {
const lines = response.data.lines || []
setAvailableLines(lines)
// Update default configs to include all lines
setStationConfigs(prev => prev.map(config => ({
...config,
selectedLines: config.selectedLines.length === 0 ? lines : config.selectedLines
})))
})
.catch(err => console.error('Error fetching lines:', err))
}, [])
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className={`mb-3 text-2xl font-semibold`}>
Learn{' '}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
Learn about Next.js in an interactive course with&nbsp;quizzes!
</p>
</a>
useEffect(() => {
axios.post('/api/start_time')
.then(response => {
if (response.data) {
setStartTime(new Date(response.data).toLocaleString('en-US'))
}
})
.catch(err => console.error('Error fetching start time:', err))
}, [])
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className={`mb-3 text-2xl font-semibold`}>
Templates{' '}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
Explore the Next.js 13 playground.
</p>
</a>
useEffect(() => {
const interval = setInterval(() => {
setLastUpdated(new Date().toLocaleString('en-US'))
}, 5000)
return () => clearInterval(interval)
}, [])
<a
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className={`mb-3 text-2xl font-semibold`}>
Deploy{' '}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
Instantly deploy your Next.js site to a shareable URL with Vercel.
</p>
</a>
</div>
</main>
)
// Save config to localStorage whenever it changes
useEffect(() => {
if (typeof window === 'undefined') return
try {
const configToSave = {
version: CONFIG_VERSION,
stations: stationConfigs,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(configToSave))
} catch (err) {
console.error('Error saving config to localStorage:', err)
}
}, [stationConfigs])
const handleConfigChange = useCallback((configId: string, newConfig: StationConfig) => {
setStationConfigs(prevConfigs => {
const updated = prevConfigs.map(config => config.id === configId ? newConfig : config)
return updated
})
}, [])
const addStation = () => {
const newConfig: StationConfig = {
id: generateId(),
stationId: '',
stationName: '',
showNorth: true,
showSouth: true,
selectedLines: availableLines,
}
setStationConfigs([...stationConfigs, newConfig])
}
const removeStation = (configId: string) => {
setStationConfigs(stationConfigs.filter(config => config.id !== configId))
}
const exportConfig = useCallback((): AppConfig => {
return {
version: CONFIG_VERSION,
stations: stationConfigs,
}
}, [stationConfigs])
const importConfig = useCallback((config: AppConfig) => {
if (config.version !== CONFIG_VERSION) {
alert(`Config version mismatch. Expected ${CONFIG_VERSION}, got ${config.version}`)
return
}
// Ensure all stations have IDs
const stationsWithIds = config.stations.map(station => ({
...station,
id: station.id || generateId(),
}))
setStationConfigs(stationsWithIds)
}, [])
if (availableLines.length === 0) {
return (
<main className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-white text-xl">Loading...</div>
</main>
)
}
return (
<main className="min-h-screen bg-gray-900 flex flex-col">
<Header
startTime={startTime}
lastUpdated={lastUpdated}
onExportConfig={exportConfig}
onImportConfig={importConfig}
/>
<div className="flex-1 p-2 md:p-4 space-y-4 md:space-y-6">
{stationConfigs.map((config) => (
<StationCard
key={config.id}
configId={config.id}
initialConfig={config.stationId ? config : undefined}
availableLines={availableLines}
onConfigChange={handleConfigChange}
onRemove={() => removeStation(config.id)}
/>
))}
<button
onClick={addStation}
className="w-full p-4 border-2 border-dashed border-gray-600 rounded-lg text-gray-400 hover:border-gray-500 hover:text-gray-300 transition-colors flex items-center justify-center gap-2"
>
<PlusIcon className="w-5 h-5" />
Add Station
</button>
</div>
</main>
)
}

View File

@@ -0,0 +1,106 @@
'use client'
import { useRef } from 'react'
import Image from 'next/image'
import { ArrowDownTrayIcon, ArrowUpTrayIcon } from '@heroicons/react/24/outline'
import { AppConfig } from '@/types/config'
interface HeaderProps {
startTime: string | null
lastUpdated: string | null
onExportConfig: () => AppConfig
onImportConfig: (config: AppConfig) => void
}
export default function Header({ startTime, lastUpdated, onExportConfig, onImportConfig }: HeaderProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const handleExport = () => {
const config = onExportConfig()
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'mta-sign-config.json'
a.click()
URL.revokeObjectURL(url)
}
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
try {
const config = JSON.parse(e.target?.result as string) as AppConfig
onImportConfig(config)
} catch (err) {
console.error('Failed to parse config file:', err)
alert('Invalid config file')
}
}
reader.readAsText(file)
// Reset input so same file can be imported again
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
return (
<nav className="bg-gray-900 w-full px-4 py-3 md:px-6 md:py-4">
<div className="flex flex-col lg:flex-row items-center justify-between gap-2 lg:gap-4">
<div className="flex items-center gap-2 md:gap-4">
<Image
src="/images/RPI-LOGO.png"
alt="Raspberry Pi Logo"
width={50}
height={50}
className="w-8 h-8 md:w-10 md:h-10 lg:w-12 lg:h-12"
/>
<h1 className="text-2xl md:text-4xl lg:text-5xl xl:text-6xl font-bold text-white">
Pi MTA Display!
</h1>
</div>
<div className="flex items-center gap-4">
<div className="flex gap-2">
<button
onClick={handleExport}
className="p-2 rounded-lg bg-gray-700 hover:bg-gray-600 transition-colors"
title="Export config"
>
<ArrowDownTrayIcon className="w-5 h-5 text-white" />
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="p-2 rounded-lg bg-gray-700 hover:bg-gray-600 transition-colors"
title="Import config"
>
<ArrowUpTrayIcon className="w-5 h-5 text-white" />
</button>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleImport}
className="hidden"
/>
</div>
<div className="flex flex-col items-center lg:items-end text-sm md:text-base lg:text-lg xl:text-xl">
<div className="text-white">
<span className="font-semibold">Last Updated:</span>{' '}
<span>{lastUpdated || 'Loading...'}</span>
</div>
<div className="text-white">
<span className="font-semibold">Started:</span>{' '}
<span>{startTime || 'Loading...'}</span>
</div>
</div>
</div>
</div>
</nav>
)
}

View File

@@ -0,0 +1,108 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { ChevronDownIcon } from '@heroicons/react/24/outline'
import axios from 'axios'
export interface Station {
id: string
name: string
}
interface StationSelectorProps {
selectedStation: Station | null
onSelect: (station: Station) => void
}
export default function StationSelector({ selectedStation, onSelect }: StationSelectorProps) {
const [search, setSearch] = useState('')
const [stations, setStations] = useState<Station[]>([])
const [isOpen, setIsOpen] = useState(false)
const [loading, setLoading] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const fetchStations = async () => {
setLoading(true)
try {
const params = search ? { search } : {}
const response = await axios.get('/api/stations', { params })
setStations(response.data.stations || [])
} catch (err) {
console.error('Error fetching stations:', err)
} finally {
setLoading(false)
}
}
const debounce = setTimeout(fetchStations, 300)
return () => clearTimeout(debounce)
}, [search])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSelect = (station: Station) => {
onSelect(station)
setSearch('')
setIsOpen(false)
}
return (
<div ref={dropdownRef} className="relative w-full">
<div
className="flex items-center gap-2 bg-gray-700 px-3 py-2 cursor-pointer"
onClick={() => setIsOpen(!isOpen)}
>
<input
type="text"
placeholder={selectedStation?.name || 'Search stations...'}
value={search}
onChange={(e) => {
setSearch(e.target.value)
setIsOpen(true)
}}
onClick={(e) => {
e.stopPropagation()
setIsOpen(true)
}}
className="flex-1 bg-transparent text-white placeholder-gray-400 outline-none text-sm md:text-base lg:text-lg"
/>
<ChevronDownIcon
className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</div>
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-gray-800 border border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{loading ? (
<div className="p-3 text-gray-400 text-center">Loading...</div>
) : stations.length === 0 ? (
<div className="p-3 text-gray-400 text-center">No stations found</div>
) : (
stations.map((station) => (
<div
key={station.id}
onClick={() => handleSelect(station)}
className={`px-3 py-2 cursor-pointer hover:bg-gray-700 transition-colors ${
selectedStation?.id === station.id ? 'bg-gray-700' : ''
}`}
>
<span className="text-white text-sm md:text-base">{station.name}</span>
<span className="text-gray-500 text-xs ml-2">({station.id})</span>
</div>
))
)}
</div>
)}
</div>
)
}

View File

@@ -1,58 +0,0 @@
'use client'
import React, {useEffect, useState} from 'react';
import {fetchStartDate} from "@/services/mta-api/mta-server";
import {MtaStartTime} from "@/services/mta-api/types";
import Image from 'next/image';
const Header = () => {
const [startDate, setStartDate] = useState<MtaStartTime | null>(null);
const [lastUpdatedDate, setLastUpdatedDate] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
console.log("CALLING API")
const mtaData = await fetchStartDate([""])
setStartDate(mtaData)
setLastUpdatedDate(new Date().toLocaleString("en-US"))
} catch (error) {
console.error('Error fetching data:', error);
}
};
fetchData();
}, []);
return (
<div className="align-middle lg:flex w-full">
<div className="flex align-middle justify-center lg:justify-normal">
<div className="align-middle p-0.5">
<div style={{width: '100%', aspectRatio: '16/9'}} className="h-5 lg:h-6">
<Image src="/images/RPI-LOGO.png" alt="rpi-logo" width="160" height="90" className="w-11"/>
</div>
</div>
<h1 className="lg:text-left text-center mb-4 text-5xl font-extrabold leading-none tracking-tight text-gray-900 lg:text-6xl dark:text-white ">
Pi MTA Display!
</h1>
</div>
<div className="lg:flex-grow"></div>
<div className="lg:text-right text-center lg:p-2">
{startDate ? (
<h2 className="text-lg lg:text-xl font-bold dark:text-white">Started
At: <span>{startDate.startTime.toLocaleString("en-US")}</span></h2>
) : (
<p>Loading data...</p>
)}
<h2 className="text-lg lg:text-xl font-bold dark:text-white">Updated
At: <span>{lastUpdatedDate}</span></h2>
</div>
</div>
);
};
export default Header;

View File

@@ -0,0 +1,42 @@
'use client'
import TrainLine from './TrainLine'
interface Route {
routeId: string
arrival_times: number[]
}
interface DirectionCardProps {
direction: 'North' | 'South'
routes: Route[]
}
const DirectionCard = ({ direction, routes }: 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>
<div className="divide-y divide-gray-700">
{routes && routes.length > 0 ? (
routes.map((route, index) => (
<TrainLine
key={`${route.routeId}-${index}`}
routeId={route.routeId}
arrivalTimes={route.arrival_times}
/>
))
) : (
<div className="p-4 text-gray-400 text-center">
No trains available
</div>
)}
</div>
</div>
)
}
export default DirectionCard

View File

@@ -0,0 +1,272 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import axios from 'axios'
import StationSelector, { Station } from '../StationSelector'
import DirectionCard from './DirectionCard'
import { StationConfig } from '@/types/config'
interface Route {
routeId: string
arrival_times: number[]
}
interface StationData {
stationId: string
routes: Route[]
}
interface StationCardProps {
configId: string
initialConfig?: StationConfig
availableLines: string[]
onConfigChange?: (configId: string, config: StationConfig) => void
onRemove?: () => void
}
export default function StationCard({ configId, initialConfig, availableLines, onConfigChange, onRemove }: StationCardProps) {
const [selectedStation, setSelectedStation] = useState<Station | null>(
initialConfig ? { id: initialConfig.stationId, name: initialConfig.stationName } : null
)
const [showNorth, setShowNorth] = useState(initialConfig?.showNorth ?? true)
const [showSouth, setShowSouth] = useState(initialConfig?.showSouth ?? true)
const [selectedLines, setSelectedLines] = useState<Set<string>>(
new Set(initialConfig?.selectedLines ?? availableLines)
)
const [northData, setNorthData] = useState<StationData | null>(null)
const [southData, setSouthData] = useState<StationData | null>(null)
const notifyConfigChange = useCallback(() => {
if (onConfigChange && selectedStation) {
onConfigChange(configId, {
id: configId,
stationId: selectedStation.id,
stationName: selectedStation.name,
showNorth,
showSouth,
selectedLines: Array.from(selectedLines),
})
}
}, [configId, onConfigChange, selectedStation, showNorth, showSouth, selectedLines])
useEffect(() => {
notifyConfigChange()
}, [notifyConfigChange])
const fetchStationData = useCallback(async () => {
if (!selectedStation) return
try {
if (showNorth) {
const response = await axios.post(`/api/mta/${selectedStation.id}N`)
setNorthData(response.data)
}
if (showSouth) {
const response = await axios.post(`/api/mta/${selectedStation.id}S`)
setSouthData(response.data)
}
} catch (err) {
console.error('Error fetching station data:', err)
}
}, [selectedStation, showNorth, showSouth])
useEffect(() => {
if (selectedStation) {
fetchStationData()
const interval = setInterval(fetchStationData, 5000)
return () => clearInterval(interval)
}
}, [selectedStation, fetchStationData])
const availableDirections = useMemo(() => {
const directions = new Set<string>()
if (northData && northData.routes.length > 0) directions.add('North')
if (southData && southData.routes.length > 0) directions.add('South')
return directions
}, [northData, southData])
const stationAvailableLines = useMemo(() => {
const lines = new Set<string>()
if (northData) {
northData.routes.forEach(route => {
const lineId = route.routeId.replace('Route.', '')
lines.add(lineId)
})
}
if (southData) {
southData.routes.forEach(route => {
const lineId = route.routeId.replace('Route.', '')
lines.add(lineId)
})
}
return Array.from(lines).sort()
}, [northData, southData])
const toggleLine = (line: string) => {
const newSelected = new Set(selectedLines)
if (newSelected.has(line)) {
newSelected.delete(line)
} else {
newSelected.add(line)
}
setSelectedLines(newSelected)
}
const toggleDirection = (direction: 'North' | 'South') => {
if (direction === 'North') {
setShowNorth(!showNorth)
} else {
setShowSouth(!showSouth)
}
}
const filterRoutes = (routes: Route[]): Route[] => {
return routes.filter(route => {
const lineId = route.routeId.replace('Route.', '')
return selectedLines.has(lineId)
})
}
return (
<div className="bg-gray-800 overflow-hidden">
<Header
selectedStation={selectedStation}
onSelectStation={setSelectedStation}
showNorth={showNorth}
showSouth={showSouth}
selectedLines={selectedLines}
availableDirections={availableDirections}
availableLines={stationAvailableLines}
onToggleDirection={toggleDirection}
onToggleLine={toggleLine}
onRemove={onRemove}
/>
<Content
selectedStation={selectedStation}
showNorth={showNorth}
showSouth={showSouth}
northRoutes={northData ? filterRoutes(northData.routes || []) : []}
southRoutes={southData ? filterRoutes(southData.routes || []) : []}
/>
</div>
)
}
interface HeaderProps {
selectedStation: Station | null
onSelectStation: (station: Station) => void
showNorth: boolean
showSouth: boolean
selectedLines: Set<string>
availableDirections: Set<string>
availableLines: string[]
onToggleDirection: (direction: 'North' | 'South') => void
onToggleLine: (line: string) => void
onRemove?: () => void
}
function Header({
selectedStation,
onSelectStation,
showNorth,
showSouth,
selectedLines,
availableDirections,
availableLines,
onToggleDirection,
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>
<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"
checked={showNorth}
onChange={() => onToggleDirection('North')}
className="w-4 h-4 rounded"
/>
<span className="text-white text-xs md:text-sm">N</span>
</label>
)}
{availableDirections.has('South') && (
<label className="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
checked={showSouth}
onChange={() => onToggleDirection('South')}
className="w-4 h-4 rounded"
/>
<span className="text-white text-xs md:text-sm">S</span>
</label>
)}
<div className="flex gap-1 md:gap-2">
{availableLines.map(line => (
<button
key={line}
onClick={() => onToggleLine(line)}
className={`w-6 h-6 md:w-7 md:h-7 rounded-full text-xs font-bold transition-all ${
selectedLines.has(line) ? 'bg-blue-600 text-white' : 'bg-gray-600 text-gray-400'
}`}
>
{line}
</button>
))}
</div>
{onRemove && (
<button
onClick={onRemove}
className="p-2 rounded-lg bg-red-600 hover:bg-red-500 transition-colors shrink-0"
title="Remove station"
>
<XMarkIcon className="w-4 h-4 md:w-5 md:h-5 text-white" />
</button>
)}
</div>
</div>
)
}
interface ContentProps {
selectedStation: Station | null
showNorth: boolean
showSouth: boolean
northRoutes: Route[]
southRoutes: Route[]
}
function Content({ selectedStation, showNorth, showSouth, northRoutes, southRoutes }: ContentProps) {
if (!selectedStation) {
return (
<div className="p-8 text-center text-gray-400">
Select a station to view train arrivals
</div>
)
}
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} />
</div>
)}
{showSouth && (
<div className="flex-1">
<DirectionCard direction="South" routes={southRoutes} />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,43 @@
'use client'
import Image from 'next/image'
interface TrainLineProps {
routeId: string
arrivalTimes: number[]
}
const TrainLine = ({ routeId, arrivalTimes }: TrainLineProps) => {
const formatTimes = (times: number[]): string => {
if (!times || times.length === 0) {
return 'No trains'
}
return times
.sort((a, b) => a - b)
.join(', ')
}
const getLineImage = (route: string): string => {
const cleanRoute = route.replace('Route.', '')
return `/images/lines/${cleanRoute}.svg`
}
return (
<div className="flex items-center justify-between p-2 md:p-3 lg:p-4 border-b border-gray-700 last:border-b-0 h-16 md:h-20 lg:h-24">
<div className="flex items-center gap-2 md:gap-3 lg:gap-4 shrink-0">
<Image
src={getLineImage(routeId)}
alt={`${routeId} line`}
width={60}
height={60}
className="w-8 h-8 md:w-12 md:h-12 lg:w-14 lg:h-14 xl:w-16 xl:h-16"
/>
</div>
<h2 className="text-xl md:text-2xl lg:text-[2.5rem] font-bold text-white text-right flex-1 ml-4 whitespace-nowrap overflow-hidden text-ellipsis">
{formatTimes(arrivalTimes)}
</h2>
</div>
)
}
export default TrainLine

View File

@@ -1,33 +0,0 @@
'use client'
import React, {useState} from 'react';
// import {fetchStartDate} from "@/services/mta-api/mta-server";
import {MtaStartTime} from "@/services/mta-api/types";
// import Image from 'next/image';
import {RouteResponse} from "@/gen-sources/mta-sign-api";
const Line = (props: RouteResponse) => {
// const [data, setData] = useState<MtaStartTime | null>(null);
//
// useEffect(() => {
// const fetchData = async () => {
// try {
// console.log("CALLING API")
// const mtaData = await fetchStartDate([""])
// setData(mtaData)
//
// } catch (error) {
// console.error('Error fetching data:', error);
// }
// };
//
// fetchData();
// }, []);
return (
<div className="align-middle lg:flex w-full">
TRAIN LINE HERE - {props.arrival_times.toJSON().toString()}
</div>
);
};
export default Line;

View File

@@ -1,45 +0,0 @@
'use client'
import React, {useEffect, useState} from 'react';
import {AllStationResponse, StationResponse} from "@/gen-sources/mta-sign-api";
import {mtaDataClient} from "@/services/mta-api/types";
const Station = () => {
const [data, setData] = useState<AllStationResponse | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
console.log("CALLING API")
const mtaData = await mtaDataClient.getAllApiMtaPost()
setData(mtaData.data)
} catch (error) {
console.error('Error fetching data:', error);
}
};
fetchData();
}, []);
return (
<div className="align-middle lg:flex w-full">
<div className="lg:flex-grow"></div>
<div className="lg:text-right text-center lg:p-2">
{data ? (
<h2 className="text-lg lg:text-xl font-bold dark:text-white">
{data.stations.map(function (station:any, i:any) {
return <span key={i}>{station.stationId}</span>
})}
Train Line <span>{}</span>
</h2>
) : (
<p>Loading data...</p>
)}
</div>
</div>
);
};
export default Station;

View File

@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,13 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
images: {
unoptimized: true,
},
// Rewrites only work during development (next dev)
// They are ignored during static export build
rewrites: async () => {
return [
{
source: '/api/:path*',
destination:
process.env.NODE_ENV === 'development'
? 'http://localhost:8000/api/:path*'
: '/api/',
destination: 'http://localhost:8000/api/:path*',
},
]
},

File diff suppressed because it is too large Load Diff

View File

@@ -5,30 +5,37 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"build:watch": "chokidar 'app/**/*' 'components/**/*' 'public/**/*' -c 'next build'",
"start": "next start",
"lint": "next lint",
"gen-apis-old": "rm -rf ./gen-sources/mta-sign-api/* && curl localhost:8000/openapi.json -O --output-dir ./gen-sources/mta-sign-api && openapi-generator-cli generate",
"test": "vitest run",
"test:watch": "vitest",
"gen-apis": "npx openapi-typescript http://localhost:8000/openapi.json --output gen-sources/mtaserver.ts"
},
"dependencies": {
"@types/node": "^20.4.2",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"autoprefixer": "^10.4.14",
"axios": "^1.4.0",
"eslint": "^8.45.0",
"eslint-config-next": "^13.4.10",
"follow-redirects": "^1.15.2",
"next": "^13.4.10",
"openapi-typescript-fetch": "^1.1.3",
"postcss": "^8.4.26",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwindcss": "^3.3.3",
"typescript": "^5.1.6"
"@heroicons/react": "^2.2.0",
"autoprefixer": "^10.4.20",
"axios": "^1.7.9",
"bootstrap": "^5.3.8",
"next": "^16.1.2",
"openapi-typescript-fetch": "^2.0.0",
"postcss": "^8.5.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^3.4.17"
},
"packageManager": "yarn@3.6.1",
"devDependencies": {
"@openapitools/openapi-generator-cli": "^2.6.0"
}
"@testing-library/react": "^16.1.0",
"@types/node": "^22.10.7",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"chokidar-cli": "^3.0.0",
"eslint": "^9.18.0",
"eslint-config-next": "^16.1.2",
"jsdom": "^26.0.0",
"typescript": "^5.7.3",
"vitest": "^3.0.0"
},
"packageManager": "pnpm@10.28.1"
}

5742
mta-sign-ui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +0,0 @@
import { MtaStartTime} from "@/services/mta-api/types";
export const fetchStartDate = async (stations: [string]): Promise<MtaStartTime> => {
const res = await fetch("/api/start_time", {method: "POST"})
const data = await res.text()
const date = new Date(data.replaceAll("\"", ""))
return {
startTime: date
};
};

View File

@@ -1,11 +0,0 @@
import {Configuration, MtaDataApi} from "@/gen-sources/mta-sign-api"
export interface MtaStartTime {
startTime: Date
}
export const mtaApiConfiguration = new Configuration(
{basePath:"http://localhost:8000"}
)
export const mtaDataClient = new MtaDataApi(mtaApiConfiguration);

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest'
import { AppConfig, StationConfig, CONFIG_VERSION } from '@/types/config'
describe('Config Types', () => {
it('CONFIG_VERSION is defined', () => {
expect(CONFIG_VERSION).toBeDefined()
expect(typeof CONFIG_VERSION).toBe('number')
})
it('StationConfig has correct shape', () => {
const config: StationConfig = {
stationId: '127',
stationName: 'Times Sq-42 St',
showNorth: true,
showSouth: false,
selectedLines: ['A', 'C', 'E'],
}
expect(config.stationId).toBe('127')
expect(config.stationName).toBe('Times Sq-42 St')
expect(config.showNorth).toBe(true)
expect(config.showSouth).toBe(false)
expect(config.selectedLines).toEqual(['A', 'C', 'E'])
})
it('AppConfig has correct shape', () => {
const appConfig: AppConfig = {
version: CONFIG_VERSION,
stations: [
{
stationId: '127',
stationName: 'Times Sq-42 St',
showNorth: true,
showSouth: true,
selectedLines: ['A', 'C', 'E'],
},
],
}
expect(appConfig.version).toBe(CONFIG_VERSION)
expect(appConfig.stations).toHaveLength(1)
expect(appConfig.stations[0].stationId).toBe('127')
})
it('AppConfig can serialize to JSON and back', () => {
const appConfig: AppConfig = {
version: CONFIG_VERSION,
stations: [
{
stationId: '127',
stationName: 'Times Sq-42 St',
showNorth: true,
showSouth: false,
selectedLines: ['1', '2', '3'],
},
{
stationId: 'A27',
stationName: '42 St-Port Authority',
showNorth: false,
showSouth: true,
selectedLines: ['A', 'C', 'E'],
},
],
}
const json = JSON.stringify(appConfig)
const parsed = JSON.parse(json) as AppConfig
expect(parsed.version).toBe(appConfig.version)
expect(parsed.stations).toHaveLength(2)
expect(parsed.stations[0]).toEqual(appConfig.stations[0])
expect(parsed.stations[1]).toEqual(appConfig.stations[1])
})
})

View File

@@ -0,0 +1 @@
import '@testing-library/react'

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -12,7 +16,7 @@
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -20,9 +24,19 @@
}
],
"paths": {
"@/*": ["./*"]
"@/*": [
"./*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -0,0 +1,15 @@
export interface StationConfig {
id: string
stationId: string
stationName: string
showNorth: boolean
showSouth: boolean
selectedLines: string[]
}
export interface AppConfig {
version: number
stations: StationConfig[]
}
export const CONFIG_VERSION = 1

View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './'),
},
},
})

File diff suppressed because it is too large Load Diff