Compare commits
42 Commits
v3.0.0-dev
...
v3.0.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 24960707e2 | |||
| 91cb119b7d | |||
| 26e40c5bf6 | |||
| a746a1eac2 | |||
| 68af07b9bd | |||
| 16d929fad1 | |||
| 5bbffcecb8 | |||
| 5ead4f8296 | |||
| abb8663766 | |||
| 3225b60140 | |||
| f7b1b7fe0d | |||
| 8c0d385772 | |||
| a07315cec2 | |||
| f3393f3c07 | |||
| b649af1925 | |||
| aa1a989139 | |||
| 304b523127 | |||
| 777519ff5d | |||
| 4a969e626c | |||
| 3e1b7ea5d5 | |||
| fd213d6340 | |||
| 3eceddf20a | |||
| 1d461780ab | |||
| ec4dd5dd3b | |||
| a7c244171c | |||
| 3467f97889 | |||
| b1d8eea518 | |||
| deb151075a | |||
| d9b60daa8b | |||
| 2f0a6b9646 | |||
| 1165c02e26 | |||
| 45dd5a1cf5 | |||
| e47bebe7d4 | |||
| b7007d2fb3 | |||
| 64bc5b979d | |||
| 3240560a0b | |||
| a327582629 | |||
| 35877ae8ac | |||
| f5c3775f59 | |||
| e5d3c0a3a7 | |||
| 1b0b93b34b | |||
| 66ed33343c |
@@ -3,7 +3,7 @@ run-name: ${{ gitea.actor }} is building and pushing
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
create:
|
create:
|
||||||
tags: "*"
|
tags: '*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_DOMAIN: git.fjla.uk
|
GITEA_DOMAIN: git.fjla.uk
|
||||||
@@ -36,4 +36,4 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.GITEA_DOMAIN }}/${{ env.RESULT_IMAGE_NAME }}:${{ gitea.ref_name }}
|
${{ 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
1
.npmrc
@@ -1 +1,2 @@
|
|||||||
engine-strict=true
|
engine-strict=true
|
||||||
|
@owlboard:registry=https://git.fjla.uk/api/packages/OwlBoard/npm/
|
||||||
1867
package-lock.json
generated
1867
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@
|
|||||||
"vitest-browser-svelte": "^2.0.2"
|
"vitest-browser-svelte": "^2.0.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@owlboard/owlboard-ts": "^3.0.0-dev.20260503T0051",
|
||||||
"@tabler/icons-svelte": "^3.40.0"
|
"@tabler/icons-svelte": "^3.40.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/app.d.ts
vendored
5
src/app.d.ts
vendored
@@ -2,7 +2,10 @@
|
|||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
interface Error {
|
||||||
|
message: string;
|
||||||
|
owlCode?: string;
|
||||||
|
}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<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
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Live station departures, Live train tracking, PIS Codes & more"
|
content="Live station departures, Live train tracking, PIS Codes & more"
|
||||||
|
|||||||
1
src/lib/assets/img/no-data.svg
Normal file
1
src/lib/assets/img/no-data.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"><path fill="#fff" d="M0 0h120v120H0z"/><g fill="#00f"><path d="M60 0h60v60H60zM0 60h60v60H0z"/><path d="M12.72 32.97h1.08l1.22-4.9 1.2 4.9h1.08l2.16-7.4h-1.4l-1.3 5.17-1.25-5.16h-1l-1.26 5.16-1.3-5.16h-1.4zm12.6-5.54H24.1v.74a2 2 0 0 0-1.8-.87c-1.65 0-2.83 1.23-2.83 2.92 0 1.67 1.17 2.88 2.8 2.88a2.2 2.2 0 0 0 1.83-.85v.72h1.22zm-2.89 1.09c.95 0 1.63.7 1.63 1.72 0 .4-.16.86-.4 1.14-.27.33-.7.5-1.2.5-.98 0-1.65-.66-1.65-1.63 0-1.01.67-1.73 1.62-1.73m3.88 4.45h1.33v-3.08c-.03-.82.4-1.28 1.2-1.31V27.3h-.1c-.58 0-.86.16-1.2.67v-.54H26.3zm3.17 0h1.33v-2.61c0-.74.05-1.06.22-1.33q.33-.5.98-.51.53 0 .8.35c.18.24.26.65.26 1.39v2.7h1.33V30c0-1-.1-1.47-.4-1.9q-.56-.79-1.73-.8a2 2 0 0 0-1.56.66v-.53h-1.23zm6 0h1.33v-5.54h-1.33zm0-6.14h1.33v-1.25h-1.33zm2.4 6.14h1.33v-2.61c0-.74.05-1.06.22-1.33q.33-.5.98-.51.53 0 .8.35c.18.24.26.65.26 1.39v2.7h1.33V30c0-1-.1-1.47-.4-1.9q-.56-.79-1.73-.8a2 2 0 0 0-1.56.66v-.53h-1.23zm10.3-5.54v.7q-.62-.83-1.8-.83c-1.58 0-2.74 1.22-2.74 2.86 0 1.7 1.14 2.94 2.72 2.94.78 0 1.3-.25 1.82-.86q-.07 1.78-1.55 1.77c-.62 0-1-.16-1.39-.6h-1.5c.44 1.12 1.48 1.77 2.84 1.77 1 0 1.8-.35 2.31-1.01.4-.51.56-1.2.56-2.25v-4.5zm-1.66 1.09c.95 0 1.6.68 1.6 1.7 0 1-.62 1.66-1.58 1.66-.95 0-1.56-.65-1.56-1.66 0-1.02.61-1.7 1.54-1.7" aria-label="Warning" font-family="URW Gothic" font-size="10" font-weight="600" style="-inkscape-font-specification:"URW Gothic Semi-Bold""/><g stroke-width="0" aria-label="No Data" font-family="URW Gothic" font-size="10" font-weight="600" style="-inkscape-font-specification:"URW Gothic Oblique";text-align:center" text-anchor="middle"><path d="M83.98 87.39h1.37v-5.37l3.1 5.37h1.52V80H88.6v5.38L85.54 80h-1.56zm9.95-5.67a2.93 2.93 0 0 0-2.93 2.9c0 1.6 1.32 2.9 2.94 2.9s2.95-1.3 2.95-2.86a2.9 2.9 0 0 0-2.96-2.94m.01 1.22c.9 0 1.61.75 1.61 1.68s-.72 1.68-1.6 1.68c-.9 0-1.6-.75-1.6-1.7 0-.91.71-1.66 1.6-1.66M79.24 99.89h1.72c1.12 0 1.79-.11 2.39-.41 1.14-.56 1.8-1.78 1.8-3.27 0-1.41-.57-2.56-1.6-3.18a5 5 0 0 0-2.62-.53h-1.7zm1.37-1.31v-4.77h.27c.88 0 1.34.08 1.77.3.7.36 1.13 1.15 1.13 2.1 0 .89-.41 1.68-1.07 2.04-.41.22-.98.33-1.8.33zm11.1-4.23h-1.22v.74a2 2 0 0 0-1.81-.87c-1.64 0-2.82 1.23-2.82 2.92 0 1.67 1.17 2.88 2.79 2.88a2.2 2.2 0 0 0 1.84-.85v.72h1.22zm-2.9 1.09c.96 0 1.64.71 1.64 1.72 0 .4-.16.86-.4 1.14-.27.33-.7.5-1.21.5-.97 0-1.64-.66-1.64-1.63 0-1.01.67-1.73 1.62-1.73m4.26 4.45h1.33v-4.33h.8v-1.21h-.8V92.5h-1.33v1.85h-.65v1.21h.65zm8.33-5.54h-1.22v.74a2 2 0 0 0-1.81-.87c-1.64 0-2.82 1.23-2.82 2.92 0 1.67 1.17 2.88 2.79 2.88a2.2 2.2 0 0 0 1.84-.85v.72h1.22zm-2.9 1.09c.96 0 1.64.71 1.64 1.72 0 .4-.16.86-.4 1.14q-.42.5-1.21.5c-.97 0-1.64-.66-1.64-1.63 0-1.01.67-1.73 1.62-1.73" style="-inkscape-font-specification:"URW Gothic Semi-Bold""/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -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: 98px;
|
||||||
|
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>
|
||||||
|
|||||||
66
src/lib/components/ui/Loading.svelte
Normal file
66
src/lib/components/ui/Loading.svelte
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { message = 'Loading...' } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="track">
|
||||||
|
<div class="shuttle"></div>
|
||||||
|
</div>
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
width: 75%;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
width: 160px;
|
||||||
|
height: 3px;
|
||||||
|
background-color: var(--color-title);
|
||||||
|
border-radius: 4px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shuttle {
|
||||||
|
position: absolute;
|
||||||
|
width: 50%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(90deg, #1abc9c 0%, #3498db 100%);
|
||||||
|
animation: data-travel 1.6s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--color-title);
|
||||||
|
animation: pulse 2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes data-travel {
|
||||||
|
0% {
|
||||||
|
left: -50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
220
src/lib/components/ui/LocationSearchBox.svelte
Normal file
220
src/lib/components/ui/LocationSearchBox.svelte
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
<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 - Exact Name Match
|
||||||
|
const aNameLow = a.n.toLowerCase();
|
||||||
|
const bNameLow = b.n.toLowerCase();
|
||||||
|
const aExactName = aNameLow === lowerQuery;
|
||||||
|
const bExactName = bNameLow === lowerQuery;
|
||||||
|
if (aExactName !== bExactName) return aExactName ? -1 : 1;
|
||||||
|
|
||||||
|
// Priority Three - Name starts with Query
|
||||||
|
const aStarts = aNameLow.startsWith(lowerQuery);
|
||||||
|
const bStarts = bNameLow.startsWith(lowerQuery);
|
||||||
|
if (aStarts !== bStarts) return aStarts ? -1 : 1;
|
||||||
|
|
||||||
|
// Priority Four - '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>
|
||||||
55
src/lib/components/ui/NoResults.svelte
Normal file
55
src/lib/components/ui/NoResults.svelte
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import noResult from '$lib/assets/img/no-data.svg';
|
||||||
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
let { title = 'No results', message = 'Try checking your search term' } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="no-results-state">
|
||||||
|
<img src={noResult} class="image" height="200" width="200" alt="" role="presentation" />
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p>{message}</p>
|
||||||
|
<div class="btn-container">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (history.length > 1 ? history.back() : (window.location.href = '/'))}
|
||||||
|
color="accent"
|
||||||
|
>
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.no-results-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
padding: 5rem;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: 'URW-Gothic', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
margin-bottom: 1.95rem;
|
||||||
|
max-width: 90%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,94 +1,95 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { HTLMInputAttributes } from 'svelte/elements';
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
interface Props extends HTMLInputAttributes {
|
interface Props extends HTMLInputAttributes {
|
||||||
value?: string;
|
value?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url';
|
type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url';
|
||||||
error?: string;
|
error?: string;
|
||||||
uppercase?: boolean;
|
uppercase?: boolean;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
label,
|
label,
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
type = 'text',
|
type = 'text',
|
||||||
error = '',
|
error = '',
|
||||||
uppercase = false,
|
uppercase = false,
|
||||||
...rest
|
...rest
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let isFocussed = $state(false);
|
let isFocussed = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="input-wrapper" class:focussed={isFocussed} class:has-error={!!error}>
|
<div class="input-wrapper" class:focussed={isFocussed} class:has-error={!!error}>
|
||||||
{#if label}
|
{#if label}
|
||||||
<label for="adaptive-input">{label}</label>
|
<label for="adaptive-input">{label}</label>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
id="adaptive-input"
|
id="adaptive-input"
|
||||||
class:all-caps={uppercase}
|
class:all-caps={uppercase}
|
||||||
{type}
|
{type}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
bind:value={value}
|
bind:value
|
||||||
onfocus={() => isFocussed = true}
|
onfocus={() => (isFocussed = true)}
|
||||||
onblur={() => isFocussed = false}
|
onblur={() => (isFocussed = false)}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<span class="error-message" transition:fade>{error}</span>
|
<span class="error-message" transition:fade>{error}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.input-wrapper {
|
.input-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: 'URW Gothic', sans-serif;
|
font-family: 'URW Gothic', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--color-title)
|
color: var(--color-title);
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
min-height: 48px;
|
min-height: 40px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
background-color: var(--color-title);
|
background-color: var(--color-title);
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
color: var(--color-bg-dark);
|
color: var(--color-bg-dark);
|
||||||
font-size: 1.5rem;
|
font-size: 1.2rem;
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
outline: none;
|
outline: none;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
box-shadow: var(--shadow-std);
|
||||||
|
}
|
||||||
|
|
||||||
.all-caps {
|
.all-caps {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.focussed input {
|
.focussed input {
|
||||||
border-color: var(--color-bg-light);
|
border-color: var(--color-bg-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.has-error input {
|
.has-error input {
|
||||||
border-color: #ff4d4d;
|
border-color: #ff4d4d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
color: #ff4d4d;
|
color: #ff4d4d;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
44
src/lib/components/ui/TimezoneWarning.svelte
Normal file
44
src/lib/components/ui/TimezoneWarning.svelte
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<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>
|
||||||
77
src/lib/components/ui/TocStyle.svelte
Normal file
77
src/lib/components/ui/TocStyle.svelte
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
toc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { toc }: Props = $props();
|
||||||
|
|
||||||
|
let code = $derived(toc.toUpperCase());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- SPACE MONO for the font? -->
|
||||||
|
<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;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
background-color: #333;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.AW {
|
||||||
|
/* Transport for Wales */
|
||||||
|
background: red;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.LM {
|
||||||
|
/* West Midlands Trains */
|
||||||
|
background: rgb(176, 115, 1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.GW {
|
||||||
|
/* Great Western Railway */
|
||||||
|
background: #004225;
|
||||||
|
color: #e2e2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.GR {
|
||||||
|
/* LNER */
|
||||||
|
background-color: #c00000;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.VT {
|
||||||
|
/* Avanti West Coast */
|
||||||
|
background-color: #004354;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.GN {
|
||||||
|
/* Great Northern */
|
||||||
|
background-color: fuchsia;
|
||||||
|
color: rgb(229, 229, 229);
|
||||||
|
}
|
||||||
|
|
||||||
|
.SW {
|
||||||
|
/* South Western Railway */
|
||||||
|
background-color: #2a3389;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.XC {
|
||||||
|
/* CrossCountry */
|
||||||
|
background-color: #660000;
|
||||||
|
color: #e4d5b1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
293
src/lib/components/ui/TrainService.svelte
Normal file
293
src/lib/components/ui/TrainService.svelte
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts';
|
||||||
|
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { quintOut } from 'svelte/easing';
|
||||||
|
import { formatUkTime } from '$lib/utils/time';
|
||||||
|
import TocStyle from '$lib/components/ui/TocStyle.svelte';
|
||||||
|
|
||||||
|
let { service }: { service: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse } = $props();
|
||||||
|
let isExpanded = $state(false);
|
||||||
|
let loadingDetails = $state(false);
|
||||||
|
let loadingDetailsError = $state(false);
|
||||||
|
let loadingDetailsErrorMsg = $state("");
|
||||||
|
let details = $state(null);
|
||||||
|
|
||||||
|
const toggleExpand = async (rid: string) => {
|
||||||
|
if (isExpanded) {
|
||||||
|
isExpanded = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details) {
|
||||||
|
isExpanded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingDetails = true;
|
||||||
|
try {
|
||||||
|
const result = await OwlClient.trains.getByRid(service.r);
|
||||||
|
details = result.data;
|
||||||
|
isExpanded = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.Error('Failed to load train details');
|
||||||
|
loadingDetailsError = true;
|
||||||
|
loadingDetailsErrorMsg = e.message;
|
||||||
|
} finally {
|
||||||
|
loadingDetails = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let OriginDepartureSummary = $derived(formatUkTime(service.od));
|
||||||
|
|
||||||
|
async function loadDetails(rid: string) {
|
||||||
|
if (details) return;
|
||||||
|
loadingDetails = true;
|
||||||
|
|
||||||
|
const result = await OwlClient.trains.getByRid(service.r);
|
||||||
|
details = result.data;
|
||||||
|
loadingDetails = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const estClass = (act, est) => (!act && est) ? 'est' : 'act';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="train-service">
|
||||||
|
<button class="summary" onclick={toggleExpand} type="button" aria-expanded={isExpanded}>
|
||||||
|
<div class="operator-summary">
|
||||||
|
<TocStyle toc={service.o} />
|
||||||
|
</div>
|
||||||
|
<div class="main-text-summary">
|
||||||
|
<div class="time-summary">
|
||||||
|
{OriginDepartureSummary}
|
||||||
|
</div>
|
||||||
|
<div class="location-summary">
|
||||||
|
{service.ot}
|
||||||
|
</div>
|
||||||
|
<div class="location-summary to-summary">to</div>
|
||||||
|
<div class="location-summary">
|
||||||
|
{service.dt}
|
||||||
|
</div>
|
||||||
|
<!-- Add arrow icon to signify drop-down -->
|
||||||
|
<!-- ADD LOADING STATE -->
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{#if isExpanded && details}
|
||||||
|
<div class="box-ext" transition:slide={{ duration: 800, easing: quintOut }}>
|
||||||
|
<!-- Here goes the data formatting! -->
|
||||||
|
<div class="detail-head">
|
||||||
|
<!-- Cancellation Section -->
|
||||||
|
{#if details.header.cr}
|
||||||
|
<span class="cancel-reason">
|
||||||
|
{details.header.cr}
|
||||||
|
{#if details.header.cl}
|
||||||
|
{details.header.cn ? ' near ' : ' at '}
|
||||||
|
{details.header.cl}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delay Section -->
|
||||||
|
{#if details.header.dr}
|
||||||
|
<span class="delay-reason">
|
||||||
|
{details.header.dr}
|
||||||
|
{#if details.header.dl}
|
||||||
|
{details.header.dn ? ' near ' : ' at '}
|
||||||
|
{details.header.dl}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<div class="pis-detail">
|
||||||
|
<!-- PIS Data Here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="schedule-table-container">
|
||||||
|
<table class="schedule-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th colspan="2"></th>
|
||||||
|
<th colspan="2">Arr</th>
|
||||||
|
<th colspan="2">Dep</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Plat</th>
|
||||||
|
<th>Sch</th>
|
||||||
|
<th>Act</th>
|
||||||
|
<th>Sch</th>
|
||||||
|
<th>Act</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each details.locations as loc}
|
||||||
|
<tr class:pass-loc={loc.r === "PASS"} class:can-loc={loc.can}>
|
||||||
|
<td class="tpl-cell">{loc.t}</td>
|
||||||
|
<td class="plat-cell">{loc.p}</td>
|
||||||
|
{#if loc.r == "PASS"}
|
||||||
|
<td class="time-cell" colspan="2">Pass</td>
|
||||||
|
<td class="time-cell">{formatUkTime(loc.wtp)}</td>
|
||||||
|
<td class="time-cell {estClass(loc.atp, loc.etp)}">{formatUkTime(loc.atp || loc.etp || "--")}</td>
|
||||||
|
{:else}
|
||||||
|
<td class="time-cell">{formatUkTime(loc.pta || loc.wta || "--")}</td>
|
||||||
|
<td class="time-cell {estClass(loc.ata, loc.eta)}">{formatUkTime(loc.ata || loc.eta || "--")}</td>
|
||||||
|
<td class="time-cell">{formatUkTime(loc.ptd || loc.wtd || "--")}</td>
|
||||||
|
<td class="time-cell {estClass(loc.atd, loc.etd)}">{formatUkTime(loc.atd || loc.etd || "--")}</td>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.train-service {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: var(--shadow-std);
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
transition: 0.2s all;
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary:hover {
|
||||||
|
filter:invert();
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
min-height: 48px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-summary {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-text-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-summary {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-summary {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--color-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.to-summary {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: oblique;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-ext {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-reason, .delay-reason {
|
||||||
|
display: block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-reason {
|
||||||
|
color:rgb(255, 0, 0);
|
||||||
|
font-weight: 500;
|
||||||
|
animation: cancel-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-reason {
|
||||||
|
color:rgb(255, 119, 0);
|
||||||
|
font-weight: 500;
|
||||||
|
animation: cancel-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cancel-pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
text-shadow: 0 0 0px rgb(255, 0, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.8;
|
||||||
|
text-shadow: 0 0 5px rgba(255,0,0,0.2)
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
text-shadow: 0 0 0px rgb(255, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-table-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-table {
|
||||||
|
width: 95%;
|
||||||
|
max-width: 375px;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-cell {
|
||||||
|
color: yellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-loc {
|
||||||
|
color: var(--color-title);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.can-loc {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.est {
|
||||||
|
font-style: oblique;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.act {
|
||||||
|
color: yellow;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,94 +1,102 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { IconHelpCircle } from '@tabler/icons-svelte';
|
import { IconHelpCircle } from '@tabler/icons-svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
header?: string;
|
header?: string;
|
||||||
helpText?: string;
|
helpText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { children, header = '', helpText }: Props = $props();
|
||||||
children,
|
|
||||||
header = "",
|
|
||||||
helpText,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let showHelp = $state(false);
|
let showHelp = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{#if header || helpText}
|
{#if header || helpText}
|
||||||
<header class="card-header">
|
<header class="card-header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
{header}
|
{header}
|
||||||
</div>
|
</div>
|
||||||
{#if helpText}
|
{#if helpText}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="help-toggle"
|
class="help-toggle"
|
||||||
onclick={() => showHelp = !showHelp}
|
onclick={() => (showHelp = !showHelp)}
|
||||||
aria-label="Show Help"
|
aria-label="Show Help"
|
||||||
>
|
>
|
||||||
<IconHelpCircle size={26} stroke={2.25} color={showHelp ? 'var(--color-brand)' : 'var(--color-title)'} />
|
<IconHelpCircle
|
||||||
</button>
|
size={26}
|
||||||
{/if}
|
stroke={2.25}
|
||||||
</header>
|
color={showHelp ? 'var(--color-brand)' : 'var(--color-title)'}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
{#if showHelp && helpText}
|
{#if showHelp && helpText}
|
||||||
<div class="help-drawer" transition:slide={{ duration: 400 }}>
|
<div class="help-drawer" transition:slide={{ duration: 400 }}>
|
||||||
<p>{helpText}</p>
|
<p>{helpText}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.card {
|
.card {
|
||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
width: 95%;
|
width: 95%;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: 'URW Gothic', sans-serif;
|
font-family: 'URW Gothic', sans-serif;
|
||||||
color: var(--color-title);
|
color: var(--color-brand);
|
||||||
}
|
padding: 10px 0;
|
||||||
|
box-shadow: var(--shadow-std);
|
||||||
|
}
|
||||||
|
|
||||||
.header-content { flex: 1;
|
.header-content {
|
||||||
font-size: 1.5rem; font-weight: 600; }
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.help-toggle {
|
.help-toggle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
cursor: help;
|
cursor: help;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
transition: opacity 0.2s, transform 0.2s;
|
transition:
|
||||||
}
|
opacity 0.2s,
|
||||||
|
transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
.help-toggle:hover {
|
.help-toggle:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-drawer {
|
.help-drawer {
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
padding: 4px 16px;
|
padding: 4px 16px;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
color: var(--color-title);
|
color: var(--color-title);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
37
src/lib/components/ui/cards/HeadcodeSearchCard.svelte
Normal file
37
src/lib/components/ui/cards/HeadcodeSearchCard.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
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 headcode = $state('');
|
||||||
|
|
||||||
|
function handleSearch(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!headcode.trim()) return;
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
searchParams.append('h', headcode.trim().toUpperCase());
|
||||||
|
|
||||||
|
goto(`/trains?${searchParams.toString()}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BaseCard header={'Search Train & PIS'}>
|
||||||
|
<form onsubmit={handleSearch} class="card-content">
|
||||||
|
<Textbox placeholder="Enter Headcode" bind:value={headcode} maxLength={4} />
|
||||||
|
<Button type="submit" disabled={!headcode}>Search</Button>
|
||||||
|
</form>
|
||||||
|
</BaseCard>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card-content {
|
||||||
|
text-align: center;
|
||||||
|
width: 90%;
|
||||||
|
margin: auto;
|
||||||
|
padding: 10px 0 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
25
src/lib/components/ui/cards/LocationBoardCard.svelte
Normal file
25
src/lib/components/ui/cards/LocationBoardCard.svelte
Normal 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>
|
||||||
73
src/lib/components/ui/cards/NearbyStationsCard.svelte
Normal file
73
src/lib/components/ui/cards/NearbyStationsCard.svelte
Normal 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 0 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>
|
||||||
71
src/lib/components/ui/cards/QuickLinksCard.svelte
Normal file
71
src/lib/components/ui/cards/QuickLinksCard.svelte
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<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 { quickLinks } from '$lib/quick-links.svelte';
|
||||||
|
|
||||||
|
const flipDuration = 300;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BaseCard header={'Quick Links'}>
|
||||||
|
<div class="card-content">
|
||||||
|
{#if quickLinks.list.length === 0}
|
||||||
|
<p class="msg">Your most viewed stations will appear here</p>
|
||||||
|
{:else}
|
||||||
|
<div class="stations-flex">
|
||||||
|
{#each quickLinks.list as station (station.id)}
|
||||||
|
<div
|
||||||
|
class="btn-container"
|
||||||
|
animate:flip={{ duration: flipDuration }}
|
||||||
|
in:fade|global={{ duration: 200 }}
|
||||||
|
>
|
||||||
|
<Button href={`/board?loc=${station.id}`}
|
||||||
|
><span class="stn-name">{station.id}</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>
|
||||||
60
src/lib/components/ui/cards/pis/PisCode.svelte
Normal file
60
src/lib/components/ui/cards/pis/PisCode.svelte
Normal 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>
|
||||||
59
src/lib/components/ui/cards/pis/PisStartEndCard.svelte
Normal file
59
src/lib/components/ui/cards/pis/PisStartEndCard.svelte
Normal 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>
|
||||||
96
src/lib/geohash.svelte.ts
Normal file
96
src/lib/geohash.svelte.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
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 initGeoConfig: PositionOptions = {
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
timeout: 500,
|
||||||
|
maximumAge: Infinity
|
||||||
|
};
|
||||||
|
|
||||||
|
private geoConfig: PositionOptions = {
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
timeout: 20000,
|
||||||
|
maximumAge: 30000
|
||||||
|
};
|
||||||
|
|
||||||
|
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.initGeoConfig
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/lib/locations-object.svelte.ts
Normal file
33
src/lib/locations-object.svelte.ts
Normal 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
21
src/lib/owlClient.ts
Normal 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 };
|
||||||
60
src/lib/quick-links.svelte.ts
Normal file
60
src/lib/quick-links.svelte.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
export interface QuickLink {
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
lastAccessed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETURNED_LENGTH: number = 9;
|
||||||
|
const MAX_SCORE: number = 50;
|
||||||
|
const MAX_ENTRIES: number = RETURNED_LENGTH * 4;
|
||||||
|
|
||||||
|
class QuickLinksService {
|
||||||
|
#links = $state<QuickLink[]>([]);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const saved = localStorage.getItem('ql');
|
||||||
|
if (saved) {
|
||||||
|
this.#links = JSON.parse(saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get list(): QuickLink[] {
|
||||||
|
return this.#links.slice(0, RETURNED_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordVisit(id: string) {
|
||||||
|
if (id == '') return;
|
||||||
|
const existing = this.#links.find((l) => l.id === id);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.score += 1;
|
||||||
|
existing.lastAccessed = Date.now();
|
||||||
|
} else {
|
||||||
|
this.#links.push({
|
||||||
|
id: id,
|
||||||
|
score: 1,
|
||||||
|
lastAccessed: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score decay - if MAX_SCORE reached, divide all by two
|
||||||
|
if (this.#links.some((l) => l.score > MAX_SCORE)) {
|
||||||
|
this.#links.forEach((l) => {
|
||||||
|
l.score = Math.max(1, Math.floor(l.score / 2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort & Prune
|
||||||
|
const sorted = [...this.#links].sort(
|
||||||
|
(a, b) => b.score - a.score || b.lastAccessed - a.lastAccessed
|
||||||
|
);
|
||||||
|
|
||||||
|
this.#links = sorted.slice(0, MAX_ENTRIES);
|
||||||
|
|
||||||
|
localStorage.setItem('ql', JSON.stringify(this.#links));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const quickLinks = new QuickLinksService();
|
||||||
17
src/lib/utils/time.ts
Normal file
17
src/lib/utils/time.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Converts ISO/JSON time to UK-formatted HH:MM string.
|
||||||
|
* Ensures Europe/London timezone irrespective of browser timezone.
|
||||||
|
*/
|
||||||
|
export function formatUkTime(dateStr: string | Date | undefined): string {
|
||||||
|
if (!dateStr) return '--:--';
|
||||||
|
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) return '--:--';
|
||||||
|
|
||||||
|
return date.toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
timeZone: 'Europe/London'
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,12 +2,18 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
|
||||||
import stopErr from '$lib/assets/img/stop-error.svg';
|
import stopErr from '$lib/assets/img/stop-error.svg';
|
||||||
|
import noResult from '$lib/assets/img/no-data.svg';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="error-wrapper">
|
<div class="error-wrapper">
|
||||||
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
|
{#if page.status == 404}
|
||||||
|
<!-- Warning no data image -->
|
||||||
|
<img class="err-img" src={noResult} alt="" role="presentation" width="200" height="200" />
|
||||||
|
{:else}
|
||||||
|
<!-- STOP Error image -->
|
||||||
|
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
|
||||||
|
{/if}
|
||||||
<h2 class="label">{page.status}</h2>
|
<h2 class="label">{page.status}</h2>
|
||||||
|
|
||||||
<p class="error-message">
|
<p class="error-message">
|
||||||
@@ -58,13 +64,15 @@
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: var(--color-title);
|
color: var(--color-title);
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.debug-info {
|
.debug-info {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
padding: 5px 12px;
|
padding: 5px 15px;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { slide, fade } from 'svelte/transition';
|
import { slide, fade } from 'svelte/transition';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { navigating } from '$app/state';
|
||||||
|
import { LOCATIONS } from '$lib/locations-object.svelte';
|
||||||
|
import { nearestStationsState } from '$lib/geohash.svelte';
|
||||||
|
|
||||||
|
import Loading from '$lib/components/ui/Loading.svelte';
|
||||||
|
import TimezoneWarning from '$lib/components/ui/TimezoneWarning.svelte';
|
||||||
|
|
||||||
import '$lib/global.css';
|
import '$lib/global.css';
|
||||||
|
|
||||||
@@ -10,6 +17,8 @@
|
|||||||
|
|
||||||
import { IconHome, IconDialpad, IconSettings, IconHelp, IconDots } from '@tabler/icons-svelte';
|
import { IconHome, IconDialpad, IconSettings, IconHelp, IconDots } from '@tabler/icons-svelte';
|
||||||
|
|
||||||
|
onMount(() => LOCATIONS.init());
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
// Navigation State
|
// Navigation State
|
||||||
@@ -18,9 +27,9 @@
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: 'Home', path: '/', icon: IconHome },
|
{ label: 'Home', path: '/', icon: IconHome },
|
||||||
{ label: 'PIS', path: '/pis', icon: IconDialpad },
|
{ label: 'PIS', path: '/pis/', icon: IconDialpad },
|
||||||
{ label: 'Options', path: '/preferences', icon: IconSettings },
|
{ label: 'Options', path: '/preferences/', icon: IconSettings },
|
||||||
{ label: 'About', path: '/about', icon: IconHelp }
|
{ label: 'About', path: '/about/', icon: IconHelp }
|
||||||
];
|
];
|
||||||
|
|
||||||
let navWidth = $state(0);
|
let navWidth = $state(0);
|
||||||
@@ -34,7 +43,7 @@
|
|||||||
if (navWidth === 0) return navItems.length;
|
if (navWidth === 0) return navItems.length;
|
||||||
const available = navWidth;
|
const available = navWidth;
|
||||||
const totalItems = navItems.length;
|
const totalItems = navItems.length;
|
||||||
const countWithoutMore = Math.floor(available/ ITEM_WIDTH);
|
const countWithoutMore = Math.floor(available / ITEM_WIDTH);
|
||||||
|
|
||||||
if (countWithoutMore >= totalItems) return totalItems;
|
if (countWithoutMore >= totalItems) return totalItems;
|
||||||
|
|
||||||
@@ -72,13 +81,19 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
{@render children()}
|
<TimezoneWarning />
|
||||||
|
|
||||||
|
{#if navigating && navigating.to}
|
||||||
|
<Loading />
|
||||||
|
{:else}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<nav bind:clientWidth={navWidth}>
|
<nav bind:clientWidth={navWidth}>
|
||||||
<!-- Dynamic Nav Elements Here! -->
|
<!-- Dynamic Nav Elements Here! -->
|
||||||
{#each visibleItems as item}
|
{#each visibleItems as item}
|
||||||
{@const isActive = activePath === item.path}
|
{@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')}
|
||||||
<a
|
<a
|
||||||
href={item.path}
|
href={item.path}
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
@@ -110,7 +125,7 @@
|
|||||||
></div>
|
></div>
|
||||||
<div class="menu-popover" transition:slide={{ axis: 'y', duration: 250 }}>
|
<div class="menu-popover" transition:slide={{ axis: 'y', duration: 250 }}>
|
||||||
{#each hiddenItems as item}
|
{#each hiddenItems as item}
|
||||||
{@const isActive = activePath === item.path}
|
{@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')}
|
||||||
<a
|
<a
|
||||||
href={item.path}
|
href={item.path}
|
||||||
class="menu-popover-item"
|
class="menu-popover-item"
|
||||||
@@ -128,10 +143,11 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="viewport-guard">
|
<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>
|
<h1 class="viewport-guard-title">Narrow Gauge Detected</h1>
|
||||||
<p>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -156,10 +172,12 @@
|
|||||||
.page-title {
|
.page-title {
|
||||||
font-family: 'URW Gothic', sans-serif;
|
font-family: 'URW Gothic', sans-serif;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: clamp(0.9rem, 2.5vw + 0.8rem, 2rem);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
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 {
|
||||||
@@ -193,7 +211,8 @@
|
|||||||
box-shadow: var(--shadow-up);
|
box-shadow: var(--shadow-up);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item, .more-menu-wrapper {
|
.nav-item,
|
||||||
|
.more-menu-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -317,8 +336,10 @@
|
|||||||
margin: auto;
|
margin: auto;
|
||||||
padding-top: 30px;
|
padding-top: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
header, main, nav {
|
header,
|
||||||
|
main,
|
||||||
|
nav {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
export const trailingSlash = 'always';
|
export const trailingSlash = 'always';
|
||||||
export const csr = true;
|
export const csr = true;
|
||||||
|
export const ssr = false;
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import LocationBoardCard from '$lib/components/ui/cards/LocationBoardCard.svelte';
|
||||||
import Textbox from '$lib/components/ui/Textbox.svelte';
|
import HeadcodeSearchCard from '$lib/components/ui/cards/HeadcodeSearchCard.svelte';
|
||||||
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
|
import NearbyStationsCard from '$lib/components/ui/cards/NearbyStationsCard.svelte';
|
||||||
|
import QuickLinksCard from '$lib/components/ui/cards/QuickLinksCard.svelte';
|
||||||
function test() {
|
|
||||||
console.log('Button Clicked');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<br /><br /><br />
|
<div class="card-container">
|
||||||
<Button>Default</Button>
|
<LocationBoardCard />
|
||||||
<Button color={'brand'} onclick={test}>Brand</Button>
|
<HeadcodeSearchCard />
|
||||||
<Button color={'accent'}>Accent</Button>
|
<NearbyStationsCard />
|
||||||
<Textbox placeholder={"Textbox am I"} uppercase={true} error={""} />
|
<QuickLinksCard />
|
||||||
|
</div>
|
||||||
|
|
||||||
<BaseCard header={"Hello"} helpText={"This is help text"}>Hello</BaseCard>
|
<style>
|
||||||
|
.card-container {
|
||||||
<h2>OwlBoard</h2>
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -29,7 +29,10 @@
|
|||||||
daily basis.
|
daily basis.
|
||||||
</p>
|
</p>
|
||||||
<p class="amble">
|
<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>
|
||||||
<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
|
||||||
|
|||||||
34
src/routes/board/+page.svelte
Normal file
34
src/routes/board/+page.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { quickLinks } from '$lib/quick-links.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
// Update 'QuickLinks'
|
||||||
|
$effect(() => {
|
||||||
|
if (data.BoardLocation) {
|
||||||
|
const id = data.BoardLocation?.c ?? data.BoardLocation?.t;
|
||||||
|
if (id) {
|
||||||
|
// Untrack, as we do not need to handle changes to quickLinks - this is WRITE_ONLY
|
||||||
|
untrack(() => {
|
||||||
|
quickLinks.recordVisit(id);
|
||||||
|
console.log(`QuickLink visit recorded: ${JSON.stringify(data.BoardLocation)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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>
|
||||||
39
src/routes/board/+page.ts
Normal file
39
src/routes/board/+page.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let BoardLocation;
|
||||||
|
|
||||||
|
if (locId) {
|
||||||
|
BoardLocation = LOCATIONS.find(locId);
|
||||||
|
|
||||||
|
if (BoardLocation) {
|
||||||
|
title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep';
|
||||||
|
} else {
|
||||||
|
error(404, {
|
||||||
|
message: `Location (${locId.toUpperCase()}) not found`,
|
||||||
|
owlCode: 'INVALID_LOCATION_CODE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
BoardLocation
|
||||||
|
};
|
||||||
|
};
|
||||||
164
src/routes/pis/+page.svelte
Normal file
164
src/routes/pis/+page.svelte
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import HeadcodeSearchCard from '$lib/components/ui/cards/HeadcodeSearchCard.svelte';
|
||||||
|
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">
|
||||||
|
<HeadcodeSearchCard />
|
||||||
|
<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
5
src/routes/pis/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const load = () => {
|
||||||
|
return {
|
||||||
|
title: 'PIS Codes'
|
||||||
|
};
|
||||||
|
};
|
||||||
29
src/routes/trains/+page.svelte
Normal file
29
src/routes/trains/+page.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import NoResults from '$lib/components/ui/NoResults.svelte';
|
||||||
|
import TrainService from '$lib/components/ui/TrainService.svelte';
|
||||||
|
let { data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if data.results.length === 0}
|
||||||
|
<NoResults message={'No trains found on this date with this headcode.'} />
|
||||||
|
{:else}
|
||||||
|
<div class="result-boxes">
|
||||||
|
{#each data.results as service (service.r)}
|
||||||
|
<TrainService {service} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.result-boxes {
|
||||||
|
width: 95%;
|
||||||
|
margin: auto;
|
||||||
|
padding-top: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
62
src/routes/trains/+page.ts
Normal file
62
src/routes/trains/+page.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
|
||||||
|
import type { ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ url }) => {
|
||||||
|
const headcode = url.searchParams.get('h');
|
||||||
|
let dateParam = url.searchParams.get('d');
|
||||||
|
const toc = url.searchParams.get('t') || '';
|
||||||
|
|
||||||
|
const date: string | Date = dateParam === '' || dateParam === null ? new Date() : dateParam;
|
||||||
|
|
||||||
|
if (!headcode) {
|
||||||
|
throw error(400, {
|
||||||
|
message: 'Headcode not provided',
|
||||||
|
owlCode: 'INVALID_DATA'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declared outside of the try so that it can be used in both the try and catch blocks
|
||||||
|
let results: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse[];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await OwlClient.trains.getByHeadcode(headcode, date, toc);
|
||||||
|
|
||||||
|
results = response.data;
|
||||||
|
return {
|
||||||
|
title: headcode.toUpperCase(),
|
||||||
|
results: results
|
||||||
|
};
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof ValidationError) {
|
||||||
|
throw error(400, {
|
||||||
|
message: e.message,
|
||||||
|
owlCode: 'VALIDATION_ERROR'
|
||||||
|
});
|
||||||
|
} else if (e instanceof ApiError) {
|
||||||
|
// Check if NO_RESULTS error, and return empty array if that is the case
|
||||||
|
if (e.code === 'NOT_FOUND') {
|
||||||
|
return {
|
||||||
|
title: headcode.toUpperCase(),
|
||||||
|
results: []
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw error(e.status, {
|
||||||
|
message: e.message,
|
||||||
|
owlCode: 'API_ERROR'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (e instanceof Error) {
|
||||||
|
throw error(500, {
|
||||||
|
message: e.message,
|
||||||
|
owlCode: 'GEN_ERROR'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw error(500, {
|
||||||
|
message: 'Unexpected error',
|
||||||
|
owlCode: 'UNKNOWN_ERR'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,15 +2,19 @@ import adapter from '@sveltejs/adapter-static';
|
|||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
kit: {
|
kit: {
|
||||||
adapter: adapter({
|
adapter: adapter({
|
||||||
pages: 'build',
|
pages: 'build',
|
||||||
assets: 'build',
|
assets: 'build',
|
||||||
fallback: 'index.html',
|
fallback: 'index.html',
|
||||||
precompress: 'true',
|
precompress: 'true',
|
||||||
strict: 'true'
|
strict: 'true'
|
||||||
})
|
}),
|
||||||
}
|
prerender: {
|
||||||
|
// Temporary option during testing
|
||||||
|
handleHttpError: 'ignore'
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user