Compare commits

..

8 Commits

Author SHA1 Message Date
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
17 changed files with 337 additions and 87 deletions

1
.npmrc
View File

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

24
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.20260319T2004",
"@playwright/test": "^1.58.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
@@ -778,6 +779,23 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@owlboard/api-schema-types": {
"version": "3.0.1-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",
"integrity": "sha512-5CVm1k/C++/VrtAw4NkvclDunH+RmYLnDZZMSWTM1mm+WlEVnmD+MVnTgC/FhcsAmsNHV8swm66RCqkCuhbOnA==",
"dev": true,
"license": "MIT"
},
"node_modules/@owlboard/owlboard-ts": {
"version": "3.0.0-dev.20260319T2004",
"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",
"integrity": "sha512-pphq1/l/8eOH4C0O7ocwBOUzt0HkCWGUlhy1itzKnQbmog7oPUEdyaxzS4Evw8onLsxZwkyqsLAyK7okYi+4XA==",
"dev": true,
"license": "GPL-3.0",
"dependencies": {
"@owlboard/api-schema-types": "^3.0.1-alpha3"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
@@ -2452,9 +2470,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"
},

View File

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

View File

@@ -6,8 +6,6 @@
import { LOCATIONS } from '$lib/locations-object.svelte.ts';
let { value = $bindable() } = $props();
let showResults = $state(false);
@@ -15,7 +13,6 @@
const MAX_RESULTS = 5;
function tokenize(query: string) {
return query.toLowerCase().trim().split(/\s+/).filter(Boolean);
}
@@ -41,12 +38,11 @@
return a.n.localeCompare(b.n);
})
.slice(0, MAX_RESULTS);
})
});
$effect(() => {
if (results) selectedIndex = -1;
});
if (results) selectedIndex = -1;
});
// Hide results when click outside of container
$effect(() => {
@@ -68,7 +64,7 @@
selectedIndex = -1;
value = '';
console.log('Selected Location: ', JSON.stringify(loc));
const queryString = loc.c || loc.t
const queryString = loc.c || loc.t;
goto(`/board?loc=${queryString.toLowerCase()}`);
}

View File

@@ -72,6 +72,7 @@
transition: all 0.2s ease-in-out;
outline: none;
text-align: center;
box-shadow: var(--shadow-std);
}
.all-caps {

View File

@@ -60,6 +60,7 @@
font-family: 'URW Gothic', sans-serif;
color: var(--color-brand);
padding: 10px 0;
box-shadow: var(--shadow-std);
}
.header-content {

View File

@@ -0,0 +1,54 @@
<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,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: (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>

View File

@@ -1,38 +1,37 @@
interface LocationRecord {
n: string; // name
t: string; // tiploc
c?: string; // crs
s: string; // search string
}
class LocationStore {
data = $state<LocationRecord[]>([]);
loaded = $state(false);
async init(fetcher = fetch) {
if (this.loaded) return;
try {
const res = await fetcher('/api/tiplocs');
this.data = await res.json();
this.loaded = true;
} catch (err) {
console.error('Failed to load locations', err);
}
}
find(id: string | null): LocationRecord | 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;
});
}
interface LocationRecord {
n: string; // name
t: string; // tiploc
c?: string; // crs
s: string; // search string
}
export const LOCATIONS = new LocationStore();
class LocationStore {
data = $state<LocationRecord[]>([]);
loaded = $state(false);
async init(fetcher = fetch) {
if (this.loaded) return;
try {
const res = await fetcher('/api/tiplocs');
this.data = await res.json();
this.loaded = true;
} catch (err) {
console.error('Failed to load locations', err);
}
}
find(id: string | null): LocationRecord | 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

@@ -162,6 +162,7 @@
.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;

View File

@@ -30,8 +30,8 @@
</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
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">

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
<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 { PisObjects } from '@owlboard/api-schema-types';
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
let results = $state<PisObjects[]>([]);
let resultsLoaded = $state<boolean>(false);
let errorState = $state<{status: number, message: message} | null>(null);
async function handleStartEndSearch(start: string, end: string): Promise<void> {
console.log(`PIS Search: ${start}-${end}`);
errorState = null;
try {
results = await OwlClient.pis.getByStartEndCrs(start, end);
} catch (e) {
if (e instanceof ValidationError) {
errorState = { status: 400, message: e.message };
} else if (e instanceof ApiError) {
errorState = { status: 500, message: e.message };
} else {
errorState = { status: 0, message: `Unknown Error: ${e.message}` };
}
}
resultsLoaded = true;
}
function handleCodeSearch(code: string) {
console.log(`PIS Search: ${code}`);
// Fetch API Results Here
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">{errorState.status}</span>
<span class="errMsg">{errorState.message}</span>
{:else}
{#if !results.length}
<p class="no-results">No results found</p>
{:else}
<p class="no-results">Results Loaded - Display logic not present</p>
{/if}{/if}
<Button onclick={clearResults}>Reset</Button>
</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: 0 0 20px 0;
margin: auto;
margin-top: 25px;
width: 90%;
max-width: 1000px;
box-shadow: var(--shadow-std);
}
</style>

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

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

View File

@@ -100,5 +100,7 @@
{"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":"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"}
]