13 Commits

21 changed files with 588 additions and 329 deletions

54
package-lock.json generated
View File

@@ -13,7 +13,7 @@
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.0.2", "@eslint/compat": "^2.0.2",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@owlboard/owlboard-ts": "^3.0.0-dev.20260319T2004", "@owlboard/owlboard-ts": "^3.0.0-dev.202603302258",
"@playwright/test": "^1.58.1", "@playwright/test": "^1.58.1",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2", "@sveltejs/kit": "^2.50.2",
@@ -780,20 +780,21 @@
} }
}, },
"node_modules/@owlboard/api-schema-types": { "node_modules/@owlboard/api-schema-types": {
"version": "3.0.1-alpha3", "version": "3.0.2-alpha3",
"resolved": "https://git.fjla.uk/api/packages/OwlBoard/npm/%40owlboard%2Fapi-schema-types/-/3.0.1-alpha3/api-schema-types-3.0.1-alpha3.tgz", "resolved": "https://git.fjla.uk/api/packages/OwlBoard/npm/%40owlboard%2Fapi-schema-types/-/3.0.2-alpha3/api-schema-types-3.0.2-alpha3.tgz",
"integrity": "sha512-5CVm1k/C++/VrtAw4NkvclDunH+RmYLnDZZMSWTM1mm+WlEVnmD+MVnTgC/FhcsAmsNHV8swm66RCqkCuhbOnA==", "integrity": "sha512-3NFP21QdSfjziwlGQixlNnUWC55HlKZGyWANIOimWu0FZejWQWExJiaAVfb6m3Sbv+zvQMu3B8mzcMCcGadZCQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@owlboard/owlboard-ts": { "node_modules/@owlboard/owlboard-ts": {
"version": "3.0.0-dev.20260319T2004", "version": "3.0.0-dev.202603302258",
"resolved": "https://git.fjla.uk/api/packages/OwlBoard/npm/%40owlboard%2Fowlboard-ts/-/3.0.0-dev.20260319T2004/owlboard-ts-3.0.0-dev.20260319t2004.tgz", "resolved": "https://git.fjla.uk/api/packages/OwlBoard/npm/%40owlboard%2Fowlboard-ts/-/3.0.0-dev.202603302258/owlboard-ts-3.0.0-dev.202603302258.tgz",
"integrity": "sha512-pphq1/l/8eOH4C0O7ocwBOUzt0HkCWGUlhy1itzKnQbmog7oPUEdyaxzS4Evw8onLsxZwkyqsLAyK7okYi+4XA==", "integrity": "sha512-uJRoahtqnkmkPg8QsIWhtxhAibWY8xG25fhjzzdDFNE/2+uCj1ev5iD+t0O1dkE7ic3yTcMZqDitEspW216bjw==",
"dev": true, "dev": true,
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@owlboard/api-schema-types": "^3.0.1-alpha3" "@owlboard/api-schema-types": "^3.0.2-alpha3",
"latlon-geohash": "^2.0.0"
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
@@ -1558,9 +1559,9 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1881,9 +1882,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2664,6 +2665,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/latlon-geohash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/latlon-geohash/-/latlon-geohash-2.0.0.tgz",
"integrity": "sha512-OKBswTwrvTdtenV+9C9euBmvgGuqyjJNAzpQCarRz1m8/pYD2nz9fKkXmLs2S3jeXaLi3Ry76twQplKKUlgS/g==",
"dev": true,
"license": "MIT"
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -2901,9 +2909,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3028,9 +3036,9 @@
} }
}, },
"node_modules/postcss-load-config/node_modules/yaml": { "node_modules/postcss-load-config/node_modules/yaml": {
"version": "1.10.2", "version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
@@ -3843,9 +3851,9 @@
} }
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.2", "version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"optional": true, "optional": true,

View File

@@ -19,7 +19,7 @@
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.0.2", "@eslint/compat": "^2.0.2",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@owlboard/owlboard-ts": "^3.0.0-dev.20260319T2004", "@owlboard/owlboard-ts": "^3.0.0-dev.202603302258",
"@playwright/test": "^1.58.1", "@playwright/test": "^1.58.1",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2", "@sveltejs/kit": "^2.50.2",

View File

