feat: rework to use next and host from single dockerfile
This commit is contained in:
@@ -1,2 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
->
|
||||
</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">
|
||||
->
|
||||
</span>
|
||||
</h2>
|
||||
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
|
||||
Learn about Next.js in an interactive course with 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">
|
||||
->
|
||||
</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">
|
||||
->
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
106
mta-sign-ui/components/Header.tsx
Normal file
106
mta-sign-ui/components/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
mta-sign-ui/components/StationSelector.tsx
Normal file
108
mta-sign-ui/components/StationSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
42
mta-sign-ui/components/trains/DirectionCard.tsx
Normal file
42
mta-sign-ui/components/trains/DirectionCard.tsx
Normal 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
|
||||
272
mta-sign-ui/components/trains/StationCard.tsx
Normal file
272
mta-sign-ui/components/trains/StationCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
mta-sign-ui/components/trains/TrainLine.tsx
Normal file
43
mta-sign-ui/components/trains/TrainLine.tsx
Normal 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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
3
mta-sign-ui/next-env.d.ts
vendored
3
mta-sign-ui/next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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*',
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
4734
mta-sign-ui/package-lock.json
generated
4734
mta-sign-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
5742
mta-sign-ui/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
74
mta-sign-ui/tests/config.test.ts
Normal file
74
mta-sign-ui/tests/config.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
1
mta-sign-ui/tests/setup.ts
Normal file
1
mta-sign-ui/tests/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/react'
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
15
mta-sign-ui/types/config.ts
Normal file
15
mta-sign-ui/types/config.ts
Normal 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
|
||||
17
mta-sign-ui/vitest.config.ts
Normal file
17
mta-sign-ui/vitest.config.ts
Normal 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
Reference in New Issue
Block a user