Initial commit: add core application files including README, server, frontend, and styles

This commit is contained in:
2026-06-01 13:33:22 +08:00
commit 412289ef01
6 changed files with 814 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# Bus SG App
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.
## Run
1. Ensure your API gateway is running at http://localhost:8067.
2. Start this app:
```bash
bun run start
```
3. Open:
- http://localhost:3000
## Features
- Dynamic endpoint explorer built from OpenAPI paths under /api/v1/*
- Bus-focused endpoint filtering (Bus and Passenger Volume tags)
- Quick bus-arrival panel with BusStopCode and optional ServiceNo
- JSON response viewer
- Arrival board card view when the response includes Services
## Config
Environment variables:
- `PORT` (default: `3000`)
- `API_ORIGIN` (default: `http://localhost:8067`)
Example:
```bash
API_ORIGIN=http://localhost:8067 PORT=3001 bun run start
```
+10
View File
@@ -0,0 +1,10 @@
{
"name": "bus-sg-app",
"version": "1.0.0",
"private": true,
"description": "Singapore bus transport app powered by OpenAPI gateway",
"type": "commonjs",
"scripts": {
"start": "bun server.js"
}
}
+281
View File
@@ -0,0 +1,281 @@
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
@@ -0,0 +1,88 @@
<!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>
+265
View File
@@ -0,0 +1,265 @@
:root {
--bg-0: #f7f4ef;
--bg-1: #efe6d7;
--ink: #1e1b16;
--ink-soft: #5e5648;
--accent: #d25f1f;
--accent-dark: #923d0f;
--line: #cbbca2;
--ok: #1e7a3f;
--card: rgba(255, 251, 243, 0.8);
--shadow: 0 14px 30px rgba(54, 39, 12, 0.15);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Space Grotesk", sans-serif;
color: var(--ink);
min-height: 100vh;
background: radial-gradient(circle at 15% 15%, #fff4dc 0%, transparent 35%),
radial-gradient(circle at 82% 0%, #ffd3b6 0%, transparent 32%),
linear-gradient(155deg, var(--bg-0), var(--bg-1));
overflow-x: hidden;
}
.bg-grid {
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.2;
background-image: linear-gradient(to right, #a88f63 1px, transparent 1px),
linear-gradient(to bottom, #a88f63 1px, transparent 1px);
background-size: 36px 36px;
}
.app-shell {
width: min(1100px, calc(100% - 2rem));
margin: 2rem auto 3rem;
position: relative;
z-index: 1;
}
.hero {
margin-bottom: 1rem;
animation: rise 420ms ease-out both;
}
.kicker {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.17em;
font-size: 0.76rem;
color: var(--accent-dark);
}
h1,
h2,
h3 {
font-family: "Chakra Petch", sans-serif;
margin: 0;
}
h1 {
font-size: clamp(2rem, 7vw, 3.6rem);
line-height: 1;
margin-top: 0.5rem;
}
.subtitle {
max-width: 60ch;
color: var(--ink-soft);
}
.panel {
background: var(--card);
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: var(--shadow);
padding: 1rem;
margin-top: 1rem;
backdrop-filter: blur(4px);
animation: rise 520ms ease-out both;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.7rem;
margin-bottom: 0.85rem;
}
.grid-two {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.92rem;
}
input,
select,
button {
font: inherit;
}
input,
select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.62rem 0.7rem;
background: #fffcf5;
}
input:focus,
select:focus {
outline: 2px solid #f5ac79;
outline-offset: 1px;
}
button {
border: 0;
border-radius: 12px;
padding: 0.6rem 0.9rem;
cursor: pointer;
transition: transform 140ms ease, filter 140ms ease;
}
button:hover {
transform: translateY(-1px);
filter: brightness(1.02);
}
.btn-accent {
color: #fff;
background: linear-gradient(135deg, #d25f1f, #a94411);
}
.btn-ghost {
background: #f2e7d0;
color: #3a3024;
}
.params-head {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0.9rem 0 0.5rem;
}
.params-list {
display: grid;
gap: 0.55rem;
}
.param-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.55rem;
}
.remove-param {
background: #efe0d2;
color: #552f19;
}
.hint {
color: var(--ink-soft);
font-size: 0.86rem;
}
.status-pill {
font-size: 0.8rem;
padding: 0.28rem 0.55rem;
border-radius: 999px;
background: #efe8da;
color: #5f5140;
}
.status-pill.ok {
background: #d6f2df;
color: var(--ok);
}
.status-pill.error {
background: #f7dbd7;
color: #8f2424;
}
.arrival-board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 0.7rem;
margin-bottom: 0.9rem;
}
.arrival-card {
border: 1px solid #d9c6a8;
background: #fff8ea;
border-radius: 14px;
padding: 0.8rem;
}
.arrival-card h4 {
margin: 0;
font-size: 1.15rem;
}
.arrival-line {
margin-top: 0.35rem;
font-size: 0.9rem;
color: #4b4134;
}
.response {
margin: 0;
background: #201d18;
color: #f5e9cf;
border-radius: 14px;
padding: 0.8rem;
min-height: 170px;
max-height: 420px;
overflow: auto;
font-size: 0.84rem;
line-height: 1.45;
}
.hidden {
display: none;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 760px) {
.app-shell {
width: min(1100px, calc(100% - 1rem));
margin-top: 1rem;
}
.grid-two,
.param-row {
grid-template-columns: 1fr;
}
.panel-header {
flex-wrap: wrap;
}
}
+129
View File
@@ -0,0 +1,129 @@
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}`);
});