@@ -19,47 +19,60 @@
{#if isLink} {#if isLink}
<a <a
{href} {href}
class="btn {color}" class="hitbox-wrapper"
target={isExternal ? '_blank' : undefined} target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined} rel={isExternal ? 'noopener noreferrer' : undefined}
{...rest} {...rest}
> ><span class="btn {color}">
{@render children?.()} {@render children?.()}
</span>
</a> </a>
{:else} {:else}
<button class="btn {color}" {onclick} {...rest}> <button class="hitbox-wrapper" {onclick} {...rest}>
{@render children?.()} <span class="btn {color}">{@render children?.()}</span>
</button> </button>
{/if} {/if}
<style> <style>
.btn { .hitbox-wrapper {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0.4rem 1.2rem;
width: fit-content;
min-width: 90px;
min-height: 48px; min-height: 48px;
min-width: 48px;
appearance: none;
background: transparent;
border: none; border: none;
border-radius: 20px; padding: 0 4px;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
font-family: 'URW Gothic', sans-serif;
letter-spacing: 0.05ch;
font-size: 1rem;
font-weight: 600;
transition: all 0.2s;
box-shadow: var(--shadow-std);
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
touch-action: manipulation; }
.btn {
display: flex;
align-items: center;
justify-content: center;
width: fit-content;
flex-shrink: 0;
padding: 0 1.2rem;
min-width: 90px;
height: 36px;
border-radius: 20px;
border: none;
box-shadow: var(--shadow-small);
font-family: 'URW Gothic', sans-serif;
font-size: 0.93rem;
font-weight: 400;
letter-spacing: 0.05ch;
transition:
all 0.1s ease,
box-shadow 0.2s;
} }
.accent { .accent {
background-color: var(--color-accent); background-color: var(--color-accent);
color: var(--color-title); color: var(--color-title);
font-weight: 600;
} }
.brand { .brand {
@@ -72,11 +85,11 @@
color: var(--color-title); color: var(--color-title);
} }
.btn:hover { .hitbox-wrapper:hover .btn {
filter: brightness(1.5); filter: brightness(1.5);
} }
.btn.active { .hitbox-wrapper:active .btn {
transform: scale(0.98); transform: scale(0.98);
} }
</style> </style>

View File

