feat: initialize SvelteKit project with bus API explorer

- Add main application styles in app.css
- Create basic HTML structure in app.html
- Implement server-side hooks for API proxying in hooks.server.js
- Set up layout component in +layout.svelte
- Develop main page with endpoint explorer and arrival check functionality in +page.svelte
- Configure SvelteKit with adapter in svelte.config.js
- Set up Vite configuration for SvelteKit in vite.config.js
This commit is contained in:
2026-06-01 13:49:17 +08:00
parent 412289ef01
commit 89b7459cef
14 changed files with 686 additions and 505 deletions
+26
View File
@@ -0,0 +1,26 @@
# Dependencies
node_modules/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Environment variables
.env
.env.*
!.env.example
# Build artifacts
coverage/
dist/
build/
.svelte-kit/
# OS files
.DS_Store
# Editor settings
.vscode/
.idea/
+21 -5
View File
@@ -4,21 +4,36 @@ A lightweight bus transport web app that uses the gateway OpenAPI contract at:
- http://localhost:8067/openapi.json
The frontend dynamically loads transport endpoints from the OpenAPI spec and calls them through a local proxy server.
The frontend is built with SvelteKit. It dynamically loads transport endpoints from the OpenAPI spec and calls them through a SvelteKit server hook proxy.
## Run
1. Ensure your API gateway is running at http://localhost:8067.
2. Start this app:
2. Install dependencies:
```bash
bun run start
bun install
```
3. Open:
3. Start this app in development:
```bash
bun run dev
```
4. Open:
- http://localhost:3000
## Build and preview
```bash
bun run build
bun run preview
```
You can also use `bun run start` to run preview mode.
## Features
- Dynamic endpoint explorer built from OpenAPI paths under /api/v1/*
@@ -26,6 +41,7 @@ bun run start
- Quick bus-arrival panel with BusStopCode and optional ServiceNo
- JSON response viewer
- Arrival board card view when the response includes Services
- Server-side proxying for `/api/*` and `/openapi.json`
## Config
@@ -37,5 +53,5 @@ Environment variables:
Example:
```bash
API_ORIGIN=http://localhost:8067 PORT=3001 bun run start
API_ORIGIN=http://localhost:8067 PORT=3001 bun run dev -- --port 3001
```
+210
View File
@@ -0,0 +1,210 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "bus-sg-app",
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.61.1",
"svelte": "^5.56.0",
"vite": "^7.0.0",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.61.0", "", { "os": "android", "cpu": "arm" }, "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.61.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.61.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.61.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.61.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.61.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.61.0", "", { "os": "linux", "cpu": "arm" }, "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.61.0", "", { "os": "linux", "cpu": "arm" }, "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.61.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.61.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.61.0", "", { "os": "linux", "cpu": "none" }, "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.61.0", "", { "os": "linux", "cpu": "none" }, "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.61.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.61.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.61.0", "", { "os": "linux", "cpu": "none" }, "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.61.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.61.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.61.0", "", { "os": "linux", "cpu": "x64" }, "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.61.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.61.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.61.0", "", { "os": "none", "cpu": "arm64" }, "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.61.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.61.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.61.0", "", { "os": "win32", "cpu": "x64" }, "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.61.0", "", { "os": "win32", "cpu": "x64" }, "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="],
"@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ=="],
"@sveltejs/kit": ["@sveltejs/kit@2.61.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-Ny8s1SR1TyQS2hD2Rvw0XKzU2Nw1eUF52dTb6T2bdcgz7wSC+Nyb5IwjWYlR4b2dvbbR5NJDiQwHg3rnNseghg=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.1.2", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
"esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.2.9", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"rollup": ["rollup@4.61.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.61.0", "@rollup/rollup-android-arm64": "4.61.0", "@rollup/rollup-darwin-arm64": "4.61.0", "@rollup/rollup-darwin-x64": "4.61.0", "@rollup/rollup-freebsd-arm64": "4.61.0", "@rollup/rollup-freebsd-x64": "4.61.0", "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", "@rollup/rollup-linux-arm-musleabihf": "4.61.0", "@rollup/rollup-linux-arm64-gnu": "4.61.0", "@rollup/rollup-linux-arm64-musl": "4.61.0", "@rollup/rollup-linux-loong64-gnu": "4.61.0", "@rollup/rollup-linux-loong64-musl": "4.61.0", "@rollup/rollup-linux-ppc64-gnu": "4.61.0", "@rollup/rollup-linux-ppc64-musl": "4.61.0", "@rollup/rollup-linux-riscv64-gnu": "4.61.0", "@rollup/rollup-linux-riscv64-musl": "4.61.0", "@rollup/rollup-linux-s390x-gnu": "4.61.0", "@rollup/rollup-linux-x64-gnu": "4.61.0", "@rollup/rollup-linux-x64-musl": "4.61.0", "@rollup/rollup-openbsd-x64": "4.61.0", "@rollup/rollup-openharmony-arm64": "4.61.0", "@rollup/rollup-win32-arm64-msvc": "4.61.0", "@rollup/rollup-win32-ia32-msvc": "4.61.0", "@rollup/rollup-win32-x64-gnu": "4.61.0", "@rollup/rollup-win32-x64-msvc": "4.61.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g=="],
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"svelte": ["svelte@5.56.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.9", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-kTXr26t1bchFp28ROrb957LtbujpBmBDibmqMGziVpUs7awBi96TGgX6SovrA8BNoEUDVRK2Fb9FkeYlGspoVg=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
}
}
+11 -2
View File
@@ -3,8 +3,17 @@
"version": "1.0.0",
"private": true,
"description": "Singapore bus transport app powered by OpenAPI gateway",
"type": "commonjs",
"type": "module",
"scripts": {
"start": "bun server.js"
"dev": "vite dev --host --port 3000",
"build": "vite build",
"preview": "vite preview --host --port 3000",
"start": "vite preview --host --port 3000"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.61.1",
"svelte": "^5.56.0",
"vite": "^7.0.0"
}
}
-281
View File
@@ -1,281 +0,0 @@
const fallbackEndpointOptions = [
{ label: "Bus Arrivals", path: "/api/v1/bus-arrival", method: "GET" },
{ label: "Bus Services", path: "/api/v1/bus-services", method: "GET" },
{ label: "Bus Routes", path: "/api/v1/bus-routes", method: "GET" },
{ label: "Bus Stops", path: "/api/v1/bus-stops", method: "GET" },
{ label: "Planned Bus Routes", path: "/api/v1/planned-bus-routes", method: "GET" },
{ label: "Passenger Volume (Bus)", path: "/api/v1/passenger-volume/bus", method: "GET" },
{ label: "Passenger Volume (OD Bus)", path: "/api/v1/passenger-volume/od-bus", method: "GET" }
];
let endpointOptions = [...fallbackEndpointOptions];
const endpointEl = document.querySelector("#endpoint");
const methodEl = document.querySelector("#method");
const paramsEl = document.querySelector("#params");
const responseEl = document.querySelector("#response");
const statusPillEl = document.querySelector("#status-pill");
const arrivalBoardEl = document.querySelector("#arrival-board");
const addParamBtn = document.querySelector("#add-param");
const sendRequestBtn = document.querySelector("#send-request");
const runArrivalBtn = document.querySelector("#run-arrival");
const busStopCodeEl = document.querySelector("#bus-stop-code");
const serviceNoEl = document.querySelector("#service-no");
function setStatus(text, state) {
statusPillEl.textContent = text;
statusPillEl.classList.remove("ok", "error");
if (state) {
statusPillEl.classList.add(state);
}
}
function addParamRow(key = "", value = "") {
const template = document.querySelector("#param-template");
const row = template.content.firstElementChild.cloneNode(true);
const keyEl = row.querySelector(".param-key");
const valueEl = row.querySelector(".param-value");
const removeBtn = row.querySelector(".remove-param");
keyEl.value = key;
valueEl.value = value;
removeBtn.addEventListener("click", () => row.remove());
paramsEl.appendChild(row);
}
function getSelectedEndpoint() {
const index = Number(endpointEl.value);
return endpointOptions[index] || endpointOptions[0];
}
function titleizePath(path) {
return path
.replace("/api/v1/", "")
.split("/")
.map((part) => part.replace(/-/g, " "))
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" - ");
}
async function loadOpenApiEndpoints() {
try {
const response = await fetch("/openapi.json");
if (!response.ok) {
return;
}
const spec = await response.json();
const paths = spec && spec.paths ? spec.paths : {};
const discovered = [];
Object.entries(paths).forEach(([path, methods]) => {
if (!path.startsWith("/api/v1/")) {
return;
}
const getMethod = methods && methods.get;
if (!getMethod) {
return;
}
const tags = Array.isArray(getMethod.tags) ? getMethod.tags.join(" ").toLowerCase() : "";
const include = tags.includes("bus") || tags.includes("passenger") || path.includes("bus");
if (!include) {
return;
}
discovered.push({
label: getMethod.summary || titleizePath(path),
path,
method: "GET"
});
});
if (discovered.length > 0) {
endpointOptions = discovered.sort((a, b) => a.path.localeCompare(b.path));
}
} catch {
// Keep fallback endpoint options when OpenAPI loading fails.
}
}
function collectParams() {
const rows = [...paramsEl.querySelectorAll(".param-row")];
const params = new URLSearchParams();
rows.forEach((row) => {
const key = row.querySelector(".param-key").value.trim();
const value = row.querySelector(".param-value").value.trim();
if (key) {
params.append(key, value);
}
});
return params;
}
function minutesUntil(isoValue) {
if (!isoValue) {
return "n/a";
}
const target = new Date(isoValue);
if (Number.isNaN(target.getTime())) {
return "n/a";
}
const deltaMs = target.getTime() - Date.now();
const mins = Math.max(0, Math.round(deltaMs / 60000));
return `${mins} min`;
}
function renderArrivalBoard(payload) {
arrivalBoardEl.innerHTML = "";
const services = payload && payload.Services;
if (!Array.isArray(services) || services.length === 0) {
arrivalBoardEl.classList.add("hidden");
return;
}
services.slice(0, 12).forEach((svc) => {
const card = document.createElement("article");
card.className = "arrival-card";
const title = document.createElement("h4");
title.textContent = `Service ${svc.ServiceNo || "?"}`;
const line1 = document.createElement("p");
line1.className = "arrival-line";
line1.textContent = `Next: ${minutesUntil(svc.NextBus && svc.NextBus.EstimatedArrival)}`;
const line2 = document.createElement("p");
line2.className = "arrival-line";
line2.textContent = `Following: ${minutesUntil(
svc.NextBus2 && svc.NextBus2.EstimatedArrival
)}`;
card.appendChild(title);
card.appendChild(line1);
card.appendChild(line2);
arrivalBoardEl.appendChild(card);
});
arrivalBoardEl.classList.remove("hidden");
}
async function callEndpoint(path, params) {
const query = params.toString();
const url = query ? `${path}?${query}` : path;
setStatus("Loading", "");
responseEl.textContent = "Loading...";
try {
const response = await fetch(url);
const text = await response.text();
let payload;
try {
payload = JSON.parse(text);
} catch {
payload = { raw: text };
}
responseEl.textContent = JSON.stringify(payload, null, 2);
if (response.ok) {
setStatus(`OK ${response.status}`, "ok");
} else {
setStatus(`Error ${response.status}`, "error");
}
renderArrivalBoard(payload);
} catch (error) {
responseEl.textContent = JSON.stringify(
{
error: "Request failed",
message: error.message
},
null,
2
);
setStatus("Network Error", "error");
arrivalBoardEl.classList.add("hidden");
}
}
function seedDefaultParams() {
paramsEl.innerHTML = "";
const selected = getSelectedEndpoint();
if (selected.path === "/api/v1/bus-arrival") {
addParamRow("BusStopCode", "83139");
addParamRow("ServiceNo", "");
return;
}
addParamRow("$skip", "0");
addParamRow("$top", "10");
}
function initEndpointOptions() {
endpointEl.innerHTML = "";
endpointOptions.forEach((item, index) => {
const opt = document.createElement("option");
opt.value = String(index);
opt.textContent = `${item.label} (${item.path})`;
endpointEl.appendChild(opt);
});
endpointEl.value = "0";
methodEl.value = endpointOptions[0].method;
seedDefaultParams();
}
addParamBtn.addEventListener("click", () => addParamRow());
endpointEl.addEventListener("change", () => {
const selected = getSelectedEndpoint();
methodEl.value = selected.method;
seedDefaultParams();
});
sendRequestBtn.addEventListener("click", async () => {
const selected = getSelectedEndpoint();
const params = collectParams();
await callEndpoint(selected.path, params);
});
runArrivalBtn.addEventListener("click", async () => {
const params = new URLSearchParams();
const busStopCode = busStopCodeEl.value.trim();
const serviceNo = serviceNoEl.value.trim();
if (!busStopCode) {
responseEl.textContent = JSON.stringify(
{ error: "BusStopCode is required for arrival checks." },
null,
2
);
setStatus("Missing BusStopCode", "error");
return;
}
params.append("BusStopCode", busStopCode);
if (serviceNo) {
params.append("ServiceNo", serviceNo);
}
await callEndpoint("/api/v1/bus-arrival", params);
});
async function init() {
await loadOpenApiEndpoints();
initEndpointOptions();
}
init();
-88
View File
@@ -1,88 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bus SG Control Deck</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Chakra+Petch:wght@600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="bg-grid"></div>
<main class="app-shell">
<header class="hero">
<p class="kicker">Singapore Transit</p>
<h1>Bus Control Deck</h1>
<p class="subtitle">
Live explorer for the bus gateway API at <span id="gateway-origin">localhost:8067</span>.
</p>
</header>
<section class="panel query-panel">
<div class="panel-header">
<h2>Endpoint Explorer</h2>
<button id="send-request" class="btn-accent">Run Request</button>
</div>
<div class="grid-two">
<label>
Endpoint
<select id="endpoint"></select>
</label>
<label>
Request Method
<input id="method" value="GET" readonly />
</label>
</div>
<div class="params-head">
<h3>Query Params</h3>
<button id="add-param" class="btn-ghost">Add Param</button>
</div>
<div id="params" class="params-list"></div>
</section>
<section class="panel quick-panel">
<div class="panel-header">
<h2>Quick Arrival Check</h2>
<button id="run-arrival" class="btn-accent">Get Arrivals</button>
</div>
<div class="grid-two">
<label>
Bus Stop Code
<input id="bus-stop-code" placeholder="e.g. 83139" />
</label>
<label>
Service No (optional)
<input id="service-no" placeholder="e.g. 14" />
</label>
</div>
<p class="hint">Uses /api/v1/bus-arrival with DataMall-style query parameters.</p>
</section>
<section class="panel">
<div class="panel-header">
<h2>Response</h2>
<span id="status-pill" class="status-pill">Idle</span>
</div>
<div id="arrival-board" class="arrival-board hidden"></div>
<pre id="response" class="response">No request yet.</pre>
</section>
</main>
<template id="param-template">
<div class="param-row">
<input class="param-key" placeholder="Key" />
<input class="param-value" placeholder="Value" />
<button class="remove-param" type="button">Remove</button>
</div>
</template>
<script src="/app.js"></script>
</body>
</html>
-129
View File
@@ -1,129 +0,0 @@
const fs = require("fs");
const path = require("path");
const http = require("http");
const https = require("https");
const { URL } = require("url");
const PORT = Number(process.env.PORT || 3000);
const API_ORIGIN = process.env.API_ORIGIN || "http://localhost:8067";
const PUBLIC_DIR = path.join(__dirname, "public");
const MIME_TYPES = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".ico": "image/x-icon"
};
function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload);
res.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body)
});
res.end(body);
}
function resolveFilePath(urlPathname) {
const relative = urlPathname === "/" ? "/index.html" : urlPathname;
const safePath = path.normalize(relative).replace(/^\.\.(\/|\\|$)/, "");
return path.join(PUBLIC_DIR, safePath);
}
function serveStatic(req, res, pathname) {
const filePath = resolveFilePath(pathname);
if (!filePath.startsWith(PUBLIC_DIR)) {
sendJson(res, 400, { error: "Invalid path" });
return;
}
fs.stat(filePath, (statErr, stats) => {
if (statErr || !stats.isFile()) {
sendJson(res, 404, { error: "Not found" });
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || "application/octet-stream";
res.writeHead(200, { "Content-Type": contentType });
fs.createReadStream(filePath).pipe(res);
});
}
function proxyApi(req, res, parsedUrl) {
const targetUrl = new URL(parsedUrl.pathname + parsedUrl.search, API_ORIGIN);
const isHttps = targetUrl.protocol === "https:";
const transport = isHttps ? https : http;
const options = {
protocol: targetUrl.protocol,
hostname: targetUrl.hostname,
port: targetUrl.port || (isHttps ? 443 : 80),
method: req.method,
path: targetUrl.pathname + targetUrl.search,
headers: {
...req.headers,
host: targetUrl.host
}
};
const proxyReq = transport.request(options, (proxyRes) => {
res.writeHead(proxyRes.statusCode || 502, {
...proxyRes.headers,
"Access-Control-Allow-Origin": "*"
});
proxyRes.pipe(res);
});
proxyReq.on("error", (error) => {
sendJson(res, 502, {
error: "Gateway request failed",
message: error.message
});
});
if (req.method === "GET" || req.method === "HEAD") {
proxyReq.end();
return;
}
req.pipe(proxyReq);
}
const server = http.createServer((req, res) => {
const parsedUrl = new URL(req.url || "/", `http://${req.headers.host}`);
if (parsedUrl.pathname.startsWith("/api/")) {
proxyApi(req, res, parsedUrl);
return;
}
if (parsedUrl.pathname === "/openapi.json") {
const targetUrl = new URL("/openapi.json", API_ORIGIN);
const transport = targetUrl.protocol === "https:" ? https : http;
const proxyReq = transport.request(targetUrl, (proxyRes) => {
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
proxyRes.pipe(res);
});
proxyReq.on("error", () => sendJson(res, 502, { error: "Cannot fetch OpenAPI spec" }));
proxyReq.end();
return;
}
if (req.method === "GET") {
serveStatic(req, res, parsedUrl.pathname);
return;
}
sendJson(res, 405, { error: "Method not allowed" });
});
server.listen(PORT, () => {
console.log(`Bus SG app is running at http://localhost:${PORT}`);
console.log(`Proxying API requests to ${API_ORIGIN}`);
});
+3
View File
@@ -1,3 +1,5 @@
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Chakra+Petch:wght@600;700&display=swap");
:root {
--bg-0: #f7f4ef;
--bg-1: #efe6d7;
@@ -242,6 +244,7 @@ button:hover {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
const API_ORIGIN = process.env.API_ORIGIN || "http://localhost:8067";
function buildTargetUrl(pathname, search) {
if (pathname === "/openapi.json") {
return new URL("/openapi.json", API_ORIGIN);
}
return new URL(`${pathname}${search}`, API_ORIGIN);
}
export async function handle({ event, resolve }) {
const { pathname, search } = event.url;
const shouldProxy = pathname.startsWith("/api/") || pathname === "/openapi.json";
if (!shouldProxy) {
return resolve(event);
}
const targetUrl = buildTargetUrl(pathname, search);
try {
const headers = new Headers(event.request.headers);
headers.set("host", targetUrl.host);
let body;
if (event.request.method !== "GET" && event.request.method !== "HEAD") {
body = await event.request.arrayBuffer();
}
const upstream = await fetch(targetUrl, {
method: event.request.method,
headers,
body
});
const responseHeaders = new Headers(upstream.headers);
if (pathname.startsWith("/api/")) {
responseHeaders.set("Access-Control-Allow-Origin", "*");
}
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders
});
} catch (error) {
return Response.json(
{
error: pathname === "/openapi.json" ? "Cannot fetch OpenAPI spec" : "Gateway request failed",
message: error?.message || "Unknown error"
},
{ status: 502 }
);
}
}
+5
View File
@@ -0,0 +1,5 @@
<script>
import "../app.css";
</script>
<slot />
+328
View File
@@ -0,0 +1,328 @@
<script>
import { onMount } from "svelte";
const fallbackEndpointOptions = [
{ label: "Bus Arrivals", path: "/api/v1/bus-arrival", method: "GET" },
{ label: "Bus Services", path: "/api/v1/bus-services", method: "GET" },
{ label: "Bus Routes", path: "/api/v1/bus-routes", method: "GET" },
{ label: "Bus Stops", path: "/api/v1/bus-stops", method: "GET" },
{ label: "Planned Bus Routes", path: "/api/v1/planned-bus-routes", method: "GET" },
{ label: "Passenger Volume (Bus)", path: "/api/v1/passenger-volume/bus", method: "GET" },
{
label: "Passenger Volume (OD Bus)",
path: "/api/v1/passenger-volume/od-bus",
method: "GET"
}
];
let endpointOptions = [...fallbackEndpointOptions];
let selectedEndpointIndex = 0;
let method = "GET";
let params = [];
let responseText = "No request yet.";
let statusText = "Idle";
let statusState = "";
let services = [];
let busStopCode = "83139";
let serviceNo = "";
function getSelectedEndpoint() {
return endpointOptions[selectedEndpointIndex] || endpointOptions[0];
}
function setStatus(text, state = "") {
statusText = text;
statusState = state;
}
function titleizePath(path) {
return path
.replace("/api/v1/", "")
.split("/")
.map((part) => part.replace(/-/g, " "))
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" - ");
}
async function loadOpenApiEndpoints() {
try {
const response = await fetch("/openapi.json");
if (!response.ok) {
return;
}
const spec = await response.json();
const paths = spec && spec.paths ? spec.paths : {};
const discovered = [];
Object.entries(paths).forEach(([path, methods]) => {
if (!path.startsWith("/api/v1/")) {
return;
}
const getMethod = methods && methods.get;
if (!getMethod) {
return;
}
const tags = Array.isArray(getMethod.tags) ? getMethod.tags.join(" ").toLowerCase() : "";
const include = tags.includes("bus") || tags.includes("passenger") || path.includes("bus");
if (!include) {
return;
}
discovered.push({
label: getMethod.summary || titleizePath(path),
path,
method: "GET"
});
});
if (discovered.length > 0) {
endpointOptions = discovered.sort((a, b) => a.path.localeCompare(b.path));
}
} catch {
// Keep fallback endpoint options when OpenAPI loading fails.
}
}
function seedDefaultParams() {
const selected = getSelectedEndpoint();
if (selected.path === "/api/v1/bus-arrival") {
params = [
{ key: "BusStopCode", value: "83139" },
{ key: "ServiceNo", value: "" }
];
return;
}
params = [
{ key: "$skip", value: "0" },
{ key: "$top", value: "10" }
];
}
function addParamRow() {
params = [...params, { key: "", value: "" }];
}
function removeParamRow(index) {
params = params.filter((_, i) => i !== index);
}
function updateParam(index, field, value) {
params = params.map((param, i) => {
if (i !== index) {
return param;
}
return { ...param, [field]: value };
});
}
function collectParams() {
const search = new URLSearchParams();
params.forEach((param) => {
const key = param.key.trim();
const value = param.value.trim();
if (key) {
search.append(key, value);
}
});
return search;
}
function minutesUntil(isoValue) {
if (!isoValue) {
return "n/a";
}
const target = new Date(isoValue);
if (Number.isNaN(target.getTime())) {
return "n/a";
}
const deltaMs = target.getTime() - Date.now();
const mins = Math.max(0, Math.round(deltaMs / 60000));
return `${mins} min`;
}
async function callEndpoint(path, queryParams) {
const query = queryParams.toString();
const url = query ? `${path}?${query}` : path;
setStatus("Loading");
responseText = "Loading...";
try {
const response = await fetch(url);
const text = await response.text();
let payload;
try {
payload = JSON.parse(text);
} catch {
payload = { raw: text };
}
responseText = JSON.stringify(payload, null, 2);
if (response.ok) {
setStatus(`OK ${response.status}`, "ok");
} else {
setStatus(`Error ${response.status}`, "error");
}
services = Array.isArray(payload?.Services) ? payload.Services.slice(0, 12) : [];
} catch (error) {
responseText = JSON.stringify(
{
error: "Request failed",
message: error?.message || "Unknown error"
},
null,
2
);
setStatus("Network Error", "error");
services = [];
}
}
async function runRequest() {
const selected = getSelectedEndpoint();
await callEndpoint(selected.path, collectParams());
}
async function runArrival() {
if (!busStopCode.trim()) {
responseText = JSON.stringify({ error: "BusStopCode is required for arrival checks." }, null, 2);
setStatus("Missing BusStopCode", "error");
return;
}
const search = new URLSearchParams();
search.append("BusStopCode", busStopCode.trim());
if (serviceNo.trim()) {
search.append("ServiceNo", serviceNo.trim());
}
await callEndpoint("/api/v1/bus-arrival", search);
}
function onEndpointChange() {
method = getSelectedEndpoint().method;
seedDefaultParams();
}
onMount(async () => {
await loadOpenApiEndpoints();
selectedEndpointIndex = 0;
method = getSelectedEndpoint().method;
seedDefaultParams();
});
</script>
<svelte:head>
<title>Bus SG Control Deck</title>
</svelte:head>
<div class="bg-grid"></div>
<main class="app-shell">
<header class="hero">
<p class="kicker">Singapore Transit</p>
<h1>Bus Control Deck</h1>
<p class="subtitle">
Live explorer for the bus gateway API at
<span>localhost:8067</span>.
</p>
</header>
<section class="panel query-panel">
<div class="panel-header">
<h2>Endpoint Explorer</h2>
<button class="btn-accent" type="button" on:click={runRequest}>Run Request</button>
</div>
<div class="grid-two">
<label>
Endpoint
<select bind:value={selectedEndpointIndex} on:change={onEndpointChange}>
{#each endpointOptions as item, index}
<option value={index}>{item.label} ({item.path})</option>
{/each}
</select>
</label>
<label>
Request Method
<input value={method} readonly />
</label>
</div>
<div class="params-head">
<h3>Query Params</h3>
<button class="btn-ghost" type="button" on:click={addParamRow}>Add Param</button>
</div>
<div class="params-list">
{#each params as param, index}
<div class="param-row">
<input
class="param-key"
placeholder="Key"
value={param.key}
on:input={(event) => updateParam(index, "key", event.currentTarget.value)}
/>
<input
class="param-value"
placeholder="Value"
value={param.value}
on:input={(event) => updateParam(index, "value", event.currentTarget.value)}
/>
<button class="remove-param" type="button" on:click={() => removeParamRow(index)}>
Remove
</button>
</div>
{/each}
</div>
</section>
<section class="panel quick-panel">
<div class="panel-header">
<h2>Quick Arrival Check</h2>
<button class="btn-accent" type="button" on:click={runArrival}>Get Arrivals</button>
</div>
<div class="grid-two">
<label>
Bus Stop Code
<input bind:value={busStopCode} placeholder="e.g. 83139" />
</label>
<label>
Service No (optional)
<input bind:value={serviceNo} placeholder="e.g. 14" />
</label>
</div>
<p class="hint">Uses /api/v1/bus-arrival with DataMall-style query parameters.</p>
</section>
<section class="panel">
<div class="panel-header">
<h2>Response</h2>
<span class={`status-pill ${statusState}`}>{statusText}</span>
</div>
<div class={`arrival-board ${services.length > 0 ? "" : "hidden"}`}>
{#each services as service}
<article class="arrival-card">
<h4>Service {service.ServiceNo || "?"}</h4>
<p class="arrival-line">Next: {minutesUntil(service?.NextBus?.EstimatedArrival)}</p>
<p class="arrival-line">Following: {minutesUntil(service?.NextBus2?.EstimatedArrival)}</p>
</article>
{/each}
</div>
<pre class="response">{responseText}</pre>
</section>
</main>
+10
View File
@@ -0,0 +1,10 @@
import adapter from "@sveltejs/adapter-auto";
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
}
};
export default config;
+6
View File
@@ -0,0 +1,6 @@
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [sveltekit()]
});