28 Commits

Author SHA1 Message Date
b649af1925 Adjust button styling to improve appearance of text 2026-03-31 20:01:20 +01:00
aa1a989139 Remove fade-out 2026-03-31 00:11:09 +01:00
304b523127 Update button style, to improve looks while adding a large enough touch-target 2026-03-31 00:05:17 +01:00
777519ff5d Add NearestStations Card & Location monitor 2026-03-30 23:34:12 +01:00
4a969e626c Ensure dependencies are listed properly 2026-03-25 10:54:21 +00:00
3e1b7ea5d5 Disable source-maps for output code 2026-03-25 10:51:37 +00:00
fd213d6340 Refine filtering logic
Remove api-types package as the types are re-exported by the TS Client package
2026-03-25 10:50:26 +00:00
3eceddf20a Bump OwlBoard-TS 2026-03-25 10:24:58 +00:00
1d461780ab Add proper capitalization to page titles and location names. 2026-03-24 15:44:50 +00:00
ec4dd5dd3b Move 'LocationFilter' fetching to API Lib package 2026-03-24 12:47:39 +00:00
a7c244171c Add display options to PIS 2026-03-20 19:41:36 +00:00
3467f97889 Add PIS fetch logic 2026-03-19 23:41:12 +00:00
b1d8eea518 Add PIS Logic 2026-03-19 20:46:12 +00:00
deb151075a Add OwlBoard API Library
Add styling to UI Components
2026-03-19 10:59:25 +00:00
d9b60daa8b Add PIS code search components 2026-03-18 20:03:02 +00:00
2f0a6b9646 Add PIS Page 2026-03-18 19:20:14 +00:00
1165c02e26 Add OwlBoard npm repo 2026-03-18 19:07:31 +00:00
45dd5a1cf5 ALL_CAPS the locId in the error message for location_not_found error 2026-03-17 20:27:34 +00:00
e47bebe7d4 Add dynamic title sizing for better display on smaller screens 2026-03-17 20:26:04 +00:00
b7007d2fb3 run npm run format 2026-03-17 20:05:44 +00:00
64bc5b979d Add initial logic for 'boards' 2026-03-17 20:04:38 +00:00
3240560a0b Refactor location object into separate module so that it can be used in multiple locations 2026-03-17 19:28:02 +00:00
a327582629 Update location logic to handle TIPLOC as a fallback to CRS 2026-03-16 20:35:26 +00:00
35877ae8ac Add LocationSearchCard and add to homepage for testing.
Run `npm run format`
2026-03-16 20:31:28 +00:00
f5c3775f59 Add further testing locations 2026-03-16 15:42:16 +00:00
e5d3c0a3a7 Add test data for LocationSearchBox 2026-03-16 15:39:49 +00:00
1b0b93b34b Add initial location search box 2026-03-16 14:08:00 +00:00
66ed33343c Ignore HTTP errors for now. 2026-03-15 20:12:08 +00:00
29 changed files with 1153 additions and 237 deletions

View File

@@ -3,7 +3,7 @@ run-name: ${{ gitea.actor }} is building and pushing
on:
create:
tags: "*"
tags: '*'
env:
GITEA_DOMAIN: git.fjla.uk
@@ -36,4 +36,4 @@ jobs:
push: true
tags: |
${{ env.GITEA_DOMAIN }}/${{ env.RESULT_IMAGE_NAME }}:${{ gitea.ref_name }}
${{ env.GITEA_DOMAIN }}/${{ env.RESULT_IMAGE_NAME }}:latest
${{ env.GITEA_DOMAIN }}/${{ env.RESULT_IMAGE_NAME }}:latest

1
.npmrc
View File

@@ -1 +1,2 @@
engine-strict=true
@owlboard:registry=https://git.fjla.uk/api/packages/OwlBoard/npm/