@@ -4,7 +4,8 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { LOCATIONS } from '$lib/locations-object.svelte.ts'; import { LOCATIONS } from '$lib/locations-object.svelte';
import type { ApiLocationFilter } from '@owlboard/api-schema-types';
let { value = $bindable() } = $props(); let { value = $bindable() } = $props();
@@ -26,13 +27,15 @@
return LOCATIONS.data return LOCATIONS.data
.filter((r) => tokens.every((t) => r.s.includes(t))) .filter((r) => tokens.every((t) => r.s.includes(t)))
.sort((a, b) => { .sort((a, b) => {
// Check if query matches CRS // Priority One - Exact CRS Match
const aIsCrs = a.c?.toLowerCase() === lowerQuery; const aExactCrs = a.c?.toLowerCase() === lowerQuery;
const bIsCrs = b.c?.toLowerCase() === lowerQuery; const bExactCrs = b.c?.toLowerCase() === lowerQuery;
if (aExactCrs && !bExactCrs) return -1;
if (!aExactCrs && bExactCrs) return 1;
// Sort matching CRS first // Priority Two - 'Stations' with CRS
if (aIsCrs && !bIsCrs) return -1; if (!!a.c && !b.c) return -1;
if (!aIsCrs && bIsCrs) return 1; if (!a.c & !!b.c) return 1;
// Alphabetical Sort // Alphabetical Sort
return a.n.localeCompare(b.n); return a.n.localeCompare(b.n);
@@ -59,7 +62,7 @@
} }
}); });
function choose(loc: LocationRecord) { function choose(loc: ApiLocationFilter.LocationFilterObject) {
showResults = false; showResults = false;
selectedIndex = -1; selectedIndex = -1;
value = ''; value = '';
@@ -195,6 +198,7 @@
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 700; font-weight: 700;
text-align: right; text-align: right;
text-transform: capitalize;
} }
.tiploc { .tiploc {

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
let isNotLondon = $state(false);
let londonZone = $state('Greenwich Mean Time');
onMount(() => {
const userTZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
isNotLondon = userTZ !== 'Europe/London';
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/London',
timeZoneName: 'long'
}).formatToParts(new Date());
londonZone = parts.find((p) => p.type === 'timeZoneName')?.value || 'UK Time';
});
</script>
{#if isNotLondon}
<div transition:slide={{duration: 300}} class="tzWarn"><p class="tzText">
All times are shown in <strong>{londonZone}</strong>
</p></div>
{/if}
<style>
.tzWarn {
display: flex;
justify-content: center;
width: 100%;
padding: 1rem 0 0 0;
}
.tzText {
width: 80%;
text-align: center;
margin: auto;
font-family: 'URW Gothic', sans-serif;
font-size: 1.2rem;
}
</style>

View File

@@ -0,0 +1,56 @@
<script lang="ts">
interface Props {
toc: string;
}
let { toc }: Props = $props();
let code = $derived(toc.toUpperCase());
</script>
<div class="toc-container {code}">
{code}
</div>
<style>
.toc-container {
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
font-weight: 800;
background-color: #333;
color: #fff;
}
.GW {
/* Great Western Railway */
background: #004225;
color: #e2e2e2;
}
.GR {
/* LNER */
background-color: #c00000;
color: #ffffff;
}
.VT {
/* Avanti West Coast */
background-color: #004354;
color: #ffffff;
}
.SW {
/* South Western Railway */
background-color: #2a3389;
color: #ffffff;
}
.XC {
/* CrossCountry */
background-color: #660000;
color: #e4d5b1;
}
</style>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { nearestStationsState } from '$lib/geohash.svelte';
const flipDuration = 300;
</script>
<BaseCard header={'Nearby Stations'}>
<div class="card-content">
{#if nearestStationsState.error && nearestStationsState.list.length === 0}
<p class="msg">{nearestStationsState.error}</p>
{:else if nearestStationsState.loading && nearestStationsState.list.length === 0}
<p class="msg">Locating stations...</p>
{:else}
<div class="stations-flex">
{#each nearestStationsState.list as station (station.c)}
<div
class="btn-container"
animate:flip={{ duration: flipDuration }}
in:fade|global={{ duration: 200 }}
>
<Button href={`/board?loc=${station.c}`}
><span class="stn-name">{station.n}</span></Button
>
</div>
{/each}
</div>
{/if}
</div>
</BaseCard>
<style>
.card-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
width: 90%;
min-height: 98px;
margin: auto;
padding: 10px 0 10px 0;
}
.stations-flex {
display: flex;
flex-wrap: wrap;
gap: 0.1rem 0.5rem;
justify-content: center;
align-items: flex-start;
}
.btn-container {
display: block;
width: fit-content;
will-change: transform;
}
.msg {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-title);
}
.stn-name {
text-transform: capitalize;
}
</style>

View File

@@ -16,7 +16,13 @@ function resetValues(): void {
<div class="card-content"> <div class="card-content">
<div class="textbox-container"> <div class="textbox-container">
<div class="textbox-item-wrapper"> <div class="textbox-item-wrapper">
<Textbox placeholder={"Code"} uppercase={true} type={'number'} max={9999} bind:value={codeValue} /> <Textbox
placeholder={'Code'}
uppercase={true}
type={'number'}
max={9999}
bind:value={codeValue}
/>
</div> </div>
</div> </div>
<div class="button-wrapper"> <div class="button-wrapper">

View File

@@ -5,7 +5,6 @@ import Button from '$lib/components/ui/Button.svelte';
let { onsearch }: { onsearch: (s: string, e: string) => void } = $props(); let { onsearch }: { onsearch: (s: string, e: string) => void } = $props();
let startValue = $state(''); let startValue = $state('');
let endValue = $state(''); let endValue = $state('');
@@ -19,10 +18,10 @@ function resetValues(): void {
<div class="card-content"> <div class="card-content">
<div class="textbox-container"> <div class="textbox-container">
<div class="textbox-item-wrapper"> <div class="textbox-item-wrapper">
<Textbox placeholder={"Start"} uppercase={true} maxLength={3} bind:value={startValue} /> <Textbox placeholder={'Start'} uppercase={true} maxLength={3} bind:value={startValue} />
</div> </div>
<div class="textbox-item-wrapper"> <div class="textbox-item-wrapper">
<Textbox placeholder={"End"} uppercase={true} maxLength={3} bind:value={endValue} /> <Textbox placeholder={'End'} uppercase={true} maxLength={3} bind:value={endValue} />
</div> </div>
</div> </div>
<div class="button-wrapper"> <div class="button-wrapper">

90
src/lib/geohash.svelte.ts Normal file
View File

@@ -0,0 +1,90 @@
import { OwlClient, ValidationError, ApiError } from './owlClient';
import type { ApiStationsNearestStations } from '@owlboard/owlboard-ts';
class NearestStationsState {
list = $state<ApiStationsNearestStations.StationsNearestStations[]>([]);
currentHash = $state('');
loading = $state(true);
error = $state<string | null>(null);
private geoConfig: PositionOptions = {
enableHighAccuracy: false,
timeout: 30000,
maximumAge: 120000
};
constructor() {
if (typeof window !== 'undefined' && 'geolocation' in navigator) {
this.jumpstart();
this.initWatcher();
}
}
private jumpstart() {
navigator.geolocation.getCurrentPosition(
(pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude),
(err) => this.handleError(err),
this.geoConfig
);
}
private initWatcher() {
navigator.geolocation.watchPosition(
(pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude),
(err) => this.handleError(err),
this.geoConfig
);
}
private async handleUpdate(lat: number, lon: number) {
const newHash = OwlClient.stationData.generateGeohash(lat, lon);
if (newHash !== this.currentHash) {
this.loading = true;
try {
const result = await OwlClient.stationData.getNearestStations(newHash);
this.list = result.data;
this.error = null;
this.currentHash = newHash;
} catch (e) {
this.handleApiError(e);
} finally {
this.loading = false;
}
}
}
private handleError(err: GeolocationPositionError) {
if (err.code === 1) {
this.error = 'Location access denied by device';
} else {
this.error = 'Waiting for GPS signal...';
}
}
private handleApiError(e: unknown) {
if (e instanceof ValidationError) {
this.error = `Request Error: ${e.reason} (Field: ${e.field})`;
} else if (e instanceof ApiError) {
switch (e.status) {
case 404:
this.error = 'No stations found nearby';
break;
case 429:
this.error = 'Too many requests, will retry';
break;
case 500:
this.error = 'Server Error, will retry';
break;
default:
this.error = `Service error: ${e.code}`;
}
} else {
this.error = 'Connection lost, waiting for signal';
}
console.error('OwlBoard API Error:', e);
}
}
export const nearestStationsState = new NearestStationsState();

View File

@@ -55,6 +55,7 @@
/* Shadows */ /* Shadows */
--color-shadow: hsla(210, 20%, 5%, 0.35); --color-shadow: hsla(210, 20%, 5%, 0.35);
--shadow-std: 0 4px 12px var(--color-shadow); --shadow-std: 0 4px 12px var(--color-shadow);
--shadow-small: 0 4px 6px var(--color-shadow);
--shadow-up: 0 -4px 12px var(--color-shadow); --shadow-up: 0 -4px 12px var(--color-shadow);
--shadow-right: 4px 0 12px var(--color-shadow); --shadow-right: 4px 0 12px var(--color-shadow);
} }

View File

@@ -1,27 +1,23 @@
interface LocationRecord { import { OwlClient } from './owlClient';
n: string; // name import type { ApiLocationFilter } from '@owlboard/owlboard-ts';
t: string; // tiploc
c?: string; // crs
s: string; // search string
}
class LocationStore { class LocationStore {
data = $state<LocationRecord[]>([]); data = $state<ApiLocationFilter.LocationFilterObject[]>([]);
loaded = $state(false); loaded = $state(false);
async init(fetcher = fetch) { async init() {
if (this.loaded) return; if (this.loaded) return;
try { try {
const res = await fetcher('/api/tiplocs'); const fetch = await OwlClient.locationFilter.getLocationFilterData();
this.data = await res.json(); this.data = fetch.data;
this.loaded = true; this.loaded = true;
} catch (err) { } catch (err) {
console.error('Failed to load locations', err); console.error('Failed to load locations', err);
} }
} }
find(id: string | null): LocationRecord | undefined { find(id: string | null): ApiLocationFilter.LocationFilterObject | undefined {
if (!id) return undefined; if (!id) return undefined;
const query = id.toUpperCase().trim(); const query = id.toUpperCase().trim();

View File

@@ -1,5 +1,5 @@
import { OwlBoardClient, ValidationError, ApiError } from "@owlboard/owlboard-ts"; import { OwlBoardClient, ValidationError, ApiError } from '@owlboard/owlboard-ts';
import { browser, dev } from "$app/environment"; import { browser, dev } from '$app/environment';
// Import the runes containing the API Key config Here... // Import the runes containing the API Key config Here...
@@ -11,11 +11,11 @@ const getBaseUrl = () => {
if (dev) return 'https://test.owlboard.info'; if (dev) return 'https://test.owlboard.info';
return window.location.origin; return window.location.origin;
} };
export const OwlClient = new OwlBoardClient( export const OwlClient = new OwlBoardClient(
getBaseUrl(), getBaseUrl()
// API Key Here when ready!!! // API Key Here when ready!!!
) );
export { ValidationError, ApiError }; export { ValidationError, ApiError };

View File

@@ -3,7 +3,10 @@
import { slide, fade } from 'svelte/transition'; import { slide, fade } from 'svelte/transition';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { LOCATIONS } from '$lib/locations-object.svelte.ts'; import { LOCATIONS } from '$lib/locations-object.svelte';
import { nearestStationsState } from '$lib/geohash.svelte';
import TimezoneWarning from '$lib/components/ui/TimezoneWarning.svelte';
import '$lib/global.css'; import '$lib/global.css';
@@ -13,7 +16,7 @@
import { IconHome, IconDialpad, IconSettings, IconHelp, IconDots } from '@tabler/icons-svelte'; import { IconHome, IconDialpad, IconSettings, IconHelp, IconDots } from '@tabler/icons-svelte';
onMount(() => LOCATIONS.init(fetch)); onMount(() => LOCATIONS.init());
let { children } = $props(); let { children } = $props();
@@ -77,6 +80,7 @@
</header> </header>
<main> <main>
<TimezoneWarning />
{@render children()} {@render children()}
</main> </main>
@@ -167,6 +171,7 @@
margin-left: 5px; margin-left: 5px;
padding-bottom: 2px; padding-bottom: 2px;
color: var(--color-title); color: var(--color-title);
text-transform: capitalize;
} }
header, header,
nav { nav {

View File

@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import LocationBoardCard from '$lib/components/ui/cards/LocationBoardCard.svelte'; import LocationBoardCard from '$lib/components/ui/cards/LocationBoardCard.svelte';
import NearbyStationsCard from '$lib/components/ui/cards/NearbyStationsCard.svelte';
</script> </script>
<div class="card-container"> <div class="card-container">
<LocationBoardCard /> <LocationBoardCard />
<NearbyStationsCard />
</div> </div>
<style> <style>

View File

@@ -30,9 +30,9 @@
</p> </p>
<p class="amble"> <p class="amble">
Why OwlBoard? The name was chosen as an evolution of its predecessor, 'Athena'; owls are Why OwlBoard? The name was chosen as an evolution of its predecessor, 'Athena'; owls are
associated with the equivalent Roman Goddess - Minerva - as well as with wisdom. This also links to Bath, where the associated with the equivalent Roman Goddess - Minerva - as well as with wisdom. This also links
app has been built and is run, relating to the 'Minerva Owl' sculpture trail in the city, with to Bath, where the app has been built and is run, relating to the 'Minerva Owl' sculpture trail
many of the sculptures still in the area. in the city, with many of the sculptures still in the area.
</p> </p>
<p class="opensource"> <p class="opensource">
Some components that combine to form OwlBoard are open-source, see the <a Some components that combine to form OwlBoard are open-source, see the <a

View File

@@ -6,7 +6,7 @@ export const load: PageLoad = async ({ url }) => {
const locId = url.searchParams.get('loc'); const locId = url.searchParams.get('loc');
if (!LOCATIONS.loaded) { if (!LOCATIONS.loaded) {
await LOCATIONS.init(fetch); await LOCATIONS.init();
} }
let title: string = ''; let title: string = '';

View File

@@ -2,37 +2,54 @@
import PisStartEndCard from '$lib/components/ui/cards/pis/PisStartEndCard.svelte'; import PisStartEndCard from '$lib/components/ui/cards/pis/PisStartEndCard.svelte';
import PisCode from '$lib/components/ui/cards/pis/PisCode.svelte'; import PisCode from '$lib/components/ui/cards/pis/PisCode.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import type { PisObjects } from '@owlboard/api-schema-types'; import type { ApiPisObject } from '@owlboard/owlboard-ts';
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient'; import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
import TocStyle from '$lib/components/ui/TocStyle.svelte';
let results = $state<PisObjects[]>([]); let results = $state<ApiPisObject.PisObjects[]>([]);
let resultsLoaded = $state<boolean>(false); let resultsLoaded = $state<boolean>(false);
let errorState = $state<{status: number, message: message} | null>(null); let errorState = $state<{ status: number; message: string } | null>(null);
async function handleStartEndSearch(start: string, end: string): Promise<void> { async function handleStartEndSearch(start: string, end: string): Promise<void> {
console.log(`PIS Search: ${start}-${end}`); console.log(`PIS Search: ${start}-${end}`);
errorState = null; errorState = null;
try { try {
results = await OwlClient.pis.getByStartEndCrs(start, end); const response = await OwlClient.pis.getByStartEndCrs(start, end);
results = response.data || [];
} catch (e) { } catch (e) {
if (e instanceof ValidationError) { if (e instanceof ValidationError) {
errorState = { status: 400, message: e.message }; errorState = { status: 400, message: e.message };
} else if (e instanceof ApiError) { } else if (e instanceof ApiError) {
errorState = { status: 500, message: e.message }; console.log(e);
errorState = { status: 20, message: e.message };
} else { } else {
errorState = { status: 0, message: `Unknown Error: ${e.message}` }; errorState = { status: 0, message: `Unknown Error: ${e.message}` };
} }
} } finally {
resultsLoaded = true; resultsLoaded = true;
} }
}
function handleCodeSearch(code: string) { async function handleCodeSearch(code: string) {
console.log(`PIS Search: ${code}`); console.log(`PIS Search: ${code}`);
// Fetch API Results Here errorState = null;
try {
const response = await OwlClient.pis.getByCode(code);
results = response.data || [];
} catch (e) {
if (e instanceof ValidationError) {
errorState = { status: 400, message: e.message };
} else if (e instanceof ApiError) {
console.log(e);
errorState = { status: 20, message: e.message };
} else {
errorState = { status: 0, message: `Unknown Error: ${e.message}` };
}
} finally {
resultsLoaded = true; resultsLoaded = true;
} }
}
function clearResults() { function clearResults() {
console.log('Clearing Results'); console.log('Clearing Results');
@@ -49,16 +66,37 @@
{:else} {:else}
<div class="result-container"> <div class="result-container">
{#if errorState} {#if errorState}
<span class="errCode">{errorState.status}</span> <span class="errCode">Error: {errorState.status}</span>
<span class="errMsg">{errorState.message}</span> <span class="errMsg">{errorState.message}</span>
{:else if results.length}
<h2 class="result-title">
{results.length} Result{#if results.length > 1}s{/if} found
</h2>
<table class="result-table">
<thead>
<tr>
<th style="width:16%">TOC</th>
<th style="width:14%">Code</th>
<th style="width:70%">Locations</th>
</tr></thead
>
{#each results as result}
<tbody
><tr>
<td><TocStyle toc={result.toc || ''} /></td>
<td>{result.code}</td>
<td class="locations-row">{result.crsStops?.join(' ') || ''}</td>
</tr></tbody
>
{/each}
</table>
{:else} {:else}
{#if !results.length} <p class="no-results">No matching results</p>
<p class="no-results">No results found</p> {/if}
{:else} <div class="reset-button-container">
<p class="no-results">Results Loaded - Display logic not present</p>
{/if}{/if}
<Button onclick={clearResults}>Reset</Button> <Button onclick={clearResults}>Reset</Button>
</div> </div>
</div>
{/if} {/if}
<style> <style>
@@ -82,11 +120,43 @@
justify-content: center; justify-content: center;
background: var(--color-accent); background: var(--color-accent);
border-radius: 15px; border-radius: 15px;
padding: 0 0 20px 0; padding: 20px 0 20px 0;
margin: auto; margin: auto;
margin-top: 25px; margin-top: 25px;
margin-bottom: 25px;
width: 90%; width: 90%;
max-width: 1000px; max-width: 500px;
box-shadow: var(--shadow-std); box-shadow: var(--shadow-std);
} }
.result-title {
color: var(--color-brand);
}
.result-table {
width: 90%;
max-width: 350px;
margin: auto;
text-align: center;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0 20px;
font-weight: 400;
}
.locations-row {
font-family: 'Courier New', Courier, monospace;
text-align: left;
padding-left: 20px;
}
.errCode {
color: rgb(255, 54, 54);
font-weight: 600;
font-size: 2rem;
}
.reset-button-container {
padding: 20px 0 3px 0;
}
</style> </style>

View File

@@ -1,106 +0,0 @@
[
{"n":"Manchester Piccadilly","t":"MANPICD","c":"MAN","s":"manchester piccadilly man manpicd"},
{"n":"Manchester Victoria","t":"MCV","c":"MCV","s":"manchester victoria mcv"},
{"n":"Manchester Oxford Road","t":"MCOR","c":"MCO","s":"manchester oxford road mco mcor"},
{"n":"Manchester Airport","t":"MANAPTL","c":"MIA","s":"manchester airport mia manaptl"},
{"n":"London Euston","t":"EUSTON","c":"EUS","s":"london euston eus euston"},
{"n":"London Kings Cross","t":"KGX","c":"KGX","s":"london kings cross kgx kingscross"},
{"n":"London St Pancras International","t":"STPANCR","c":"STP","s":"london st pancras international stp stpancr"},
{"n":"London Paddington","t":"PADTON","c":"PAD","s":"london paddington pad padton"},
{"n":"London Victoria","t":"VIC","c":"VIC","s":"london victoria vic"},
{"n":"London Liverpool Street","t":"LIVST","c":"LST","s":"london liverpool street lst livst"},
{"n":"London Bridge","t":"LONGBR","c":"LBG","s":"london bridge lbg longbr"},
{"n":"Birmingham New Street","t":"BHMNEWST","c":"BHM","s":"birmingham new street bhm bhmnewst bham"},
{"n":"Birmingham Moor Street","t":"BHMMRS","c":"BMO","s":"birmingham moor street bmo bhmmrs"},
{"n":"Birmingham Snow Hill","t":"BHMSH","c":"BSW","s":"birmingham snow hill bsw bhmsh"},
{"n":"Leeds","t":"LEEDS","c":"LDS","s":"leeds lds"},
{"n":"York","t":"YORK","c":"YRK","s":"york yrk"},
{"n":"Liverpool Lime Street","t":"LIVLST","c":"LIV","s":"liverpool lime street liv livlst"},
{"n":"Liverpool Central","t":"LIVCEN","c":"LVC","s":"liverpool central lvc livcen"},
{"n":"Sheffield","t":"SHEFFLD","c":"SHF","s":"sheffield shf sheffld"},
{"n":"Nottingham","t":"NOTTM","c":"NOT","s":"nottingham not nottm"},
{"n":"Derby","t":"DERBY","c":"DBY","s":"derby dby"},
{"n":"Leicester","t":"LEICEST","c":"LEI","s":"leicester lei leicest"},
{"n":"Bristol Temple Meads","t":"BRSTLTM","c":"BRI","s":"bristol temple meads bri brstltm"},
{"n":"Cardiff Central","t":"CDFCEN","c":"CDF","s":"cardiff central cdf cdfcen"},
{"n":"Newcastle","t":"NEWCAST","c":"NCL","s":"newcastle ncl newcast"},
{"n":"Edinburgh Waverley","t":"EDINBUR","c":"EDB","s":"edinburgh waverley edb edinbur"},
{"n":"Glasgow Central","t":"GLASCEN","c":"GLC","s":"glasgow central glc glascen"},
{"n":"Glasgow Queen Street","t":"GLAQS","c":"GLQ","s":"glasgow queen street glq glaqs"},
{"n":"Reading","t":"READING","c":"RDG","s":"reading rdg"},
{"n":"Oxford","t":"OXFORD","c":"OXF","s":"oxford oxf"},
{"n":"Cambridge","t":"CAMBRDG","c":"CBG","s":"cambridge cbg cambrdg"},
{"n":"Peterborough","t":"PBOUGH","c":"PBO","s":"peterborough pbo pbough"},
{"n":"Doncaster","t":"DONCAST","c":"DON","s":"doncaster don doncast"},
{"n":"Crewe","t":"CREWE","c":"CRE","s":"crewe cre"},
{"n":"Preston","t":"PRESTON","c":"PRE","s":"preston pre"},
{"n":"Blackpool North","t":"BPLNOR","c":"BPN","s":"blackpool north bpn bplnor"},
{"n":"Bolton","t":"BOLTON","c":"BON","s":"bolton bon"},
{"n":"Huddersfield","t":"HUDDSFD","c":"HUD","s":"huddersfield hud huddsfd"},
{"n":"Stockport","t":"STOCKPT","c":"SPT","s":"stockport spt stockpt"},
{"n":"Wigan North Western","t":"WIGNW","c":"WGN","s":"wigan north western wgn wignw"},
{"n":"Bath Spa","t":"BATHSPA","c":"BTH","s":"bath spa bth bathspa"},
{"n":"Exeter St Davids","t":"EXD","c":"EXD","s":"exeter st davids exd"},
{"n":"Plymouth","t":"PLYMTH","c":"PLY","s":"plymouth ply plymth"},
{"n":"Truro","t":"TRURO","c":"TRU","s":"truro tru"},
{"n":"Aberdeen","t":"ABERDN","c":"ABD","s":"aberdeen abd aberdn"},
{"n":"Inverness","t":"INVNESS","c":"INV","s":"inverness inv invness"},
{"n":"Perth","t":"PERTH","c":"PTH","s":"perth pth"},
{"n":"Dundee","t":"DUNDEE","c":"DEE","s":"dundee dee"},
{"n":"Stirling","t":"STIRLNG","c":"STG","s":"stirling stg stirlng"},
{"n":"Falkirk Grahamston","t":"FLKGRA","c":"FKG","s":"falkirk grahamston fkg flkgra"},
{"n":"Motherwell","t":"MOTHRWL","c":"MTH","s":"motherwell mth mothrwl"},
{"n":"Paisley Gilmour Street","t":"PAISGL","c":"PYG","s":"paisley gilmour street pyg paisgl"},
{"n":"Greenock Central","t":"GRNOCK","c":"GKC","s":"greenock central gkc grnock"},
{"n":"Ayr","t":"AYR","c":"AYR","s":"ayr"},
{"n":"Carlisle","t":"CARLISL","c":"CAR","s":"carlisle car carlisl"},
{"n":"Penrith North Lakes","t":"PNRITH","c":"PNR","s":"penrith north lakes pnr pnrith"},
{"n":"Kendal","t":"KENDAL","c":"KEN","s":"kendal ken"},
{"n":"Windermere","t":"WNDRMRE","c":"WDM","s":"windermere wdm wndrme"},
{"n":"Lancaster","t":"LANCAST","c":"LAN","s":"lancaster lan lancast"},
{"n":"Chester","t":"CHESTER","c":"CTR","s":"chester ctr"},
{"n":"Warrington Bank Quay","t":"WRRGBQ","c":"WBQ","s":"warrington bank quay wbq wrrgbq"},
{"n":"Warrington Central","t":"WRRGCN","c":"WAC","s":"warrington central wac wrrgcn"},
{"n":"Runcorn","t":"RUNCORN","c":"RUN","s":"runcorn run"},
{"n":"Widnes","t":"WIDNES","c":"WID","s":"widnes wid"},
{"n":"Southport","t":"STHPORT","c":"SOP","s":"southport sop sthport"},
{"n":"Ormskirk","t":"ORMSKRK","c":"OMS","s":"ormskirk oms ormskrk"},
{"n":"Blackburn","t":"BLKBRN","c":"BBN","s":"blackburn bbn blkbrn"},
{"n":"Burnley Manchester Road","t":"BURNMR","c":"BYM","s":"burnley manchester road bym burnmr"},
{"n":"Rochdale","t":"ROCHDAL","c":"RCD","s":"rochdale rcd rochdal"},
{"n":"Oldham Mumps","t":"OLDMUM","c":"OMM","s":"oldham mumps omm oldmum"},
{"n":"Ashton-under-Lyne","t":"ASHTON","c":"AHN","s":"ashton under lyne ahn ashton"},
{"n":"Stalybridge","t":"STALYBG","c":"SYB","s":"stalybridge syb stalybg"},
{"n":"Macclesfield","t":"MACCLFD","c":"MAC","s":"macclesfield mac macclfd"},
{"n":"Congleton","t":"CONGLTN","c":"CNG","s":"congleton cng conglt"},
{"n":"Stoke-on-Trent","t":"STOKETR","c":"SOT","s":"stoke on trent sot stoketr"},
{"n":"Stafford","t":"STAFFRD","c":"STA","s":"stafford sta staffrd"},
{"n":"Tamworth","t":"TAMWTH","c":"TAM","s":"tamworth tam tamwth"},
{"n":"Nuneaton","t":"NUNEATN","c":"NUN","s":"nuneaton nun nuneatn"},
{"n":"Coventry","t":"COVNTRY","c":"COV","s":"coventry cov covntry"},
{"n":"Rugby","t":"RUGBY","c":"RUG","s":"rugby rug"},
{"n":"Milton Keynes Central","t":"MKCEN","c":"MKC","s":"milton keynes central mkc mkcen"},
{"n":"Birmingham Washwood Heath Junction","t":"BWHJCT","c":"","s":"birmingham washwood heath junction bwhjct"},
{"n":"Manchester Trafford Park Yard","t":"MTRYD","c":"","s":"manchester trafford park yard mtryd"},
{"n":"London Willesden Junction","t":"WLSDJCT","c":"","s":"london willesden junction wlsdjct"},
{"n":"Leeds Neville Hill Depot","t":"NVHLDP","c":"","s":"leeds neville hill depot nvhldp"},
{"n":"York Holgate Junction","t":"YHGJCT","c":"","s":"york holgate junction yhgjct"},
{"n":"Crewe Basford Hall Junction","t":"CBHJCT","c":"","s":"crewe basford hall junction cbhjct"},
{"n":"Doncaster Decoy Sidings","t":"DCDSID","c":"","s":"doncaster decoy sidings dcdsid"},
{"n":"Liverpool Edge Hill Yard","t":"LEHYD","c":"","s":"liverpool edge hill yard lehyd"},
{"n":"Bristol East Junction","t":"BREJCT","c":"","s":"bristol east junction brejct"},
{"n":"Glasgow Polmadie Depot","t":"GLPDEP","c":"","s":"glasgow polmadie depot glpdep"},
{"n":"Newcastle Manors Junction","t":"NCMJCT","c":"","s":"newcastle manors junction ncmjct"},
{"n":"Edinburgh Haymarket Sidings","t":"EHSID","c":"","s":"edinburgh haymarket sidings ehsid"},
{"n":"Reading South Junction","t":"RDSJCT","c":"","s":"reading south junction rdsjct"},
{"n":"Oxford Rewley Road Depot","t":"OXRDEP","c":"","s":"oxford rewley road depot oxrdep"},
{"n":"Cambridge Coldham Lane Junction","t":"CCLJCT","c":"","s":"cambridge coldham lane junction ccljct"},
{"n":"Watford North Junction","t":"WFNJCT","c":"","s":"watford north junction wfnjct"},
{"n":"Luton Airport Sidings","t":"LUTSID","c":"","s":"luton airport sidings lutsid"},
{"n":"Stevenage Hitchin Junction","t":"STHJC","c":"","s":"stevenage hitchin junction sthjc"},
{"n":"Chelmsford New Hall Junction","t":"CHNJCT","c":"","s":"chelmsford new hall junction chnjct"},
{"n":"","t":"BPWY532","c":"","s":"bpwy532"},
{"n":"Ipswich Derby Road Depot","t":"IPDRDP","c":"","s":"ipswich derby road depot ipdrdp"},
{"n":"Rhoose Cardiff International Airport","c":"RIA","t":"RHOOSE","s":"rhoose cardiff international airport ria"},
{"n":"Southampton Airport Parkway","c":"SOA","t":"SOTAPT","s":"southampton airport parkway soa sotapt"}
]

View File

@@ -8,7 +8,7 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": false,
"strict": true, "strict": true,
"moduleResolution": "bundler" "moduleResolution": "bundler"
} }