Compare commits
67 Commits
v3.0.0-dev
...
v3.0.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 40e926bf11 | |||
| 631d324cba | |||
| 07579310b2 | |||
| b51845528f | |||
| 808b576df1 | |||
| 6ed262ce59 | |||
| 6faa620bdb | |||
| 1105a9f7c5 | |||
| 25b64edabd | |||
| bc56e66178 | |||
| 0ef9e5b56f | |||
| b88f4f3b35 | |||
| a2e6f3b99a | |||
| 7edccf294b | |||
| 13d22d7b73 | |||
| 59e8a77d3d | |||
| 1f1e215c0c | |||
| 909e36ebc2 | |||
| f3d633e719 | |||
| 05a04ec922 | |||
| 3b91fad590 | |||
| 6a857c2d64 | |||
| 1c4c7ccabc | |||
| 9ca3662ada | |||
| c524fe3c2e | |||
| 5486795711 | |||
| 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 |
@@ -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
|
||||||
|
|||||||
1
.npmrc
@@ -1 +1,2 @@
|
|||||||
engine-strict=true
|
engine-strict=true
|
||||||
|
@owlboard:registry=https://git.fjla.uk/api/packages/OwlBoard/npm/
|
||||||
2977
package-lock.json
generated
@@ -23,6 +23,7 @@
|
|||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
|
"@tabler/icons-svelte-runes": "^3.44.0",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@vitest/browser-playwright": "^4.0.18",
|
"@vitest/browser-playwright": "^4.0.18",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
"vitest-browser-svelte": "^2.0.2"
|
"vitest-browser-svelte": "^2.0.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tabler/icons-svelte": "^3.40.0"
|
"@owlboard/api-schema-types": "^3.0.3-alpha19",
|
||||||
|
"@owlboard/owlboard-ts": "^3.0.0-dev.20260509t2101"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
src/app.d.ts
vendored
@@ -2,7 +2,12 @@
|
|||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
interface Error {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
msg?: string;
|
||||||
|
}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<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" />
|
<meta name="title" content="OwlBoard | Your fastest route to live and reference data" />
|
||||||
<meta name="title" content="OwlBoard | Your fasted 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"
|
||||||
/>
|
/>
|
||||||
|
<!-- PWA Requirements -->
|
||||||
|
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
||||||
|
<link rel="apple-touch-icon" href="%sveltekit.assets%/icons/apple-touch-icon.png" />
|
||||||
<meta name="theme-color" content="#4fd1d1" />
|
<meta name="theme-color" content="#4fd1d1" />
|
||||||
<link rel="canonical" href="https://owlboard.info" />
|
<link rel="canonical" href="https://owlboard.info" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
|
|||||||
11
src/hooks.client.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { HandleClientError } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const handleError: HandleClientError = ({ error }) => {
|
||||||
|
const err = error as any;
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: err?.message || 'An unexpected application error occurred.',
|
||||||
|
code: err?.code || 'CRITICAL',
|
||||||
|
name: err?.name || 'UNHANDLED_EXCEPTION'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
Before Width: | Height: | Size: 13 KiB |
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 |
1
src/lib/assets/owlboard-logo-square.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" viewBox="0 0 562.1 562.1"><defs><radialGradient xlink:href="#a" id="b" cx="256" cy="256" r="254" fx="256" fy="256" gradientTransform="translate(27 27)" gradientUnits="userSpaceOnUse"/><linearGradient id="a"><stop offset="0" stop-color="#2b343c"/><stop offset="1" stop-color="#404c55"/></linearGradient></defs><path fill="url(#b)" d="M694.3 283a411.3 394 0 0 1-410.7 394 411.3 394 0 0 1-412-392.8 411.3 394 0 0 1 409.5-395.1 411.3 394 0 0 1 413.2 391.4" transform="translate(-2 -2)"/><path fill="#4fd1d1" d="M281 81a200 200 0 0 0-153.4 71.8 120 120 0 0 0 50.5 25.8c9 2.3 8.9 2.3 15.6-.6 50-21.6 124.4-21.6 175.2-.1 13.3 5.6 44.6-6.5 65.9-24.8a200 200 0 0 0-153.8-72m-161.9 82.7a200 200 0 0 0-38 117.3 200 200 0 0 0 200 200 200 200 0 0 0 200-200 200 200 0 0 0-37.8-116.8 96 96 0 0 1-17.7 46.6l-2.8 4 1.3 1.8c73.3 99.2-14.3 222.2-116.7 163.6-2.5-1.4-4.7-2.4-5-2.1s-10.5 19.6-18 34c-3.4 6.5-2.7 6.6-6.3-.3-9.2-17.5-18-34-18.3-34-.1 0-2.3 1-4.7 2.5-101.3 58-190.7-65.7-117.4-162.4q2.3-3 1.8-3.6a102 102 0 0 1-20.4-50.6m86.5 57q-14.6 0-29.8 7.5C108 261.8 143.1 366.4 217.5 352a68 68 0 0 0 43.3-28.4c2.4-3.4 3.3-4.2 3.7-3.7l8.5 14.6c9.4 16.3 8 15 11.4 9 5.1-9 13.7-23.5 14-24 .3-.2 1.9 1.8 3.6 4.3 31 45.5 101.6 35.6 118.3-16.5a66.4 66.4 0 0 0-83.9-83c-23.3 7.7-38.7 24.4-53.8 58.4l-1.3 3-2.7-6c-17-38.5-43.7-59.5-73-59.2m147.2 34.7a32 32 0 0 1 23.7 9.5c22.8 22.8.7 60.8-30.9 53.1a32 32 0 0 1-23.2-37.3 31.5 31.5 0 0 1 30.4-25.3m-145.8 0c16.4-.8 32.7 11 33.9 30.1a32.2 32.2 0 0 1-40.1 32.5 31.5 31.5 0 0 1-15-52.9 32 32 0 0 1 21.2-9.6m12 9.6q-3.3 0-5.8 3c-6.3 7.4 2.5 17.7 11 13a9 9 0 0 0 4.2-7.5c0-5.4-4.8-8.9-9.4-8.5m123.1 0c-6 .5-9.6 6.5-7 12.3 2 4.5 8.3 6.3 12.2 3.6 7.2-4.9 4.7-15.3-4-15.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
87
src/lib/components/ui/Loading.svelte
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
let { message = 'Loading...' } = $props();
|
||||||
|
|
||||||
|
let messageIndex = $state(0);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (messageIndex === 0) {
|
||||||
|
messageIndex = 1;
|
||||||
|
} else {
|
||||||
|
messageIndex = messageIndex === 1 ? 2 : 1;
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="track">
|
||||||
|
<div class="shuttle"></div>
|
||||||
|
</div>
|
||||||
|
<div class="message-container">
|
||||||
|
{#if messageIndex === 0}
|
||||||
|
<p in:fade={{ delay: 300, duration: 250 }} out:fade={{ duration: 250 }}>{message}</p>
|
||||||
|
{:else if messageIndex === 1}
|
||||||
|
<p in:fade={{ delay: 300, duration: 250 }} out:fade={{ duration: 250 }}>Slow connection...</p>
|
||||||
|
{:else}
|
||||||
|
<p in:fade={{ delay: 300, duration: 250 }} out:fade={{ duration: 250 }}>Still trying...</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-container {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 2rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--color-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes data-travel {
|
||||||
|
0% {
|
||||||
|
left: -50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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/form-elements/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 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
import type { HTLMInputAttributes } from 'svelte/elements';
|
|
||||||
|
|
||||||
interface Props extends HTMLInputAttributes {
|
|
||||||
value?: string;
|
|
||||||
label?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url';
|
|
||||||
error?: string;
|
|
||||||
uppercase?: boolean;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
value = $bindable(''),
|
|
||||||
label,
|
|
||||||
placeholder = '',
|
|
||||||
type = 'text',
|
|
||||||
error = '',
|
|
||||||
uppercase = false,
|
|
||||||
...rest
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let isFocussed = $state(false);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="input-wrapper" class:focussed={isFocussed} class:has-error={!!error}>
|
|
||||||
{#if label}
|
|
||||||
<label for="adaptive-input">{label}</label>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<input
|
|
||||||
id="adaptive-input"
|
|
||||||
class:all-caps={uppercase}
|
|
||||||
{type}
|
|
||||||
{placeholder}
|
|
||||||
bind:value={value}
|
|
||||||
onfocus={() => isFocussed = true}
|
|
||||||
onblur={() => isFocussed = false}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<span class="error-message" transition:fade>{error}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.input-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
font-family: 'URW Gothic', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--color-title)
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
min-height: 48px;
|
|
||||||
padding: 0 16px;
|
|
||||||
background-color: var(--color-title);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
border-radius: 20px;
|
|
||||||
color: var(--color-bg-dark);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
outline: none;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.all-caps {
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.focussed input {
|
|
||||||
border-color: var(--color-bg-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.has-error input {
|
|
||||||
border-color: #ff4d4d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
color: #ff4d4d;
|
|
||||||
font-size: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
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>
|
||||||
10
src/lib/components/ui/TiplocConverter.svelte
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { LOCATIONS } from '$lib/locations-object.svelte';
|
||||||
|
|
||||||
|
let { code } = $props<{ code: string | null | undefined }>();
|
||||||
|
|
||||||
|
const location = $derived(LOCATIONS.getName(code));
|
||||||
|
const displayName = $derived(location?.n ?? code ?? '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{displayName}
|
||||||
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>
|
||||||
570
src/lib/components/ui/TrainService.svelte
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ApiPisObject, ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts';
|
||||||
|
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { quintOut } from 'svelte/easing';
|
||||||
|
import { formatUkTime, calculateDelay } from '$lib/utils/time';
|
||||||
|
import TocStyle from '$lib/components/ui/TocStyle.svelte';
|
||||||
|
import TiplocConverter from '$lib/components/ui/TiplocConverter.svelte';
|
||||||
|
import { IconChevronDownFilled } from '@tabler/icons-svelte-runes';
|
||||||
|
import { estClass } from '$lib/utils/time';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 activityMap: Record<string, string> = {
|
||||||
|
'-D': 'Vehicles detatched',
|
||||||
|
'-T': 'Vehicles attached & detached',
|
||||||
|
'-U': 'Vehicles attached',
|
||||||
|
AE: 'Assist locomotive attached',
|
||||||
|
C: 'Traincrew change only',
|
||||||
|
D: 'Set down only',
|
||||||
|
E: 'Stops for examination',
|
||||||
|
K: 'Passenger count point',
|
||||||
|
KC: 'Ticket collection point',
|
||||||
|
KE: 'Ticket examination point',
|
||||||
|
KF: 'First class ticket examination point',
|
||||||
|
KS: 'Selective ticket examination point',
|
||||||
|
L: 'Locomotive changed',
|
||||||
|
N: 'Unadvertised stop',
|
||||||
|
OP: 'Operational stop only',
|
||||||
|
OR: 'Locomotive attached on rear',
|
||||||
|
R: 'Request stop only',
|
||||||
|
RM: 'Train changes direction',
|
||||||
|
RR: 'Locomotive run round',
|
||||||
|
S: 'Staff only stop',
|
||||||
|
TW: 'Stops for token/staff/tablet only',
|
||||||
|
U: 'Pick up only',
|
||||||
|
W: 'Watering coaches',
|
||||||
|
X: 'Passes another train'
|
||||||
|
};
|
||||||
|
|
||||||
|
const activityRegex = new RegExp(
|
||||||
|
Object.keys(activityMap)
|
||||||
|
.sort((a, b) => b.length - a.length) // Longest codes first
|
||||||
|
.map((key) => key.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')) // Escape special chars
|
||||||
|
.join('|'),
|
||||||
|
'g'
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRelevantActivities = (act: string) => {
|
||||||
|
if (!act) return [];
|
||||||
|
|
||||||
|
// Find all matches in the string
|
||||||
|
const matches = act.match(activityRegex) || [];
|
||||||
|
|
||||||
|
// Map to labels and remove duplicates
|
||||||
|
return [...new Set(matches)].map((code) => activityMap[code]);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="train-service" class:isExpanded>
|
||||||
|
<button class="summary" onclick={toggleExpand} type="button" aria-expanded={isExpanded}>
|
||||||
|
{#if loadingDetails}
|
||||||
|
<div class="loading-state"><div class="loading-spinner"></div></div>
|
||||||
|
{/if}
|
||||||
|
<div class="operator-summary">
|
||||||
|
<TocStyle toc={service.o} />
|
||||||
|
</div>
|
||||||
|
<div class="main-text-summary">
|
||||||
|
<div class="time-summary">
|
||||||
|
{OriginDepartureSummary}
|
||||||
|
</div>
|
||||||
|
<div class="location-summary" class:can-all={service.ct}>
|
||||||
|
{service.ot}
|
||||||
|
</div>
|
||||||
|
<div class="location-summary to-summary" class:can-all={service.ct}>to</div>
|
||||||
|
<div class="location-summary" class:can-all={service.ct}>
|
||||||
|
{service.dt}
|
||||||
|
</div>
|
||||||
|
<div class="arrow" class:expanded={isExpanded}>
|
||||||
|
<IconChevronDownFilled color={'var(--color-title)'} size={25} />
|
||||||
|
</div>
|
||||||
|
</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 service.ct}
|
||||||
|
<span class="cancel-reason"> Cancelled throughout </span>
|
||||||
|
{/if}
|
||||||
|
{#if details.header.cr}
|
||||||
|
<span class="cancel-reason">
|
||||||
|
{details.header.cr}
|
||||||
|
{#if details.header.cl}
|
||||||
|
{details.header.cn ? ' near ' : ' at '}
|
||||||
|
<TiplocConverter code={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}
|
||||||
|
{#if details.pis}
|
||||||
|
<div class="pis-detail">
|
||||||
|
<span class="pis-code">PIS: {details.pis.code}</span>
|
||||||
|
{#if details.pis.skip?.skip > 0}
|
||||||
|
<span class="pis-skip">
|
||||||
|
(skip {details.pis.skip.position === 'head' ? 'first' : 'last'}
|
||||||
|
{details.pis.skip.skip}
|
||||||
|
{details.pis.skip.skip === 1 ? 'stop' : 'stops'}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="color-key">
|
||||||
|
<p class="tpl-stop">Times in yellow are estimates</p>
|
||||||
|
</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>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{#each details.locations as loc}
|
||||||
|
<tbody class="location-group">
|
||||||
|
<tr class:pass-loc={loc.r === 'PASS'} class:can-loc={loc.can}>
|
||||||
|
<td class="tpl-cell" class:tpl-stop={loc.r != 'PASS'}>
|
||||||
|
{loc.t}
|
||||||
|
</td>
|
||||||
|
<td class="plat-cell cell-divider-right" class:plat-change={loc.pc}>{loc.p}</td>
|
||||||
|
{#if loc.r == 'PASS'}
|
||||||
|
<td class="time-cell cell-divider-right" 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 cell-divider-right {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 cell-divider-right {estClass(loc.atd, loc.etd)}"
|
||||||
|
>{formatUkTime(loc.atd || loc.etd || '--')}</td
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if loc}
|
||||||
|
{@const delay = calculateDelay(loc)}
|
||||||
|
<td class="delay-{delay.type}">{delay.val}</td>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
{#if loc.act && getRelevantActivities(loc.act).length > 0}
|
||||||
|
<tr class="activity-row">
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="activity-container">
|
||||||
|
{#each getRelevantActivities(loc.act) as note}
|
||||||
|
<span class="activity-tag">{note}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>{/each}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
Main container
|
||||||
|
*/
|
||||||
|
.train-service {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-std);
|
||||||
|
overflow: visible;
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
transition: 0.2s all;
|
||||||
|
filter: brightness(1.1);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.train-service:active {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.train-service:active .arrow {
|
||||||
|
filter: brightness(0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.train-service:not(.isExpanded):hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary:hover .arrow {
|
||||||
|
filter: brightness(0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Summary Header
|
||||||
|
*/
|
||||||
|
.summary {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 1rem;
|
||||||
|
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: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-summary {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--color-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.to-summary {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: oblique;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 0 auto;
|
||||||
|
height: 25px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded {
|
||||||
|
transform: rotateX(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.can-all {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
PIS Data
|
||||||
|
*/
|
||||||
|
.pis-detail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 8px 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.pis-code {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pis-skip {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
font-style: oblique;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Box Extention
|
||||||
|
*/
|
||||||
|
.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%;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
animation: cancel-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-reason {
|
||||||
|
color: var(--cancel-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-reason {
|
||||||
|
color: rgb(255, 119, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Schedule Table
|
||||||
|
*/
|
||||||
|
|
||||||
|
.color-key,
|
||||||
|
.color-key p {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 0 0.2rem 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-table-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-table {
|
||||||
|
width: 95%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: 'URW Gothic', 'Inter', sans-serif;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: -0.02ch;
|
||||||
|
transition: all 0.5s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-group:nth-of-type(even) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
.cell-divider-right {
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-row td {
|
||||||
|
padding: 0 0 10px 0;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-tag {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-cell {
|
||||||
|
color: var(--color-title);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-stop {
|
||||||
|
color: var(--location-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plat-change {
|
||||||
|
animation: fast-pulse 2s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-loc td {
|
||||||
|
color: var(--color-title);
|
||||||
|
font-style: oblique;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.can-loc {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.est {
|
||||||
|
color: var(--location-yellow);
|
||||||
|
font-style: oblique;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.delay-late {
|
||||||
|
color: var(--delay-orange);
|
||||||
|
font-weight: 600;
|
||||||
|
animation: pulse 2s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.delay-early {
|
||||||
|
color: var(--early-blue);
|
||||||
|
font-weight: 600;
|
||||||
|
animation: pulse 2s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Loading State
|
||||||
|
*/
|
||||||
|
.loading-state {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: load-spin 0.8s linear infinite;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Responsivity
|
||||||
|
*/
|
||||||
|
@media (min-width: 330px) {
|
||||||
|
.time-summary,
|
||||||
|
.location-summary {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.activity-row td {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 340px) {
|
||||||
|
.schedule-table {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 360px) {
|
||||||
|
.time-summary {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.location-summary {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.schedule-table {
|
||||||
|
font-size: 0.99rem;
|
||||||
|
}
|
||||||
|
.activity-row td {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 420px) {
|
||||||
|
.schedule-table {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
KEYFRAMES
|
||||||
|
*/
|
||||||
|
@keyframes load-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<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-runes';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,11 +9,7 @@
|
|||||||
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>
|
||||||
@@ -28,10 +24,14 @@
|
|||||||
<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
|
||||||
|
size={26}
|
||||||
|
stroke={2.25}
|
||||||
|
color={showHelp ? 'var(--color-brand)' : 'var(--color-title)'}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
@@ -52,17 +52,23 @@
|
|||||||
.card {
|
.card {
|
||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 20px;
|
border-radius: 12px;
|
||||||
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;
|
||||||
@@ -74,7 +80,9 @@
|
|||||||
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 {
|
||||||
|
|||||||
39
src/lib/components/ui/cards/HeadcodeSearchCard.svelte
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
|
||||||
|
import Textbox from '$lib/components/ui/form-elements/Textbox.svelte';
|
||||||
|
|
||||||
|
let headcode = $state('');
|
||||||
|
|
||||||
|
function handleSearch(headcode: string) {
|
||||||
|
if (!headcode.trim()) return;
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
searchParams.append('h', headcode.trim().toUpperCase());
|
||||||
|
|
||||||
|
goto(`/trains?${searchParams.toString()}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BaseCard header={'Search Train & PIS'}>
|
||||||
|
<div class="card-content">
|
||||||
|
<Textbox
|
||||||
|
placeholder="Enter Headcode"
|
||||||
|
bind:value={headcode}
|
||||||
|
maxLength={4}
|
||||||
|
onsubmit={handleSearch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</BaseCard>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card-content {
|
||||||
|
text-align: center;
|
||||||
|
width: 90%;
|
||||||
|
margin: auto;
|
||||||
|
padding: 10px 0 0.5rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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/form-elements/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>
|
||||||
77
src/lib/components/ui/cards/NearbyStationsCard.svelte
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
|
||||||
|
import Button from '$lib/components/ui/form-elements/Button.svelte';
|
||||||
|
|
||||||
|
import { prefs } from '$lib/preferences.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 !prefs.current.NearToMe}
|
||||||
|
<Button onclick={() => prefs.toggleNearToMe(true)}>Enable Location</Button>
|
||||||
|
{:else 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
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
|
||||||
|
import Button from '$lib/components/ui/form-elements/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>
|
||||||
50
src/lib/components/ui/cards/pis/PisCode.svelte
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
|
||||||
|
import Textbox from '$lib/components/ui/form-elements/Textbox.svelte';
|
||||||
|
|
||||||
|
let { onsearch }: { onsearch: (c: string) => void } = $props();
|
||||||
|
</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={'text'}
|
||||||
|
onsubmit={onsearch}
|
||||||
|
maxLength={4}
|
||||||
|
inputmode={'numeric'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
65
src/lib/components/ui/cards/pis/PisStartEndCard.svelte
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
|
||||||
|
import Textbox from '$lib/components/ui/form-elements/Textbox.svelte';
|
||||||
|
import Button from '$lib/components/ui/form-elements/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'}>
|
||||||
|
<form
|
||||||
|
class="card-content"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onsearch(startValue, endValue);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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 type={'submit'}>Search</Button>
|
||||||
|
<Button onclick={resetValues}>Reset</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</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: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textbox-item-wrapper {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -19,47 +19,60 @@
|
|||||||
{#if isLink}
|
{#if isLink}
|
||||||
<a
|
<a
|
||||||
{href}
|
{href}
|
||||||
class="btn {color}"
|
class="hitbox-wrapper"
|
||||||
target={isExternal ? '_blank' : undefined}
|
target={isExternal ? '_blank' : undefined}
|
||||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
><span class="btn {color}">
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="btn {color}" {onclick} {...rest}>
|
<button class="hitbox-wrapper" {onclick} {...rest}>
|
||||||
{@render children?.()}
|
<span class="btn {color}">{@render children?.()}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.btn {
|
.hitbox-wrapper {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.4rem 1.2rem;
|
|
||||||
width: fit-content;
|
|
||||||
min-width: 90px;
|
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 20px;
|
padding: 0 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-family: 'URW Gothic', sans-serif;
|
|
||||||
letter-spacing: 0.05ch;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: all 0.2s;
|
|
||||||
box-shadow: var(--shadow-std);
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
touch-action: manipulation;
|
}
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: fit-content;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 1.2rem;
|
||||||
|
min-width: 40px;
|
||||||
|
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>
|
||||||
220
src/lib/components/ui/form-elements/LocationSearchBox.svelte
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Textbox from '$lib/components/ui/form-elements/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>
|
||||||
171
src/lib/components/ui/form-elements/Textbox.svelte
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
import { IconChevronRightFilled } from '@tabler/icons-svelte-runes';
|
||||||
|
|
||||||
|
interface Props extends HTMLInputAttributes {
|
||||||
|
value?: string;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url';
|
||||||
|
error?: string;
|
||||||
|
uppercase?: boolean;
|
||||||
|
onsubmit?: (val: string) => void | Promise<void>;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
label,
|
||||||
|
placeholder = '',
|
||||||
|
type = 'text',
|
||||||
|
error = '',
|
||||||
|
uppercase = false,
|
||||||
|
onsubmit,
|
||||||
|
...rest
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let isFocussed = $state(false);
|
||||||
|
|
||||||
|
const handleSubmit = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (onsubmit && value) {
|
||||||
|
onsubmit(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="input-wrapper" class:focussed={isFocussed} class:has-error={!!error}>
|
||||||
|
{#if label}
|
||||||
|
<label for="adaptive-input">{label}</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit} class="input-container">
|
||||||
|
<input
|
||||||
|
id="adaptive-input"
|
||||||
|
class:all-caps={uppercase}
|
||||||
|
{type}
|
||||||
|
{placeholder}
|
||||||
|
bind:value
|
||||||
|
onfocus={() => (isFocussed = true)}
|
||||||
|
onblur={() => (isFocussed = false)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if onsubmit}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="submit-icon"
|
||||||
|
transition:fade
|
||||||
|
disabled={!value}
|
||||||
|
aria-label="Submit"
|
||||||
|
>
|
||||||
|
<IconChevronRightFilled />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<span class="error-message" transition:fade>{error}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--color-title);
|
||||||
|
border-radius: 5000px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='number']::-webkit-outer-spin-button,
|
||||||
|
input[type='number']::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='number'] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
min-height: 40px;
|
||||||
|
height: 100%;
|
||||||
|
line-height: normal;
|
||||||
|
padding: 0 48px;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-bg-dark);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
outline: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-icon {
|
||||||
|
position: absolute;
|
||||||
|
background: transparent;
|
||||||
|
right: 0;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-bg-dark);
|
||||||
|
cursor: pointer;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0.75;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s,
|
||||||
|
transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-icon:hover:not(:disabled) {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-icon:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-caps {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focussed input {
|
||||||
|
border-color: var(--color-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-error input {
|
||||||
|
border-color: #ff4d4d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #ff4d4d;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
113
src/lib/components/ui/form-elements/Toggle.svelte
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
checked: boolean;
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onchange?: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { checked = $bindable(false), id, label, disabled = false, onchange }: Props = $props();
|
||||||
|
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
checked = target.checked;
|
||||||
|
onchange?.(checked);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="toggle-wrapper" class:is-disabled={disabled}>
|
||||||
|
<!-- 1. Interactive Switch Mechanism (Sits Left) -->
|
||||||
|
<div class="toggle-control">
|
||||||
|
<input type="checkbox" {id} {disabled} {checked} onchange={handleInput} class="toggle-input" />
|
||||||
|
<label for={id} class="toggle-track">
|
||||||
|
<span class="toggle-thumb"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Visible Text Label (Sits Right) -->
|
||||||
|
<label for={id} class="toggle-label-text">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toggle-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 1.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-control {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 3.75rem;
|
||||||
|
height: 2rem;
|
||||||
|
background-color: rgb(93, 45, 45);
|
||||||
|
border-radius: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
box-shadow: inset 0 1px 3px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.25rem;
|
||||||
|
left: 0.25rem;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||||
|
filter: brightness(1.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-input:checked + .toggle-track {
|
||||||
|
background-color: rgb(11, 70, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-input:checked + .toggle-track .toggle-thumb {
|
||||||
|
transform: translateX(1.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-input:focus-visible + .toggle-track {
|
||||||
|
outline: 2px solid #3b82f6;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label-text {
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
color: var(--color-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-disabled .toggle-track,
|
||||||
|
.is-disabled .toggle-label-text {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
421
src/lib/components/ui/station-board/StaffServicesTable.svelte
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
|
||||||
|
import { formatUkTime, estClass, delayClassFromTimePair } from '$lib/utils/time';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
let { services }: { services: ApiStationsBoard.BoardService[] } = $props();
|
||||||
|
|
||||||
|
const getRowKey = (s: ApiStationsBoard.BoardService) =>
|
||||||
|
`${s.r}${s.sta ?? ''}${s.std ?? ''}${s.wtp ?? ''}`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<table class="departure-board">
|
||||||
|
<colgroup>
|
||||||
|
<col style="width:10%;" />
|
||||||
|
<!-- ID (Headcode) -->
|
||||||
|
<col style="width:17%;" />
|
||||||
|
<!-- ORIG (Origin) -->
|
||||||
|
<col style="width:17%;" />
|
||||||
|
<!-- DEST (Destination) -->
|
||||||
|
<col style="width:9%;" />
|
||||||
|
<!-- PLT (Platform) -->
|
||||||
|
<col style="width: 12%;" />
|
||||||
|
<!-- STA -->
|
||||||
|
<col style="width: 12%;" />
|
||||||
|
<!-- ETA/ATA -->
|
||||||
|
<col style="width: 12%;" />
|
||||||
|
<!-- STD -->
|
||||||
|
<col style="width: 11%;" />
|
||||||
|
<!-- ETD/ATD -->
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th colspan="4" aria-hidden="true"></th>
|
||||||
|
<th scope="colgroup" colspan="2" class="upper-head"><abbr title="Arrival">Arr</abbr></th>
|
||||||
|
<th scope="colgroup" colspan="2" class="upper-head"><abbr title="Departure">Dep</abbr></th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="col"><abbr title="Headcode">ID</abbr></th>
|
||||||
|
<th scope="col"><abbr title="Origin">Orig</abbr></th>
|
||||||
|
<th scope="col"><abbr title="Destination">Dest</abbr></th>
|
||||||
|
<th scope="col"><abbr title="Platform">Plt</abbr></th>
|
||||||
|
<th scope="col"><abbr title="Scheduled">Sch</abbr></th>
|
||||||
|
<th scope="col"><abbr title="Actual/Expected">Act</abbr></th>
|
||||||
|
<th scope="col"><abbr title="Scheduled">Sch</abbr></th>
|
||||||
|
<th scope="col"><abbr title="Actual/Expected">Act</abbr></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
{#each services as service (getRowKey(service))}
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
in:fade={{duration: 150, delay:150}}
|
||||||
|
out:fade={{duration:150}}
|
||||||
|
class="service-row"
|
||||||
|
class:serviceCancelled={service.c}
|
||||||
|
class:servicePass={service.wtp}
|
||||||
|
class:serviceNonPassenger={!service.ip}
|
||||||
|
>
|
||||||
|
<td class="id-cell">{service.h}</td>
|
||||||
|
<td class="orig-cell"
|
||||||
|
><abbr title={service.og.n.toLocaleUpperCase()}>{service.og.t}</abbr></td
|
||||||
|
>
|
||||||
|
<td class="dest-cell"
|
||||||
|
><abbr title={service.dt.n.toLocaleUpperCase()}>{service.dt.t}</abbr></td
|
||||||
|
>
|
||||||
|
<td class="plt-cell" class:platSup={service.ps} class:platChange={service.pc}
|
||||||
|
>{service.p || '-'}</td
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Handle different display for a passing train -->
|
||||||
|
{#if service.wtp}
|
||||||
|
<td class="pass-cell" colspan="2">Pass</td>
|
||||||
|
<td class="time-cell">{formatUkTime(service.wtp)}</td>
|
||||||
|
<!-- If cancelled, show '-', otherwise check for RT or show time -->
|
||||||
|
<td
|
||||||
|
class="time-cell {estClass(service.atp, service.etp)} {delayClassFromTimePair(
|
||||||
|
service.wtp,
|
||||||
|
service.atp || service.etp
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{service.c
|
||||||
|
? '-'
|
||||||
|
: delayClassFromTimePair(service.wtp, service.atp || service.etp) === 'delay-rt'
|
||||||
|
? 'RT'
|
||||||
|
: formatUkTime(service.atp || service.etp)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{:else}
|
||||||
|
<td class="time-cell">{formatUkTime(service.sta)}</td>
|
||||||
|
|
||||||
|
<!-- Arrival Actual/Expected -->
|
||||||
|
<td
|
||||||
|
class="time-cell {estClass(service.ata, service.eta)} {delayClassFromTimePair(
|
||||||
|
service.sta,
|
||||||
|
service.ata || service.eta
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{service.c
|
||||||
|
? '-'
|
||||||
|
: delayClassFromTimePair(service.sta, service.ata || service.eta) === 'delay-rt'
|
||||||
|
? 'RT'
|
||||||
|
: formatUkTime(service.ata || service.eta)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="time-cell">{formatUkTime(service.std)}</td>
|
||||||
|
|
||||||
|
<!-- Departure Actual/Expected -->
|
||||||
|
<td
|
||||||
|
class="time-cell {estClass(service.atd, service.etd)} {delayClassFromTimePair(
|
||||||
|
service.std,
|
||||||
|
service.atd || service.etd
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{service.c
|
||||||
|
? '-'
|
||||||
|
: delayClassFromTimePair(service.std, service.atd || service.etd) === 'delay-rt'
|
||||||
|
? 'RT'
|
||||||
|
: formatUkTime(service.atd || service.etd)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{#if service.o}
|
||||||
|
<tr class="toc-coach-row" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
|
||||||
|
<td colspan="8">
|
||||||
|
{service.o}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{#if service.c && service.cr?.r}
|
||||||
|
<tr class="cancel-row" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
|
||||||
|
<td colspan="8">
|
||||||
|
{service.cr.r}
|
||||||
|
{#if service.cr.l}
|
||||||
|
{service.cr.n ? 'near' : 'at'}
|
||||||
|
{service.cr.l}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{#if service.dr?.r}
|
||||||
|
<tr class="delay-row" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
|
||||||
|
<td colspan="9">
|
||||||
|
{service.dr.r}
|
||||||
|
{#if service.dr.l}
|
||||||
|
{service.dr.n ? 'near' : 'at'}
|
||||||
|
{service.dr.l}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
{/each}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.departure-board {
|
||||||
|
width: 100%;
|
||||||
|
margin: 5px auto;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.departure-board td {
|
||||||
|
font-family: 'Inconsolata Variable', monospace;
|
||||||
|
font-size: clamp(1rem, 0.475rem + 2.8vw, 1.35rem);
|
||||||
|
font-variant-ligatures: additional-ligatures;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead,
|
||||||
|
.cancel-row td,
|
||||||
|
.delay-row td {
|
||||||
|
letter-spacing: -0.15ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--color-bg-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr {
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: none;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:first-child td {
|
||||||
|
border-top: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row Logic */
|
||||||
|
.serviceCancelled {
|
||||||
|
color: rgb(255, 131, 131);
|
||||||
|
}
|
||||||
|
|
||||||
|
.servicePass td {
|
||||||
|
opacity: 0.75;
|
||||||
|
font-weight: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.serviceNonPassenger td {
|
||||||
|
opacity: 0.5;
|
||||||
|
font-weight: 290;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Special Row Styles */
|
||||||
|
.cancel-row td,
|
||||||
|
.delay-row td {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-row td[colspan],
|
||||||
|
.delay-row td[colspan] {
|
||||||
|
padding-left: 0ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-row td {
|
||||||
|
color: rgb(255, 131, 131);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-row td {
|
||||||
|
color: var(--delay-orange);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
@media (min-width: 375px) {
|
||||||
|
.cancel-row td,
|
||||||
|
.delay-row td {
|
||||||
|
font-size: 1.08rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 420px) {
|
||||||
|
.cancel-row td,
|
||||||
|
.delay-row td {
|
||||||
|
font-size: 1.12rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 550px) {
|
||||||
|
.cancel-row td,
|
||||||
|
.delay-row td {
|
||||||
|
font-size: 1.18rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 620px) {
|
||||||
|
.cancel-row td,
|
||||||
|
.delay-row td {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-coach-row td {
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0;
|
||||||
|
color: rgb(187, 187, 255);
|
||||||
|
}
|
||||||
|
@media (min-width: 400px) {
|
||||||
|
.toc-coach-row td {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 550px) {
|
||||||
|
.toc-coach-row td {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column Specifics */
|
||||||
|
.id-cell {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 400;
|
||||||
|
font-stretch: 85%;
|
||||||
|
filter: brightness(0.75);
|
||||||
|
}
|
||||||
|
@media (min-width: 400px) {
|
||||||
|
.id-cell {
|
||||||
|
font-stretch: 90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.orig-cell,
|
||||||
|
.dest-cell {
|
||||||
|
font-stretch: 80%;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.orig-cell {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--location-yellow);
|
||||||
|
}
|
||||||
|
.dest-cell {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--location-yellow);
|
||||||
|
}
|
||||||
|
@media (min-width: 350px) {
|
||||||
|
.orig-cell,
|
||||||
|
.dest-cell {
|
||||||
|
font-stretch: 85%;
|
||||||
|
}
|
||||||
|
@media (min-width: 400px) {
|
||||||
|
.orig-cell,
|
||||||
|
.dest-cell {
|
||||||
|
font-stretch: 90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 490px) {
|
||||||
|
.orig-cell,
|
||||||
|
.dest-cell {
|
||||||
|
font-stretch: 95%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 520px) {
|
||||||
|
.orig-cell,
|
||||||
|
.dest-cell {
|
||||||
|
font-stretch: 100%;
|
||||||
|
}
|
||||||
|
.orig-cell {
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 7px;
|
||||||
|
}
|
||||||
|
.dest-cell {
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.orig-cell,
|
||||||
|
.dest-cell {
|
||||||
|
letter-spacing: 0.05ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.plt-cell {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 410;
|
||||||
|
font-stretch: 100%;
|
||||||
|
}
|
||||||
|
@media (min-width: 525px) {
|
||||||
|
.plt-cell {
|
||||||
|
font-stretch: 110%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.plt-cell.platSup {
|
||||||
|
font-weight: 200;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.plt-cell.platChange {
|
||||||
|
animation: fast-pulse 2s ease-out infinite;
|
||||||
|
}
|
||||||
|
.service-row.serviceCancelled .plt-cell {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.pass-cell {
|
||||||
|
text-align: center;
|
||||||
|
font-stretch: 100%;
|
||||||
|
}
|
||||||
|
/* Colour orig and dest values when cancelled */
|
||||||
|
.service-row.serviceCancelled .orig-cell,
|
||||||
|
.service-row.serviceCancelled .dest-cell {
|
||||||
|
color: rgb(255, 131, 131);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-cell {
|
||||||
|
text-align: center;
|
||||||
|
font-stretch: 72%;
|
||||||
|
}
|
||||||
|
@media (min-width: 350px) {
|
||||||
|
.time-cell {
|
||||||
|
font-stretch: 77%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 400px) {
|
||||||
|
.time-cell {
|
||||||
|
font-stretch: 82%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 525px) {
|
||||||
|
.time-cell {
|
||||||
|
font-stretch: 90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.time-cell {
|
||||||
|
font-stretch: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RT Logic */
|
||||||
|
.time-cell.delay-rt span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.time-cell.delay-rt::after {
|
||||||
|
content: 'RT';
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-cell.delay-early {
|
||||||
|
color: var(--early-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-cell.delay-late {
|
||||||
|
color: var(--delay-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time Types */
|
||||||
|
.est {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.75;
|
||||||
|
font-weight: 350;
|
||||||
|
}
|
||||||
|
.act {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
143
src/lib/components/ui/station-board/StationAlertCard.svelte
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
|
||||||
|
import { IconAlertOctagonFilled, IconChevronDownFilled } from '@tabler/icons-svelte-runes';
|
||||||
|
|
||||||
|
let { messages = [] }: { messages: ApiStationsBoard.BoardMsgs[] } = $props();
|
||||||
|
let isOpen = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if messages.length > 0}
|
||||||
|
<aside class="alert-card">
|
||||||
|
<button onclick={() => (isOpen = !isOpen)} class="trigger" class:active={isOpen}>
|
||||||
|
<span class="warning-icon">
|
||||||
|
<IconAlertOctagonFilled />
|
||||||
|
</span>
|
||||||
|
<span class="summary">
|
||||||
|
{messages.length}
|
||||||
|
{messages.length === 1 ? 'active alert' : 'active alerts'}
|
||||||
|
</span>
|
||||||
|
<span class="chevron" class:rotated={isOpen}><IconChevronDownFilled /></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div transition:slide={{ duration: 450 }} class="content">
|
||||||
|
{#each messages as msg}
|
||||||
|
<div class="message-item">
|
||||||
|
<p class="message-text">
|
||||||
|
{msg.t}
|
||||||
|
{#if msg.l && msg.lt}
|
||||||
|
<a href={msg.l} target="_blank" rel="noopener noreferrer" class="alert-link">
|
||||||
|
{msg.lt}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.alert-card {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
margin-bottom: 0;
|
||||||
|
width: 95%;
|
||||||
|
max-width: 750px;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card:active {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
border-radius: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.1rem 1.05rem;
|
||||||
|
background: var(--alert-orange);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
height: 40px;
|
||||||
|
transition: all 0.65s 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger.active {
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
transition: all 0.01s 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 1rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
flex: 1;
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
transition: transform 0.5s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron.rotated {
|
||||||
|
transform: rotateX(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 1rem;
|
||||||
|
position: absolute;
|
||||||
|
top: 40px;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--alert-orange);
|
||||||
|
filter: brightness(1.2);
|
||||||
|
color: var(--color-title);
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item {
|
||||||
|
margin: 10px;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item a {
|
||||||
|
color: #f2ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
124
src/lib/geohash.svelte.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { OwlClient, ValidationError, ApiError } from './owlClient';
|
||||||
|
import type { ApiStationsNearestStations } from '@owlboard/owlboard-ts';
|
||||||
|
import { prefs } from './preferences.svelte';
|
||||||
|
|
||||||
|
class NearestStationsState {
|
||||||
|
list = $state<ApiStationsNearestStations.StationsNearestStations[]>([]);
|
||||||
|
currentHash = $state('');
|
||||||
|
loading = $state(false);
|
||||||
|
error = $state<string | null>(null);
|
||||||
|
|
||||||
|
private watcherId: number | 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)) return;
|
||||||
|
|
||||||
|
$effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
if (prefs.current.NearToMe) {
|
||||||
|
this.startTracking();
|
||||||
|
} else {
|
||||||
|
this.stopTracking();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private startTracking() {
|
||||||
|
if (this.watcherId !== null) return; // Already tracking
|
||||||
|
this.loading = true;
|
||||||
|
this.jumpstart();
|
||||||
|
this.initWatcher();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopTracking() {
|
||||||
|
if (this.watcherId !== null) {
|
||||||
|
navigator.geolocation.clearWatch(this.watcherId);
|
||||||
|
this.watcherId = null;
|
||||||
|
}
|
||||||
|
this.list = [];
|
||||||
|
this.currentHash = '';
|
||||||
|
this.loading = false;
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private jumpstart() {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude),
|
||||||
|
(err) => this.handleError(err),
|
||||||
|
this.initGeoConfig
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initWatcher() {
|
||||||
|
this.watcherId = 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();
|
||||||
@@ -44,7 +44,110 @@
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 100: Thin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
src: url('/type/jetbrains-mono/JetBrainsMono-Thin.woff2') format('woff2');
|
||||||
|
font-weight: 100;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
src: url('/type/jetbrains-mono/JetBrainsMono-ThinItalic.woff2') format('woff2');
|
||||||
|
font-weight: 100;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 200: ExtraLight */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
src: url('/type/jetbrains-mono/JetBrainsMono-ExtraLight.woff2') format('woff2');
|
||||||
|
font-weight: 200;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 300: Light */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
src: url('/type/jetbrains-mono/JetBrainsMono-Light.woff2') format('woff2');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 400: Regular / Italic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
src: url('/type/jetbrains-mono/JetBrainsMono-Regular.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
src: url('/type/jetbrains-mono/JetBrainsMono-Italic.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 500: Medium */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
src: url('/type/jetbrains-mono/JetBrainsMono-Medium.woff2') format('woff2');
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 600: SemiBold */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
src: url('/type/jetbrains-mono/JetBrainsMono-SemiBold.woff2') format('woff2');
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 700: Bold */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
src: url('/type/jetbrains-mono/JetBrainsMono-Bold.woff2') format('woff2');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 800: ExtraBold */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
src: url('/type/jetbrains-mono/JetBrainsMono-ExtraBold.woff2') format('woff2');
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inconsolata Variable */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inconsolata Variable';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 200 900;
|
||||||
|
font-stretch: 50% 200%;
|
||||||
|
src: url(/type/inconsolata/latin-wdth-normal.woff2) format('woff2-variations');
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||||
|
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
/* Fixed Heights */
|
||||||
|
--header-height: 80px;
|
||||||
|
--nav-height: 60px;
|
||||||
|
|
||||||
/* Brand Colours */
|
/* Brand Colours */
|
||||||
--color-brand: #4fd1d1;
|
--color-brand: #4fd1d1;
|
||||||
--color-accent: #3c6f79;
|
--color-accent: #3c6f79;
|
||||||
@@ -55,8 +158,39 @@
|
|||||||
/* 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);
|
||||||
|
|
||||||
|
/* Functional Colours */
|
||||||
|
--location-yellow: #edff22;
|
||||||
|
--delay-orange: #ff914d;
|
||||||
|
--alert-orange: #f87728;
|
||||||
|
--cancel-red: #c60000;
|
||||||
|
--early-blue: #5ec1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse Animations */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fast-pulse {
|
||||||
|
0%,
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
25%,
|
||||||
|
75% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
47
src/lib/locations-object.svelte.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getName(code: string | number | null | undefined): ApiLocationFilter.LocationFilterObject | null {
|
||||||
|
try {
|
||||||
|
if (!code) return null;
|
||||||
|
|
||||||
|
const query = String(code).toUpperCase().trim();
|
||||||
|
|
||||||
|
const match = this.data.find((loc) => loc.t === query || loc.c === query);
|
||||||
|
return match ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error finding location object: ', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOCATIONS = new LocationStore();
|
||||||
66
src/lib/owlClient.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { OwlBoardClient, ValidationError, ApiError } from '@owlboard/owlboard-ts';
|
||||||
|
import { browser, dev } from '$app/environment';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
// 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 function ThrowApiError(e: unknown): never {
|
||||||
|
// Handle Request failure
|
||||||
|
if (e instanceof TypeError && e.message === 'Failed to fetch') {
|
||||||
|
throw error(503, {
|
||||||
|
message: 'Unable to connect to the OwlBoard server',
|
||||||
|
name: 'NETWORK_ERROR',
|
||||||
|
code: 'NETWORK_UNREACHABLE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map ApiError 'code' values
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
console.error(JSON.stringify(e), e.code, 'ERRCODE');
|
||||||
|
let status = 500;
|
||||||
|
if (e.code === 'AUTH') {
|
||||||
|
status = 401;
|
||||||
|
} else if (e.code === 'NOT_FOUND') {
|
||||||
|
status = 404;
|
||||||
|
} else if (e.code === 'RATE_LIMIT') {
|
||||||
|
status = 421;
|
||||||
|
} else if (e.code === 'NETWORK_DISCONNECTED') {
|
||||||
|
status = 503;
|
||||||
|
} else if (e.code === 'VALIDATION') {
|
||||||
|
status = 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error(status, {
|
||||||
|
message: e.message || 'An operational error has occurred',
|
||||||
|
name: e.name || 'API_ERROR',
|
||||||
|
code: e.code || 'UNKNOWN_API_ERROR'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for other error kind
|
||||||
|
const genericMessage = e instanceof Error ? e.message : 'An unexpected error occurred.';
|
||||||
|
console.log(e);
|
||||||
|
throw error(500, {
|
||||||
|
message: genericMessage,
|
||||||
|
name: 'CRITICAL_ERROR',
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
msg: 'UNHANDLED_EXCEPTION'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ValidationError, ApiError };
|
||||||
67
src/lib/preferences.svelte.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export interface Preferences {
|
||||||
|
NearToMe: boolean;
|
||||||
|
ShowPassingTrains: boolean;
|
||||||
|
BoardWakeLock: boolean;
|
||||||
|
AutoRefresh: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'ob_pref';
|
||||||
|
|
||||||
|
const DEFAULT_PREFS: Preferences = {
|
||||||
|
NearToMe: false,
|
||||||
|
ShowPassingTrains: true,
|
||||||
|
BoardWakeLock: true,
|
||||||
|
AutoRefresh: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadPrefs(): Preferences {
|
||||||
|
if (!browser) return DEFAULT_PREFS;
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!stored) return DEFAULT_PREFS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
const cleaned = {} as Preferences;
|
||||||
|
|
||||||
|
for (const key of Object.keys(DEFAULT_PREFS) as Array<keyof Preferences>) {
|
||||||
|
cleaned[key] = key in parsed ? parsed[key] : DEFAULT_PREFS[key];
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_PREFS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PreferencesStore {
|
||||||
|
current = $state<Preferences>(loadPrefs());
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
$effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.current));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleNearToMe(enabled: boolean) {
|
||||||
|
this.current.NearToMe = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleShowPassingTrains(enabled: boolean) {
|
||||||
|
this.current.ShowPassingTrains = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleBoardWakeLock(enabled: boolean) {
|
||||||
|
this.current.BoardWakeLock = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAutoRefresh(enabled: boolean) {
|
||||||
|
this.current.AutoRefresh = enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prefs = new PreferencesStore();
|
||||||
73
src/lib/quick-links.svelte.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
export interface QuickLink {
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
lastAccessed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETURNED_LENGTH: number = 6;
|
||||||
|
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) {
|
||||||
|
try {
|
||||||
|
this.#links = JSON.parse(saved);
|
||||||
|
} catch {
|
||||||
|
this.#links = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('ql', JSON.stringify(this.#links));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.#links = [];
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('ql');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const quickLinks = new QuickLinksService();
|
||||||
134
src/lib/utils/time.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import type { ApiTrainsTrainDetails } from '@owlboard/owlboard-ts';
|
||||||
|
|
||||||
|
export const estClass = (act: any, est: any) => (act ? 'act' : 'est');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts ISO/JSON time to UK-formatted HH:MM string, with optional (default off) seconds
|
||||||
|
* Ensures Europe/London timezone irrespective of browser timezone.
|
||||||
|
*/
|
||||||
|
export function formatUkTime(
|
||||||
|
dateStr: string | Date | undefined,
|
||||||
|
includeSeconds: boolean = false
|
||||||
|
): 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',
|
||||||
|
...(includeSeconds && { second: '2-digit' }),
|
||||||
|
hour12: false,
|
||||||
|
timeZone: 'Europe/London'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts ISO/JSON time to UK-formatted DD/MM/YY HH:MM:SS string.
|
||||||
|
* Ensures Europe/London timezone irrespective of browser timezone.
|
||||||
|
*/
|
||||||
|
export function formatUkDateTime(
|
||||||
|
dateStr: string | Date | undefined,
|
||||||
|
includeSeconds: boolean = false
|
||||||
|
): string {
|
||||||
|
if (!dateStr) return '--/--/-- --:--:--';
|
||||||
|
|
||||||
|
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) return '--/--/-- --:--:--';
|
||||||
|
|
||||||
|
return date
|
||||||
|
.toLocaleString('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
...(includeSeconds && { second: '2-digit' }),
|
||||||
|
hour12: false,
|
||||||
|
timeZone: 'Europe/London'
|
||||||
|
})
|
||||||
|
.replace(',', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specific type that can handle the TrainDetails.ServiceLocation and the ApiStationsBoard.BoardService types for delay calculation
|
||||||
|
*/
|
||||||
|
interface DelayInput {
|
||||||
|
// Board types
|
||||||
|
sta?: string | null;
|
||||||
|
std?: string | null;
|
||||||
|
eta?: string | null;
|
||||||
|
etd?: string | null;
|
||||||
|
ata?: string | null;
|
||||||
|
atd?: string | null;
|
||||||
|
|
||||||
|
// Journey types
|
||||||
|
pta?: string | null;
|
||||||
|
ptd?: string | null;
|
||||||
|
wta?: string | null;
|
||||||
|
wtd?: string | null;
|
||||||
|
atp?: string | null;
|
||||||
|
wtp?: string | null;
|
||||||
|
etp?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates a 'delay' value, in the formats:
|
||||||
|
* RT, 1E, 7L, etc.
|
||||||
|
* @param 'Schedule Location' or 'Board Service' object
|
||||||
|
* @returns Delay string for departure boards
|
||||||
|
*/
|
||||||
|
export function calculateDelay(loc: DelayInput): {
|
||||||
|
val: string;
|
||||||
|
type: string;
|
||||||
|
} {
|
||||||
|
const pairs = [
|
||||||
|
// Departure check (Board: atd/etd vs std | Journey: atd vs ptd/wtd)
|
||||||
|
{ actual: loc.atd || loc.etd, sched: loc.std || loc.ptd || loc.wtd },
|
||||||
|
// Arrival check (Board: ata/eta vs sta | Journey: ata vs pta/wta)
|
||||||
|
{ actual: loc.ata || loc.eta, sched: loc.sta || loc.pta || loc.wta },
|
||||||
|
// Passing check
|
||||||
|
{ actual: loc.atp || loc.etp, sched: loc.wtp }
|
||||||
|
];
|
||||||
|
|
||||||
|
const match = pairs.find((p) => p.actual && p.sched);
|
||||||
|
|
||||||
|
if (!match || !match.actual || !match.sched) return { val: '', type: 'none' };
|
||||||
|
|
||||||
|
const diffMinutes = Math.round((Date.parse(match.actual) - Date.parse(match.sched)) / 60000);
|
||||||
|
|
||||||
|
if (diffMinutes === 0) return { val: 'RT', type: 'ontime' };
|
||||||
|
|
||||||
|
const absDiff = Math.abs(diffMinutes);
|
||||||
|
if (diffMinutes > 0) {
|
||||||
|
return { val: `${absDiff}L`, type: 'late' };
|
||||||
|
} else {
|
||||||
|
return { val: `${absDiff}E`, type: 'early' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts a pair of times (string or Date) and outputs a delay class 'delay-early', 'delay-late' or 'delay-rt'
|
||||||
|
* @param sched Scheduled Time (string, Date)
|
||||||
|
* @param act Actual Time (string, Date)
|
||||||
|
*/
|
||||||
|
export function delayClassFromTimePair(sched: any, act: any): string {
|
||||||
|
if (!sched || !act) return '';
|
||||||
|
|
||||||
|
const s = new Date(sched).getTime();
|
||||||
|
const a = new Date(act).getTime();
|
||||||
|
|
||||||
|
if (isNaN(s) || isNaN(a)) return '';
|
||||||
|
|
||||||
|
const diff = a - s;
|
||||||
|
const oneMinute = 60000;
|
||||||
|
|
||||||
|
// on-time if within one minute
|
||||||
|
if (Math.abs(diff) < oneMinute) {
|
||||||
|
return 'delay-rt';
|
||||||
|
}
|
||||||
|
|
||||||
|
return diff > 0 ? 'delay-late' : 'delay-early';
|
||||||
|
}
|
||||||
@@ -2,25 +2,60 @@
|
|||||||
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 Button from '$lib/components/ui/Button.svelte';
|
import noResult from '$lib/assets/img/no-data.svg';
|
||||||
|
import Button from '$lib/components/ui/form-elements/Button.svelte';
|
||||||
|
import { IconAntennaBarsOff } from '@tabler/icons-svelte-runes';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="error-wrapper">
|
<!-- Will need to check error type, using the upstream code combined with response code -->
|
||||||
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
|
|
||||||
|
|
||||||
|
<!-- {#if page.error}
|
||||||
|
{JSON.stringify(page.error)}
|
||||||
|
STATUS: {page.status}
|
||||||
|
{/if} -->
|
||||||
|
|
||||||
|
<div class="error-wrapper">
|
||||||
|
{#if page.error?.code}
|
||||||
|
{#if page.error.code === 'NOT_FOUND'}
|
||||||
|
<img class="err-img" src={noResult} alt="" role="presentation" width="200" height="200" />
|
||||||
|
{:else if page.error.code === 'RATE_LIMIT'}
|
||||||
|
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
|
||||||
|
{:else if page.error.code === 'VALIDATION'}
|
||||||
|
<img class="err-img" src={noResult} alt="" role="presentation" width="150" height="210" />
|
||||||
|
{:else if page.error.code === 'AUTH'}
|
||||||
|
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
|
||||||
|
{:else if page.error.code === 'SERVER'}
|
||||||
|
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
|
||||||
|
{:else if page.error.code === 'NETWORK_DISCONNECTED'}
|
||||||
|
<div class="err-img gentle-flash">
|
||||||
|
<IconAntennaBarsOff size={120} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
|
||||||
|
{/if}
|
||||||
|
{: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">
|
||||||
{page.error?.message ?? 'An unexpected derailment occurred.'}
|
{page.error?.message ?? 'An unexpected derailment occurred.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if page.error?.owlCode}
|
{#if page.error?.code}
|
||||||
<div class="debug-info">
|
<div class="debug-info">
|
||||||
<code>Ref: {page.error.owlCode}</code>
|
<code>Ref: {page.error.code}</code>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if page.error?.code === 'NETWORK_DISCONNECTED'}
|
||||||
|
<Button onclick={() => window.location.reload()} color={'accent'}>Retry</Button>
|
||||||
|
{:else if page.error?.code === 'NOT_FOUND'}
|
||||||
|
<Button onclick={() => history.back()} color={'accent'}>Go Back</Button>
|
||||||
|
{:else}
|
||||||
<Button href={'/'} color={'accent'}>Return to Home</Button>
|
<Button href={'/'} color={'accent'}>Return to Home</Button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -48,6 +83,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes gentle-flash {
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gentle-flash {
|
||||||
|
animation: gentle-flash ease-in-out 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
color: #ff4444;
|
color: #ff4444;
|
||||||
letter-spacing: 0.2rem;
|
letter-spacing: 0.2rem;
|
||||||
@@ -59,12 +107,14 @@
|
|||||||
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 { browser } from '$app/environment';
|
||||||
|
import { navigating } from '$app/state';
|
||||||
|
import { LOCATIONS } from '$lib/locations-object.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';
|
||||||
|
|
||||||
@@ -8,7 +15,28 @@
|
|||||||
import logoPlain from '$lib/assets/round-logo.svg';
|
import logoPlain from '$lib/assets/round-logo.svg';
|
||||||
import favicon from '$lib/assets/round-logo.svg';
|
import favicon from '$lib/assets/round-logo.svg';
|
||||||
|
|
||||||
import { IconHome, IconDialpad, IconSettings, IconHelp, IconDots } from '@tabler/icons-svelte';
|
import {
|
||||||
|
IconHome,
|
||||||
|
IconDialpad,
|
||||||
|
IconSettings,
|
||||||
|
IconHelp,
|
||||||
|
IconDots,
|
||||||
|
IconSpy,
|
||||||
|
} from '@tabler/icons-svelte-runes';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
LOCATIONS.init();
|
||||||
|
if (browser && 'serviceWorker' in navigator) {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.register('/service-worker.js', {
|
||||||
|
type: 'module'
|
||||||
|
});
|
||||||
|
console.info('OwlBoard Service Worker registered', registration.scope);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Service Worker installation failed: ', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -18,15 +46,16 @@
|
|||||||
|
|
||||||
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 },
|
||||||
|
{ label: 'Privacy Policy', path: '/privacy/', icon: IconSpy },
|
||||||
];
|
];
|
||||||
|
|
||||||
let navWidth = $state(0);
|
let navWidth = $state(0);
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
const ITEM_WIDTH = 70;
|
const ITEM_WIDTH = 110;
|
||||||
const MORE_BUTTON_WIDTH = 70;
|
const MORE_BUTTON_WIDTH = 110;
|
||||||
|
|
||||||
const activePath = $derived(page.url?.pathname ?? '');
|
const activePath = $derived(page.url?.pathname ?? '');
|
||||||
|
|
||||||
@@ -72,13 +101,20 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
<TimezoneWarning />
|
||||||
|
|
||||||
|
{#if navigating && navigating.to}
|
||||||
|
<Loading />
|
||||||
|
{:else}
|
||||||
{@render children()}
|
{@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(/\/$/, '')}
|
||||||
|
{@const Icon = item.icon}
|
||||||
<a
|
<a
|
||||||
href={item.path}
|
href={item.path}
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
@@ -86,7 +122,7 @@
|
|||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
onclick={() => (menuOpen = false)}
|
onclick={() => (menuOpen = false)}
|
||||||
>
|
>
|
||||||
<item.icon size={24} stroke={isActive ? 2 : 1.5} class="nav-icon" />
|
<Icon size={24} stroke={isActive ? 2 : 1.5} class="nav-icon" />
|
||||||
|
|
||||||
<span class="label">{item.label}</span>
|
<span class="label">{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -110,7 +146,8 @@
|
|||||||
></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(/\/$/, '')}
|
||||||
|
{@const Icon = item.icon}
|
||||||
<a
|
<a
|
||||||
href={item.path}
|
href={item.path}
|
||||||
class="menu-popover-item"
|
class="menu-popover-item"
|
||||||
@@ -118,7 +155,7 @@
|
|||||||
class:active={isActive}
|
class:active={isActive}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
<item.icon size={20} />
|
<Icon size={20} />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -128,17 +165,18 @@
|
|||||||
</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 320px to view the app.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
header {
|
header {
|
||||||
top: 0;
|
top: 0;
|
||||||
height: 80px;
|
height: var(--header-height);
|
||||||
box-shadow: var(--shadow-std);
|
box-shadow: var(--shadow-std);
|
||||||
}
|
}
|
||||||
.logo-link {
|
.logo-link {
|
||||||
@@ -156,10 +194,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 {
|
||||||
@@ -183,17 +223,17 @@
|
|||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background-color: var(--color-bg-dark);
|
background-color: var(--color-bg-dark);
|
||||||
background-image: radial-gradient(var(--color-bg-dark), var(--color-bg-light));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: 60px;
|
height: var(--nav-height);
|
||||||
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;
|
||||||
@@ -301,7 +341,7 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 299px) {
|
@media (max-width: 319px) {
|
||||||
.viewport-guard {
|
.viewport-guard {
|
||||||
display: block;
|
display: block;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -318,7 +358,9 @@
|
|||||||
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>
|
||||||
|
|||||||
@@ -11,13 +11,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="logo-container">
|
<div class="logo-container">
|
||||||
<img
|
<button
|
||||||
class="logo"
|
type="button"
|
||||||
src={logo}
|
class="logo-btn"
|
||||||
alt="OwlBoard Logo"
|
|
||||||
onclick={handleLogoTap}
|
onclick={handleLogoTap}
|
||||||
class:animate={isSpinning}
|
class:animate={isSpinning}
|
||||||
/>
|
aria-label="Spin the OwlBoard logo"
|
||||||
|
>
|
||||||
|
<img class="logo-img" src={logo} alt="OwlBoard Logo" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="about">
|
<section class="about">
|
||||||
@@ -29,7 +31,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
|
||||||
@@ -56,6 +61,17 @@ Why OwlBoard? The name was chosen as an evolution of its predecessor, 'Athena';
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
padding-top: 25px;
|
||||||
|
margin: auto;
|
||||||
|
width: clamp(80px, 20vw, 200px);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes owl-spin {
|
@keyframes owl-spin {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
@@ -68,14 +84,13 @@ Why OwlBoard? The name was chosen as an evolution of its predecessor, 'Athena';
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo-img {
|
||||||
padding-top: 25px;
|
width: 100%;
|
||||||
margin: auto;
|
|
||||||
height: auto;
|
height: auto;
|
||||||
width: clamp(80px, 20vw, 200px);
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo.animate {
|
.logo-btn.animate {
|
||||||
animation: owl-spin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
animation: owl-spin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
170
src/routes/board/+page.svelte
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, untrack } from 'svelte';
|
||||||
|
import { quickLinks } from '$lib/quick-links.svelte';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import StationAlertCard from '$lib/components/ui/station-board/StationAlertCard.svelte';
|
||||||
|
|
||||||
|
import { formatUkDateTime, formatUkTime } from '$lib/utils/time';
|
||||||
|
import StaffServicesTable from '$lib/components/ui/station-board/StaffServicesTable.svelte';
|
||||||
|
let { data } = $props();
|
||||||
|
let now = $state(new Date());
|
||||||
|
|
||||||
|
// Handle auto-refreshing & Wake-lock
|
||||||
|
$effect(() => {
|
||||||
|
let intervalId: number | undefined;
|
||||||
|
let wakeLock: WakeLockSentinel | null = null;
|
||||||
|
|
||||||
|
const requestWakeLock = async () => {
|
||||||
|
if ('wakeLock' in navigator && !wakeLock) {
|
||||||
|
try {
|
||||||
|
wakeLock = await navigator.wakeLock.request('screen');
|
||||||
|
console.log("Wake lock obtained");
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Wake lock request rejected: ", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const releaseWakeLock = async () => {
|
||||||
|
if (wakeLock) {
|
||||||
|
try {
|
||||||
|
await wakeLock.release();
|
||||||
|
console.log("Wake lock released");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Wake lock release failed: ", error);
|
||||||
|
}
|
||||||
|
wakeLock = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPolling = async () => {
|
||||||
|
await requestWakeLock();
|
||||||
|
if (intervalId) return;
|
||||||
|
intervalId = window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
console.log("Invalidating data")
|
||||||
|
await invalidateAll();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Background data fetch failed: ", error)
|
||||||
|
}
|
||||||
|
}, 61000);
|
||||||
|
console.log("Polling started")
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPolling = async () => {
|
||||||
|
if (intervalId) {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
intervalId = undefined;
|
||||||
|
console.log("Polling ended")
|
||||||
|
}
|
||||||
|
await releaseWakeLock();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
stopPolling();
|
||||||
|
} else {
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!document.hidden) {
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopPolling();
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
now = new Date();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentBoardId = $derived(
|
||||||
|
data.BoardLocation ? (data.BoardLocation.c ?? data.BoardLocation.t) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update 'QuickLinks' when currentBoardId changes
|
||||||
|
$effect(() => {
|
||||||
|
if (currentBoardId) {
|
||||||
|
// Keep the execution and logs inside untrack to isolate dependencies completely
|
||||||
|
untrack(() => {
|
||||||
|
quickLinks.recordVisit(currentBoardId);
|
||||||
|
console.log(`QuickLink visit recorded: ${JSON.stringify(data.BoardLocation)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wake Lock API Handling
|
||||||
|
// Load Data Invalidation Handling
|
||||||
|
// Refresh countdown logic
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="board-wrapper">
|
||||||
|
<div class="time-data">
|
||||||
|
<span class="time-loaded">Updated: {formatUkDateTime(data.boardData.producedAt, true)}</span>
|
||||||
|
<span class="time-now">{formatUkTime(now, true)}</span>
|
||||||
|
</div>
|
||||||
|
{#if data.boardData.data.m?.length}
|
||||||
|
<StationAlertCard messages={data.boardData.data.m} />
|
||||||
|
{/if}
|
||||||
|
{#if data.boardData.data.s?.length}
|
||||||
|
<div class="service-list-wrapper">
|
||||||
|
<StaffServicesTable services={data.boardData.data.s} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.board-wrapper {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100dvh - var(--nav-height) - var(--header-height));
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-data {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 10px auto;
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-loaded,
|
||||||
|
.time-now {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-loaded {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-now {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-list-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: calc(100dvh - 60px);
|
||||||
|
width: 97%;
|
||||||
|
max-width: 700px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
40
src/routes/board/+page.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { LOCATIONS } from '$lib/locations-object.svelte';
|
||||||
|
import { ApiError, OwlClient, ValidationError } from '$lib/owlClient';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ url, fetch }) => {
|
||||||
|
const locId = url.searchParams.get('loc');
|
||||||
|
|
||||||
|
if (!LOCATIONS.loaded) {
|
||||||
|
await LOCATIONS.init();
|
||||||
|
}
|
||||||
|
if (!locId) {
|
||||||
|
error(400, {
|
||||||
|
message: 'Location not provided',
|
||||||
|
code: 'VALIDATION'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const BoardLocation = LOCATIONS.find(locId);
|
||||||
|
|
||||||
|
if (!BoardLocation) {
|
||||||
|
error(404, {
|
||||||
|
message: `Location (${locId.toUpperCase()}) not found`,
|
||||||
|
code: 'VALIDATION'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const boardData = await OwlClient.board.getByLocation(locId, fetch);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
BoardLocation,
|
||||||
|
boardData
|
||||||
|
};
|
||||||
|
} catch (e: unknown) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
170
src/routes/pis/+page.svelte
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<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/form-elements/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: unknown) {
|
||||||
|
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 instanceof Error ? e.message : String(e)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} 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 instanceof Error ? e.message : String(e)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} 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
@@ -0,0 +1,5 @@
|
|||||||
|
export const load = () => {
|
||||||
|
return {
|
||||||
|
title: 'PIS Codes'
|
||||||
|
};
|
||||||
|
};
|
||||||
66
src/routes/preferences/+page.svelte
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
|
||||||
|
import Button from '$lib/components/ui/form-elements/Button.svelte';
|
||||||
|
import Toggle from '$lib/components/ui/form-elements/Toggle.svelte';
|
||||||
|
import { quickLinks } from '$lib/quick-links.svelte';
|
||||||
|
import { prefs } from '$lib/preferences.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="page-body">
|
||||||
|
<BaseCard header={'Display Settings'}>
|
||||||
|
<section class="card-content">
|
||||||
|
<Button onclick={() => quickLinks.reset()}>Reset QuickLinks</Button>
|
||||||
|
<div class="toggle-container">
|
||||||
|
<Toggle
|
||||||
|
label={'Show stations near to you'}
|
||||||
|
id={'toggle-nearby-stations'}
|
||||||
|
checked={prefs.current.NearToMe}
|
||||||
|
onchange={(checked) => prefs.toggleNearToMe(checked)}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label={'Show passsing trains on boards'}
|
||||||
|
id={'toggle-passing-trains'}
|
||||||
|
checked={prefs.current.ShowPassingTrains}
|
||||||
|
onchange={(checked) => prefs.toggleShowPassingTrains(checked)}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label={'Keep screen awake on live pages'}
|
||||||
|
id={'toggle-board-wake-lock'}
|
||||||
|
checked={prefs.current.BoardWakeLock}
|
||||||
|
onchange={(checked) => prefs.toggleBoardWakeLock(checked)}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label={'Auto-refresh data'}
|
||||||
|
id={'toggle-auto-refresh'}
|
||||||
|
checked={prefs.current.AutoRefresh}
|
||||||
|
onchange={(checked) => prefs.toggleAutoRefresh(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</BaseCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 5px 0 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
src/routes/preferences/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const load = () => {
|
||||||
|
return {
|
||||||
|
title: 'Preferences'
|
||||||
|
};
|
||||||
|
};
|
||||||
218
src/routes/privacy/+page.svelte
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="policy-block-container">
|
||||||
|
<p class="policy-meta"><em>Last updated: May 2026</em></p>
|
||||||
|
|
||||||
|
<p class="policy-intro">
|
||||||
|
The OwlBoard project is built to help you track trains, not track you. Here is exactly what data
|
||||||
|
is used, how it is used, and why.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr class="policy-divider" />
|
||||||
|
|
||||||
|
<section class="policy-section">
|
||||||
|
<h3 class="section-heading">1. The Data Processed (And What Is Discarded)</h3>
|
||||||
|
|
||||||
|
<ul class="policy-list">
|
||||||
|
<li class="list-item">
|
||||||
|
<strong class="highlight-text">Email Addresses:</strong> During sign-up, an email address is
|
||||||
|
requested solely to send a verification code. This needs to happen, as some data is
|
||||||
|
restricted to rail staff only due to licensing restrictions.
|
||||||
|
<strong
|
||||||
|
>The moment the verification code is sent, the email address is permanently deleted
|
||||||
|
from the system.</strong
|
||||||
|
> It is not stored, it is not logged, and it cannot be looked up later.
|
||||||
|
</li>
|
||||||
|
<li class="list-item">
|
||||||
|
<strong class="highlight-text">Platform Identity:</strong> Instead of an email, an account
|
||||||
|
is identified by two things:
|
||||||
|
<ol class="policy-nested-list">
|
||||||
|
<li>
|
||||||
|
A unique alphanumeric <strong class="code-text">"key"</strong> generated randomly for the
|
||||||
|
device upon verification.
|
||||||
|
</li>
|
||||||
|
<li>The <strong class="code-text">domain portion</strong> of the registration email.</li>
|
||||||
|
</ol>
|
||||||
|
<blockquote class="policy-example">
|
||||||
|
<strong>Example:</strong> If signing up as <code>user@owlboard.info</code>, the email is
|
||||||
|
wiped, and the account identifier becomes <code>[Your-Secret-Key] @ owlboard.info</code>.
|
||||||
|
This allows OwlBoard to manage access without knowing who you are.
|
||||||
|
</blockquote>
|
||||||
|
</li>
|
||||||
|
<li class="list-item">
|
||||||
|
<strong class="highlight-text">Fuzzy Location Data:</strong> If the "Near to Me" toggle is
|
||||||
|
enabled, exact GPS coordinates are calculated, but never leave your device. Your device compresses the position
|
||||||
|
into a <strong class="highlight-text">"fuzzy location block"</strong> (a geohash) roughly 1.2km
|
||||||
|
by 0.6km in size before contacting the server to request nearby stations. OwlBoard only processes
|
||||||
|
that neighborhood block—never an exact location.
|
||||||
|
</li>
|
||||||
|
<li class="list-item">
|
||||||
|
<strong class="highlight-text">Server Logs & IP Addresses:</strong> Like any web application,
|
||||||
|
self-hosted security tools and the Web Application Firewall (WAF) temporarily process network
|
||||||
|
IP addresses. This is strictly for system health, infrastructure monitoring, and blocking malicious or
|
||||||
|
automated attacks.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="policy-divider" />
|
||||||
|
|
||||||
|
<section class="policy-section">
|
||||||
|
<h3 class="section-heading">2. What Is Considered "Personal Data"?</h3>
|
||||||
|
|
||||||
|
<p class="policy-text">
|
||||||
|
Under the GDPR, "Personal Data" is anything that can directly or indirectly pinpoint a living
|
||||||
|
individual.
|
||||||
|
</p>
|
||||||
|
<p class="policy-text">
|
||||||
|
Because email addresses are discarded instantly and location hardware telemetry is fuzzed
|
||||||
|
directly on the device, <strong
|
||||||
|
>station boards, preferences, and account keys are completely pseudonymized.</strong
|
||||||
|
> However, because network IP addresses are technically classified as personal data under European
|
||||||
|
law, transient security logs are treated with the highest level of guardrail protection.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="policy-divider" />
|
||||||
|
|
||||||
|
<section class="policy-section">
|
||||||
|
<h3 class="section-heading">
|
||||||
|
3. Subject Access Requests (SAR) & The Right to be Forgotten
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="policy-text">
|
||||||
|
Absolute sovereignty over your digital footprint should be standard, without jumping through
|
||||||
|
bureaucratic hoops. There is no need to send an email to exercise GDPR rights:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="policy-list">
|
||||||
|
<li class="list-item">
|
||||||
|
<strong class="highlight-text">Instant SAR (Right of Access):</strong> The
|
||||||
|
<strong>Account</strong> dashboard contains a dedicated section displaying
|
||||||
|
every single line of data which can be identified with the account key block. It can be viewed or copied instantly.
|
||||||
|
</li>
|
||||||
|
<li class="list-item">
|
||||||
|
<strong class="highlight-text">The Right to be Forgotten (Instant Erasure):</strong> A
|
||||||
|
prominent <strong>"Delete Account"</strong> button is available on that same page. Clicking this
|
||||||
|
instantly wipes the account key. You will be able to re-register at any time, and all of your preferences remain only on your device.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="policy-text">
|
||||||
|
While the pseudonymized settings left behind are not technically personal data once
|
||||||
|
disconnected from an identity, the OwlBoard project operates on a simple philosophy.
|
||||||
|
When the delete button is pressed, the data is erased.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="policy-divider" />
|
||||||
|
|
||||||
|
<section class="policy-section">
|
||||||
|
<h3 class="section-heading">4. No Third-Party Conglomerates</h3>
|
||||||
|
|
||||||
|
<p class="policy-text">
|
||||||
|
All data is hosted transparently on isolated, self-hosted server infrastructure in Somerset. OwlBoard does
|
||||||
|
not use third-party analytics trackers, tracking cookies, or advertising networks.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.policy-block-container {
|
||||||
|
margin: 1rem auto;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 650px;
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-block-container:last-child {
|
||||||
|
padding-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-meta {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted, #64748b);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-intro {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-divider {
|
||||||
|
border: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-border, #e2e8f0);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Section Blocks --- */
|
||||||
|
.policy-section {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-text {
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Lists & Typography Elements --- */
|
||||||
|
.policy-list {
|
||||||
|
list-style-type: disc;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-nested-list {
|
||||||
|
list-style-type: decimal;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-nested-list li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-example {
|
||||||
|
background-color: var(--color-bg-light);
|
||||||
|
border-left: 4px solid var(--color-accent);
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-text,
|
||||||
|
.policy-example code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
src/routes/privacy/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const load = () => {
|
||||||
|
return {
|
||||||
|
title: 'Privacy'
|
||||||
|
};
|
||||||
|
};
|
||||||
32
src/routes/trains/+page.svelte
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import NoResults from '$lib/components/ui/NoResults.svelte';
|
||||||
|
import TrainService from '$lib/components/ui/TrainService.svelte';
|
||||||
|
let { data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h6 style="text-align:center;width=100%;margin:auto;padding-top:1rem;font-size:1rem;">
|
||||||
|
DateSelector
|
||||||
|
</h6>
|
||||||
|
{#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>
|
||||||
29
src/routes/trains/+page.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { OwlClient, ApiError, ValidationError, ThrowApiError } from '$lib/owlClient';
|
||||||
|
import type { ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ fetch, 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;
|
||||||
|
|
||||||
|
// Validation removed, handled by the API Client Lib
|
||||||
|
|
||||||
|
// 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, fetch);
|
||||||
|
|
||||||
|
let title = !!headcode ? headcode.toUpperCase() : 'Error';
|
||||||
|
results = response.data;
|
||||||
|
return {
|
||||||
|
title: title,
|
||||||
|
results: results
|
||||||
|
};
|
||||||
|
} catch (e: unknown) {
|
||||||
|
ThrowApiError(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
121
src/service-worker.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/// <reference types="@sveltejs/kit" />
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
import type { ApiEnvelope } from '@owlboard/api-schema-types';
|
||||||
|
import { build, files, version } from '$service-worker';
|
||||||
|
|
||||||
|
const sw = self as unknown as ServiceWorkerGlobalScope;
|
||||||
|
const CACHE_NAME = `owlboard-cache-${version}`;
|
||||||
|
const ASSETS = [
|
||||||
|
...build,
|
||||||
|
...files.filter((file) => !file.endsWith('manifest.json') && !file.startsWith('/icons/')),
|
||||||
|
'/'
|
||||||
|
];
|
||||||
|
|
||||||
|
sw.addEventListener('install', (event) => {
|
||||||
|
sw.skipWaiting();
|
||||||
|
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)));
|
||||||
|
});
|
||||||
|
|
||||||
|
sw.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
Promise.all([
|
||||||
|
caches.keys().then((keys) => {
|
||||||
|
return Promise.all(
|
||||||
|
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
sw.clients.claim()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
sw.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Uncached static assets
|
||||||
|
if (url.pathname.endsWith('manifest.json') || url.pathname.includes('/icons/')) {
|
||||||
|
event.respondWith(fetch(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static Assets (Cache-First)
|
||||||
|
if (ASSETS.includes(url.pathname) || url.pathname.startsWith('/_app/')) {
|
||||||
|
event.respondWith(caches.match(request).then((res) => res || fetch(request)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cachable API Responses - stale-while-revalidate
|
||||||
|
if (url.pathname === '/api/v3/locationFilter/data') {
|
||||||
|
event.respondWith(
|
||||||
|
caches.open('ob-dynamic-cache').then(async (cache) => {
|
||||||
|
const cachedResponse = await cache.match(request);
|
||||||
|
|
||||||
|
// Ensure fallback response exists
|
||||||
|
const makeOfflineFallback = () => {
|
||||||
|
const errorObject: ApiEnvelope.Envelope = {
|
||||||
|
e: {
|
||||||
|
code: 'NETWORK_DISCONNECTED',
|
||||||
|
msg: 'Cannot connect to the OwlBoard server'
|
||||||
|
},
|
||||||
|
t: Math.floor(Date.now() / 1000)
|
||||||
|
};
|
||||||
|
return new Response(JSON.stringify(errorObject), {
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const networkFetch = fetch(request)
|
||||||
|
.then((networkResponse) => {
|
||||||
|
if (networkResponse.ok) {
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// If offline & cache empty, return fallback
|
||||||
|
return makeOfflineFallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
// If cachedResponse is undefined, networkFetch safely resolves to a Response
|
||||||
|
return cachedResponse || networkFetch;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline API Fallback (Network-First)
|
||||||
|
if (url.pathname.startsWith('/api') || url.hostname.includes('api')) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request).catch(() => {
|
||||||
|
const errorObject: ApiEnvelope.Envelope = {
|
||||||
|
e: {
|
||||||
|
code: 'NETWORK_DISCONNECTED',
|
||||||
|
msg: 'Cannot connect to the OwlBoard server'
|
||||||
|
},
|
||||||
|
t: Math.floor(Date.now() / 1000)
|
||||||
|
};
|
||||||
|
return new Response(JSON.stringify(errorObject), {
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nav fallback - return fallback page, if offline and page requested
|
||||||
|
if (request.mode === 'navigate' || request.headers.get('accept')?.includes('text/html')) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request).catch(() => {
|
||||||
|
// Serve svelte fallback page
|
||||||
|
return caches.match('/') as Promise<Response>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
BIN
static/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
static/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
static/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
static/icons/maskable-512.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
62
static/manifest.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "OwlBoard",
|
||||||
|
"short_name": "OwlBoard",
|
||||||
|
"description": "Real-time UK rail data and operational tools for staff and enthusiasts.",
|
||||||
|
"id": "/",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"display_override": [
|
||||||
|
"window-controls-overlay",
|
||||||
|
"standalone",
|
||||||
|
"minimal-ui"
|
||||||
|
],
|
||||||
|
"background_color": "#404c55",
|
||||||
|
"theme_color": "#4fd1d1",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"categories": [
|
||||||
|
"transportation",
|
||||||
|
"utilities"
|
||||||
|
],
|
||||||
|
"dir": "ltr",
|
||||||
|
"lang": "en-GB",
|
||||||
|
"prefer_related_applications": false,
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Find PIS",
|
||||||
|
"short_name": "PIS",
|
||||||
|
"description": "Find PIS Codes fast",
|
||||||
|
"url": "/pis",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"handle_links": "preferred",
|
||||||
|
"launch_handler": {
|
||||||
|
"client_mode": "focus-existing"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
static/type/inconsolata/latin-wdth-normal.woff2
Normal file
BIN
static/type/jetbrains-mono/JetBrainsMono-Bold.woff2
Normal file
BIN
static/type/jetbrains-mono/JetBrainsMono-BoldItalic.woff2
Normal file
BIN
static/type/jetbrains-mono/JetBrainsMono-ExtraBold.woff2
Normal file
BIN
static/type/jetbrains-mono/JetBrainsMono-ExtraBoldItalic.woff2
Normal file
BIN
static/type/jetbrains-mono/JetBrainsMono-ExtraLight.woff2
Normal file
BIN
static/type/jetbrains-mono/JetBrainsMono-ExtraLightItalic.woff2
Normal file
BIN
static/type/jetbrains-mono/JetBrainsMono-Italic.woff2
Normal file
BIN
static/type/jetbrains-mono/JetBrainsMono-Light.woff2
Normal file
BIN
static/type/jetbrains-mono/JetBrainsMono-LightItalic.woff2
Normal file
BIN
static/type/jetbrains-mono/JetBrainsMono-Medium.woff2
Normal file
BIN
static/type/jetbrains-mono/JetBrainsMono-MediumItalic.woff2
Normal file
BIN
static/type/jetbrains-mono/JetBrainsMono-Regular.woff2
Normal file
BIN
static/type/jetbrains-mono/JetBrainsMono-SemiBold.woff2
Normal file
BIN
static/type/jetbrains-mono/JetBrainsMono-SemiBoldItalic.woff2
Normal file
BIN
static/type/jetbrains-mono/JetBrainsMono-Thin.woff2
Normal file
BIN
static/type/jetbrains-mono/JetBrainsMono-ThinItalic.woff2
Normal file
@@ -8,7 +8,7 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": false,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"moduleResolution": "bundler"
|
"moduleResolution": "bundler"
|
||||||
}
|
}
|
||||||
|
|||||||