62
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"devDependencies": {
"@eslint/compat": "^2.0.2",
"@eslint/js": "^9.39.2",
"@owlboard/owlboard-ts": "^3.0.0-dev.202603302258",
"@playwright/test": "^1.58.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
@@ -778,6 +779,24 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@owlboard/api-schema-types": {
"version": "3.0.2-alpha3",
"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-3NFP21QdSfjziwlGQixlNnUWC55HlKZGyWANIOimWu0FZejWQWExJiaAVfb6m3Sbv+zvQMu3B8mzcMCcGadZCQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@owlboard/owlboard-ts": {
"version": "3.0.0-dev.202603302258",
"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-uJRoahtqnkmkPg8QsIWhtxhAibWY8xG25fhjzzdDFNE/2+uCj1ev5iD+t0O1dkE7ic3yTcMZqDitEspW216bjw==",
"dev": true,
"license": "GPL-3.0",
"dependencies": {
"@owlboard/api-schema-types": "^3.0.2-alpha3",
"latlon-geohash": "^2.0.0"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
@@ -1540,9 +1559,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1863,9 +1882,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2452,9 +2471,9 @@
}
},
"node_modules/flatted": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -2646,6 +2665,13 @@
"dev": true,
"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": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -2883,9 +2909,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3010,9 +3036,9 @@
}
},
"node_modules/postcss-load-config/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"dev": true,
"license": "ISC",
"engines": {
@@ -3825,9 +3851,9 @@
}
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC",
"optional": true,

View File

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

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="title" content="OwlBoard | Your fasted route to live and reference data" />
<meta name="title" content="OwlBoard | Your fastest route to live and reference data" />
<meta
name="description"
content="Live station departures, Live train tracking, PIS Codes & more"

View File

@@ -19,47 +19,60 @@
{#if isLink}
<a
{href}
class="btn {color}"
class="hitbox-wrapper"
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
{...rest}
>
{@render children?.()}
><span class="btn {color}">
{@render children?.()}
</span>
</a>
{:else}
<button class="btn {color}" {onclick} {...rest}>
{@render children?.()}
<button class="hitbox-wrapper" {onclick} {...rest}>
<span class="btn {color}">{@render children?.()}</span>
</button>
{/if}
<style>
.btn {
.hitbox-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.4rem 1.2rem;
width: fit-content;
min-width: 90px;
min-height: 48px;
min-width: 48px;
appearance: none;
background: transparent;
border: none;
border-radius: 20px;
padding: 0 4px;
cursor: pointer;
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;
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 {
background-color: var(--color-accent);
color: var(--color-title);
font-weight: 600;
}
.brand {
@@ -72,11 +85,11 @@
color: var(--color-title);
}
.btn:hover {
.hitbox-wrapper:hover .btn {
filter: brightness(1.5);
}
.btn.active {
.hitbox-wrapper:active .btn {
transform: scale(0.98);
}
</style>

View File

@@ -0,0 +1,208 @@
<script lang="ts">
import Textbox from '$lib/components/ui/Textbox.svelte';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { goto } from '$app/navigation';
import { LOCATIONS } from '$lib/locations-object.svelte';
import type { ApiLocationFilter } from '@owlboard/api-schema-types';
let { value = $bindable() } = $props();
let showResults = $state(false);
let selectedIndex = $state(-1);
const MAX_RESULTS = 5;
function tokenize(query: string) {
return query.toLowerCase().trim().split(/\s+/).filter(Boolean);
}
let results = $derived.by(() => {
if (value.length < 3) return [];
const tokens = tokenize(value);
const lowerQuery = value.toLowerCase().trim();
return LOCATIONS.data
.filter((r) => tokens.every((t) => r.s.includes(t)))
.sort((a, b) => {
// Priority One - Exact CRS Match
const aExactCrs = a.c?.toLowerCase() === lowerQuery;
const bExactCrs = b.c?.toLowerCase() === lowerQuery;
if (aExactCrs && !bExactCrs) return -1;
if (!aExactCrs && bExactCrs) return 1;
// Priority Two - 'Stations' with CRS
if (!!a.c && !b.c) return -1;
if (!a.c & !!b.c) return 1;
// Alphabetical Sort
return a.n.localeCompare(b.n);
})
.slice(0, MAX_RESULTS);
});
$effect(() => {
if (results) selectedIndex = -1;
});
// Hide results when click outside of container
$effect(() => {
if (showResults) {
const onClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.location-search')) {
showResults = false;
}
};
document.addEventListener('click', onClick);
return () => document.removeEventListener('click', onClick);
}
});
function choose(loc: ApiLocationFilter.LocationFilterObject) {
showResults = false;
selectedIndex = -1;
value = '';
console.log('Selected Location: ', JSON.stringify(loc));
const queryString = loc.c || loc.t;
goto(`/board?loc=${queryString.toLowerCase()}`);
}
function handleKey(e: KeyboardEvent) {
if (!results.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
}
if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
}
if (e.key === 'Enter' && selectedIndex >= 0) {
choose(results[selectedIndex]);
}
}
</script>
<div class="location-search">
<Textbox
bind:value
placeholder="Enter Location"
oninput={() => (showResults = true)}
onkeydown={handleKey}
capital
/>
{#if showResults && results.length}
<ul
id="location-results"
popover={showResults && results.length ? 'manual' : null}
role="listbox"
class="suggestions"
transition:fade={{ duration: 200 }}
>
{#each results as loc, i}
<li class="result-item" class:selected={i === selectedIndex} onclick={() => choose(loc)}>
<div class="crs-badge-container">
{#if loc.c}
<span class="crs-badge">{loc.c}</span>
{/if}
</div>
<div class="details">
<span class="name">{loc.n || loc.t}</span>
<span class="tiploc">{loc.t}</span>
</div>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.location-search {
position: relative;
width: 100%;
}
.suggestions[popover] {
position: absolute;
inset: unset;
margin: 0;
margin-top: 3px;
border: none;
border-radius: 5px;
padding: 0;
width: 100%;
max-height: 350px;
top: 100%;
background-color: var(--color-title);
color: var(--color-bg-dark);
box-shadow: var(--shadow-std);
display: block;
z-index: 9999;
}
.suggestions:not([popover]) {
display: none;
}
.result-item {
font-family: 'URW Gothic', sans-serif;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
cursor: pointer;
min-height: 48px;
transition: all 0.15s;
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
}
.result-item.selected,
.result-item:hover {
background-color: var(--color-accent);
color: var(--color-title);
}
.crs-badge {
font-family: ui-monospace, monospace;
font-size: 1.1rem;
font-weight: 700;
background: var(--color-accent);
color: var(--color-title);
padding: 3px 6px;
border-radius: 10px;
transition: all 0.1s;
}
.crs-badge.empty {
filter: opacity(0);
}
.result-item:hover .crs-badge {
filter: brightness(1.3);
}
.details {
display: flex;
flex-direction: column;
}
.name {
font-size: 1.1rem;
font-weight: 700;
text-align: right;
text-transform: capitalize;
}
.tiploc {
text-align: right;
font-size: 0.8rem;
}
</style>

View File

@@ -1,94 +1,95 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import type { HTLMInputAttributes } from 'svelte/elements';
import { fade } from 'svelte/transition';
import type { HTMLInputAttributes } from 'svelte/elements';
interface Props extends HTMLInputAttributes {
value?: string;
label?: string;
placeholder?: string;
type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url';
error?: string;
uppercase?: boolean;
[key: string]: any;
}
interface Props extends HTMLInputAttributes {
value?: string;
label?: string;
placeholder?: string;
type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url';
error?: string;
uppercase?: boolean;
[key: string]: any;
}
let {
value = $bindable(''),
label,
placeholder = '',
type = 'text',
error = '',
uppercase = false,
...rest
}: Props = $props();
let {
value = $bindable(''),
label,
placeholder = '',
type = 'text',
error = '',
uppercase = false,
...rest
}: Props = $props();
let isFocussed = $state(false);
let isFocussed = $state(false);
</script>
<div class="input-wrapper" class:focussed={isFocussed} class:has-error={!!error}>
{#if label}
<label for="adaptive-input">{label}</label>
{/if}
{#if label}
<label for="adaptive-input">{label}</label>
{/if}
<input
id="adaptive-input"
class:all-caps={uppercase}
{type}
{placeholder}
bind:value={value}
onfocus={() => isFocussed = true}
onblur={() => isFocussed = false}
{...rest}
/>
<input
id="adaptive-input"
class:all-caps={uppercase}
{type}
{placeholder}
bind:value
onfocus={() => (isFocussed = true)}
onblur={() => (isFocussed = false)}
{...rest}
/>
{#if error}
<span class="error-message" transition:fade>{error}</span>
{/if}
{#if error}
<span class="error-message" transition:fade>{error}</span>
{/if}
</div>
<style>
.input-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
font-family: 'URW Gothic', sans-serif;
}
.input-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
font-family: 'URW Gothic', sans-serif;
}
label {
font-size: 0.9rem;
font-weight: 400;
color: var(--color-title)
}
label {
font-size: 0.9rem;
font-weight: 400;
color: var(--color-title);
}
input {
min-height: 48px;
padding: 0 16px;
background-color: var(--color-title);
border: 2px solid transparent;
border-radius: 20px;
color: var(--color-bg-dark);
font-size: 1.5rem;
transition: all 0.2s ease-in-out;
outline: none;
text-align: center;
}
input {
min-height: 40px;
padding: 0 16px;
background-color: var(--color-title);
border: 2px solid transparent;
border-radius: 20px;
color: var(--color-bg-dark);
font-size: 1.2rem;
transition: all 0.2s ease-in-out;
outline: none;
text-align: center;
box-shadow: var(--shadow-std);
}
.all-caps {
text-transform: uppercase;
}
.all-caps {
text-transform: uppercase;
}
.focussed input {
border-color: var(--color-bg-light);
}
.focussed input {
border-color: var(--color-bg-light);
}
.has-error input {
border-color: #ff4d4d;
}
.has-error input {
border-color: #ff4d4d;
}
.error-message {
color: #ff4d4d;
font-size: 1rem;
text-align: center;
}
</style>
.error-message {
color: #ff4d4d;
font-size: 1rem;
text-align: center;
}
</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

@@ -1,94 +1,102 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { IconHelpCircle } from '@tabler/icons-svelte';
import { slide } from 'svelte/transition';
import type { Snippet } from 'svelte';
import { IconHelpCircle } from '@tabler/icons-svelte';
import { slide } from 'svelte/transition';
interface Props {
children: Snippet;
header?: string;
helpText?: string;
}
interface Props {
children: Snippet;
header?: string;
helpText?: string;
}
let {
children,
header = "",
helpText,
}: Props = $props();
let { children, header = '', helpText }: Props = $props();
let showHelp = $state(false);
let showHelp = $state(false);
</script>
<div class="card">
{#if header || helpText}
<header class="card-header">
<div class="header-content">
{header}
</div>
{#if helpText}
<button
type="button"
class="help-toggle"
onclick={() => showHelp = !showHelp}
aria-label="Show Help"
>
<IconHelpCircle size={26} stroke={2.25} color={showHelp ? 'var(--color-brand)' : 'var(--color-title)'} />
</button>
{/if}
</header>
{#if header || helpText}
<header class="card-header">
<div class="header-content">
{header}
</div>
{#if helpText}
<button
type="button"
class="help-toggle"
onclick={() => (showHelp = !showHelp)}
aria-label="Show Help"
>
<IconHelpCircle
size={26}
stroke={2.25}
color={showHelp ? 'var(--color-brand)' : 'var(--color-title)'}
/>
</button>
{/if}
</header>
{#if showHelp && helpText}
<div class="help-drawer" transition:slide={{ duration: 400 }}>
<p>{helpText}</p>
</div>
{/if}
{/if}
{#if showHelp && helpText}
<div class="help-drawer" transition:slide={{ duration: 400 }}>
<p>{helpText}</p>
</div>
{/if}
{/if}
<div class="card-body">
{@render children?.()}
</div>
<div class="card-body">
{@render children?.()}
</div>
</div>
<style>
.card {
background: var(--color-accent);
position: relative;
border-radius: 20px;
overflow: hidden;
width: 95%;
max-width: 600px;
text-align: center;
font-family: 'URW Gothic', sans-serif;
color: var(--color-title);
}
.card {
background: var(--color-accent);
position: relative;
border-radius: 20px;
overflow: visible;
width: 95%;
max-width: 600px;
text-align: center;
font-family: 'URW Gothic', sans-serif;
color: var(--color-brand);
padding: 10px 0;
box-shadow: var(--shadow-std);
}
.header-content { flex: 1;
font-size: 1.5rem; font-weight: 600; }
.header-content {
flex: 1;
margin: 0;
font-size: 1.3rem;
font-weight: 600;
}
.help-toggle {
position: absolute;
top: 8px;
right: 8px;
background: none;
border: none;
padding: 4px;
cursor: help;
opacity: 0.6;
z-index: 2;
transition: opacity 0.2s, transform 0.2s;
}
.help-toggle {
position: absolute;
top: 8px;
right: 8px;
background: none;
border: none;
padding: 4px;
cursor: help;
opacity: 0.6;
z-index: 2;
transition:
opacity 0.2s,
transform 0.2s;
}
.help-toggle:hover {
opacity: 1;
transform: scale(1.1);
}
.help-toggle:hover {
opacity: 1;
transform: scale(1.1);
}
.help-drawer {
background-color: var(--color-accent);
padding: 4px 16px;
font-size: 0.95rem;
line-height: 1.2;
margin: auto;
border-bottom: 1px solid rgba(255,255,255,0.05);
color: var(--color-title);
}
</style>
.help-drawer {
background-color: var(--color-accent);
padding: 4px 16px;
font-size: 0.95rem;
line-height: 1.2;
margin: auto;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
color: var(--color-title);
}
</style>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import LocationSearchBox from '$lib/components/ui/LocationSearchBox.svelte';
let locationValue = $state('');
function resetSearchBox() {
value = '';
}
</script>
<BaseCard header={'Live Arrivals & Departures'}>
<div class="card-content">
<LocationSearchBox bind:value={locationValue} />
</div>
</BaseCard>
<style>
.card-content {
text-align: center;
width: 90%;
margin: auto;
padding: 10px 0 10px 0;
}
</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

@@ -0,0 +1,60 @@
<script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Textbox from '$lib/components/ui/Textbox.svelte';
import Button from '$lib/components/ui/Button.svelte';
let { onsearch }: { onsearch: (c: string) => void } = $props();
let codeValue = $state('');
function resetValues(): void {
codeValue = '';
}
</script>
<BaseCard header={'Find by Code'}>
<div class="card-content">
<div class="textbox-container">
<div class="textbox-item-wrapper">
<Textbox
placeholder={'Code'}
uppercase={true}
type={'number'}
max={9999}
bind:value={codeValue}
/>
</div>
</div>
<div class="button-wrapper">
<Button onclick={() => onsearch(codeValue.toString())}>Search</Button>
<Button onclick={resetValues}>Reset</Button>
</div>
</div>
</BaseCard>
<style>
.card-content {
text-align: center;
width: 90%;
margin: auto;
padding: 10px 0 10px 0;
}
.textbox-container {
display: flex;
width: 100%;
justify-content: center;
gap: 4rem;
}
.textbox-item-wrapper {
width: 30%;
}
.button-wrapper {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 15px;
}
</style>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Textbox from '$lib/components/ui/Textbox.svelte';
import Button from '$lib/components/ui/Button.svelte';
let { onsearch }: { onsearch: (s: string, e: string) => void } = $props();
let startValue = $state('');
let endValue = $state('');
function resetValues(): void {
startValue = '';
endValue = '';
}
</script>
<BaseCard header={'Find by Start/End CRS'}>
<div class="card-content">
<div class="textbox-container">
<div class="textbox-item-wrapper">
<Textbox placeholder={'Start'} uppercase={true} maxLength={3} bind:value={startValue} />
</div>
<div class="textbox-item-wrapper">
<Textbox placeholder={'End'} uppercase={true} maxLength={3} bind:value={endValue} />
</div>
</div>
<div class="button-wrapper">
<Button onclick={() => onsearch(startValue, endValue)}>Search</Button>
<Button onclick={resetValues}>Reset</Button>
</div>
</div>
</BaseCard>
<style>
.card-content {
text-align: center;
width: 90%;
margin: auto;
padding: 10px 0 10px 0;
}
.textbox-container {
display: flex;
width: 100%;
justify-content: center;
gap: 4rem;
}
.textbox-item-wrapper {
width: 30%;
}
.button-wrapper {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 15px;
}
</style>

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 */
--color-shadow: hsla(210, 20%, 5%, 0.35);
--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-right: 4px 0 12px var(--color-shadow);
}

View File

@@ -0,0 +1,33 @@
import { OwlClient } from './owlClient';
import type { ApiLocationFilter } from '@owlboard/owlboard-ts';
class LocationStore {
data = $state<ApiLocationFilter.LocationFilterObject[]>([]);
loaded = $state(false);
async init() {
if (this.loaded) return;
try {
const fetch = await OwlClient.locationFilter.getLocationFilterData();
this.data = fetch.data;
this.loaded = true;
} catch (err) {
console.error('Failed to load locations', err);
}
}
find(id: string | null): ApiLocationFilter.LocationFilterObject | undefined {
if (!id) return undefined;
const query = id.toUpperCase().trim();
console.log(query);
return this.data.find((loc) => {
return loc.t === query || loc.c === query;
});
}
}
export const LOCATIONS = new LocationStore();

21
src/lib/owlClient.ts Normal file
View File

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

View File

@@ -58,13 +58,15 @@
font-size: 1.1rem;
color: var(--color-title);
max-width: 300px;
margin-top: 5px;
margin-bottom: 30px;
margin-top: 5px;
margin-bottom: 20px;
}
.debug-info {
background: rgba(255, 255, 255, 0.05);
padding: 5px 12px;
padding: 5px 15px;
margin-top: 0;
margin-bottom: 20px;
border-radius: 4px;
font-size: 0.8rem;
opacity: 0.6;

View File

@@ -1,6 +1,10 @@
<script lang="ts">
import { page } from '$app/state';
import { slide, fade } from 'svelte/transition';
import { onMount } from 'svelte';
import { LOCATIONS } from '$lib/locations-object.svelte';
import { nearestStationsState } from '$lib/geohash.svelte';
import '$lib/global.css';
@@ -10,6 +14,8 @@
import { IconHome, IconDialpad, IconSettings, IconHelp, IconDots } from '@tabler/icons-svelte';
onMount(() => LOCATIONS.init());
let { children } = $props();
// Navigation State
@@ -18,9 +24,9 @@
const navItems = [
{ label: 'Home', path: '/', icon: IconHome },
{ label: 'PIS', path: '/pis', icon: IconDialpad },
{ label: 'Options', path: '/preferences', icon: IconSettings },
{ label: 'About', path: '/about', icon: IconHelp }
{ label: 'PIS', path: '/pis/', icon: IconDialpad },
{ label: 'Options', path: '/preferences/', icon: IconSettings },
{ label: 'About', path: '/about/', icon: IconHelp }
];
let navWidth = $state(0);
@@ -34,7 +40,7 @@
if (navWidth === 0) return navItems.length;
const available = navWidth;
const totalItems = navItems.length;
const countWithoutMore = Math.floor(available/ ITEM_WIDTH);
const countWithoutMore = Math.floor(available / ITEM_WIDTH);
if (countWithoutMore >= totalItems) return totalItems;
@@ -78,7 +84,7 @@
<nav bind:clientWidth={navWidth}>
<!-- Dynamic Nav Elements Here! -->
{#each visibleItems as item}
{@const isActive = activePath === item.path}
{@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')}
<a
href={item.path}
class="nav-item"
@@ -110,7 +116,7 @@
></div>
<div class="menu-popover" transition:slide={{ axis: 'y', duration: 250 }}>
{#each hiddenItems as item}
{@const isActive = activePath === item.path}
{@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')}
<a
href={item.path}
class="menu-popover-item"
@@ -128,10 +134,11 @@
</nav>
<div class="viewport-guard">
<img src={logoPlain} alt="OwlBoard Logo" width=100 height=100>
<img src={logoPlain} alt="OwlBoard Logo" width="100" height="100" />
<h1 class="viewport-guard-title">Narrow Gauge Detected</h1>
<p>
Just as trains need the right track width, our data needs a bit more room to stay on the rails. Please expand your view to at least 300px to view the app.
Just as trains need the right track width, our data needs a bit more room to stay on the rails.
Please expand your view to at least 300px to view the app.
</p>
</div>
@@ -156,10 +163,12 @@
.page-title {
font-family: 'URW Gothic', sans-serif;
font-weight: 600;
font-size: clamp(0.9rem, 2.5vw + 0.8rem, 2rem);
font-style: normal;
margin-left: 5px;
padding-bottom: 2px;
color: var(--color-title);
text-transform: capitalize;
}
header,
nav {
@@ -193,7 +202,8 @@
box-shadow: var(--shadow-up);
}
.nav-item, .more-menu-wrapper {
.nav-item,
.more-menu-wrapper {
display: flex;
flex: 1;
flex-direction: column;
@@ -317,8 +327,10 @@
margin: auto;
padding-top: 30px;
}
header, main, nav {
header,
main,
nav {
display: none;
}
}

View File

@@ -1,3 +1,4 @@
export const prerender = true;
export const trailingSlash = 'always';
export const csr = true;
export const csr = true;
export const ssr = false;

View File

@@ -1,19 +1,20 @@
<script lang="ts">
import Button from '$lib/components/ui/Button.svelte';
import Textbox from '$lib/components/ui/Textbox.svelte';
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
function test() {
console.log('Button Clicked');
}
import LocationBoardCard from '$lib/components/ui/cards/LocationBoardCard.svelte';
import NearbyStationsCard from '$lib/components/ui/cards/NearbyStationsCard.svelte';
</script>
<br /><br /><br />
<Button>Default</Button>
<Button color={'brand'} onclick={test}>Brand</Button>
<Button color={'accent'}>Accent</Button>
<Textbox placeholder={"Textbox am I"} uppercase={true} error={""} />
<div class="card-container">
<LocationBoardCard />
<NearbyStationsCard />
</div>
<BaseCard header={"Hello"} helpText={"This is help text"}>Hello</BaseCard>
<h2>OwlBoard</h2>
<style>
.card-container {
display: flex;
align-items: center;
flex-direction: column;
gap: 20px;
justify-content: center;
padding: 20px 10px;
}
</style>

View File

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

View File

@@ -0,0 +1,13 @@
<section>Live board are not yet implemented on the server</section>
<style>
section {
font-family: 'URW Gothic', sans-serif;
text-align: center;
font-size: 2rem;
width: 90%;
margin: auto;
padding-top: 25px;
max-width: 500px;
}
</style>

37
src/routes/board/+page.ts Normal file
View File

@@ -0,0 +1,37 @@
import { LOCATIONS } from '$lib/locations-object.svelte';
import type { PageLoad } from './$types';
import { error } from '@sveltejs/kit';
export const load: PageLoad = async ({ url }) => {
const locId = url.searchParams.get('loc');
if (!LOCATIONS.loaded) {
await LOCATIONS.init();
}
let title: string = '';
if (!locId) {
error(400, {
message: 'Location not provided',
owlCode: 'NO_LOCATION_IN_PATH'
});
}
if (locId) {
const location = LOCATIONS.find(locId);
if (location) {
title = location.n || location.t;
} else {
error(404, {
message: `Location (${locId.toUpperCase()}) not found`,
owlCode: 'INVALID_LOCATION_CODE'
});
}
}
return {
title,
location
};
};

162
src/routes/pis/+page.svelte Normal file
View File

@@ -0,0 +1,162 @@
<script lang="ts">
import PisStartEndCard from '$lib/components/ui/cards/pis/PisStartEndCard.svelte';
import PisCode from '$lib/components/ui/cards/pis/PisCode.svelte';
import Button from '$lib/components/ui/Button.svelte';
import type { ApiPisObject } from '@owlboard/owlboard-ts';
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
import TocStyle from '$lib/components/ui/TocStyle.svelte';
let results = $state<ApiPisObject.PisObjects[]>([]);
let resultsLoaded = $state<boolean>(false);
let errorState = $state<{ status: number; message: string } | null>(null);
async function handleStartEndSearch(start: string, end: string): Promise<void> {
console.log(`PIS Search: ${start}-${end}`);
errorState = null;
try {
const response = await OwlClient.pis.getByStartEndCrs(start, end);
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;
}
}
async function handleCodeSearch(code: string) {
console.log(`PIS Search: ${code}`);
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;
}
}
function clearResults() {
console.log('Clearing Results');
resultsLoaded = false;
results = [];
}
</script>
{#if !resultsLoaded}
<div class="card-container">
<PisStartEndCard onsearch={handleStartEndSearch} />
<PisCode onsearch={handleCodeSearch} />
</div>
{:else}
<div class="result-container">
{#if errorState}
<span class="errCode">Error: {errorState.status}</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}
<p class="no-results">No matching results</p>
{/if}
<div class="reset-button-container">
<Button onclick={clearResults}>Reset</Button>
</div>
</div>
{/if}
<style>
.card-container {
display: flex;
align-items: center;
flex-direction: column;
gap: 20px;
justify-content: center;
padding: 20px 10px;
}
.result-container {
font-family: 'URW Gothic', sans-serif;
font-size: 1.2rem;
font-weight: 600;
display: flex;
align-items: center;
flex-direction: column;
gap: 5px;
justify-content: center;
background: var(--color-accent);
border-radius: 15px;
padding: 20px 0 20px 0;
margin: auto;
margin-top: 25px;
margin-bottom: 25px;
width: 90%;
max-width: 500px;
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>

5
src/routes/pis/+page.ts Normal file
View File

@@ -0,0 +1,5 @@
export const load = () => {
return {
title: 'PIS Codes'
};
};

View File

@@ -2,15 +2,19 @@ import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: 'true',
strict: 'true'
})
}
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: 'true',
strict: 'true'
}),
prerender: {
// Temporary option during testing
handleHttpError: 'ignore'
}
}
};
export default config;

View File

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