Compare commits
50 Commits
0011cced12
...
v3.0.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 91cb119b7d | |||
| 26e40c5bf6 | |||
| a746a1eac2 | |||
| 68af07b9bd | |||
| 16d929fad1 | |||
| 5bbffcecb8 | |||
| 5ead4f8296 | |||
| abb8663766 | |||
| 3225b60140 | |||
| f7b1b7fe0d | |||
| 8c0d385772 | |||
| a07315cec2 | |||
| f3393f3c07 | |||
| b649af1925 | |||
| aa1a989139 | |||
| 304b523127 | |||
| 777519ff5d | |||
| 4a969e626c | |||
| 3e1b7ea5d5 | |||
| fd213d6340 | |||
| 3eceddf20a | |||
| 1d461780ab | |||
| ec4dd5dd3b | |||
| a7c244171c | |||
| 3467f97889 | |||
| b1d8eea518 | |||
| deb151075a | |||
| d9b60daa8b | |||
| 2f0a6b9646 | |||
| 1165c02e26 | |||
| 45dd5a1cf5 | |||
| e47bebe7d4 | |||
| b7007d2fb3 | |||
| 64bc5b979d | |||
| 3240560a0b | |||
| a327582629 | |||
| 35877ae8ac | |||
| f5c3775f59 | |||
| e5d3c0a3a7 | |||
| 1b0b93b34b | |||
| 66ed33343c | |||
| 50e56159b7 | |||
| 6152afda3b | |||
| 061598a0ad | |||
| 54ea6ebf59 | |||
| d7f6d957b7 | |||
| 9e3d6856d9 | |||
| ea2468092c | |||
| d82592cebf | |||
| 6508244580 |
39
.gitea/workflows/build-push.yaml
Normal file
39
.gitea/workflows/build-push.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Build and Push container image
|
||||||
|
run-name: ${{ gitea.actor }} is building and pushing
|
||||||
|
|
||||||
|
on:
|
||||||
|
create:
|
||||||
|
tags: '*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_DOMAIN: git.fjla.uk
|
||||||
|
GITEA_REGISTRY_USER: owlbot
|
||||||
|
RESULT_IMAGE_NAME: owlboard/web-pwa
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
options: --privileged
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.GITEA_DOMAIN }}
|
||||||
|
username: ${{ env.GITEA_REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
- name: Build and Push image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.GITEA_DOMAIN }}/${{ env.RESULT_IMAGE_NAME }}:${{ gitea.ref_name }}
|
||||||
|
${{ env.GITEA_DOMAIN }}/${{ env.RESULT_IMAGE_NAME }}:latest
|
||||||
1
.npmrc
1
.npmrc
@@ -1 +1,2 @@
|
|||||||
engine-strict=true
|
engine-strict=true
|
||||||
|
@owlboard:registry=https://git.fjla.uk/api/packages/OwlBoard/npm/
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
WORKDIR /web-content
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.description="SvelteKit Static build - Designed to be an InitContainer to copy files from to the output web server container's filesystem"
|
||||||
|
|
||||||
|
COPY --from=builder /app/build ./
|
||||||
|
CMD ["sh", "-c", "cp -R /web-content/* /target-dir/"]
|
||||||
1924
package-lock.json
generated
1924
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -39,5 +39,9 @@
|
|||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.0.18",
|
||||||
"vitest-browser-svelte": "^2.0.2"
|
"vitest-browser-svelte": "^2.0.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@owlboard/owlboard-ts": "^3.0.0-dev.20260503T0051",
|
||||||
|
"@tabler/icons-svelte": "^3.40.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/app.d.ts
vendored
5
src/app.d.ts
vendored
@@ -2,7 +2,10 @@
|
|||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
interface Error {
|
||||||
|
message: string;
|
||||||
|
owlCode?: string;
|
||||||
|
}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
|
|||||||
@@ -4,8 +4,11 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<meta name="title" content="OwlBoard | Your fasted route to live and reference data" />
|
<meta name="title" content="OwlBoard | Your fastest route to live and reference data" />
|
||||||
<meta name="description" content="Live station departures, Live train tracking, PIS Codes & more" />
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Live station departures, Live train tracking, PIS Codes & more"
|
||||||
|
/>
|
||||||
<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" />
|
||||||
|
|||||||
1
src/lib/assets/img/no-data.svg
Normal file
1
src/lib/assets/img/no-data.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"><path fill="#fff" d="M0 0h120v120H0z"/><g fill="#00f"><path d="M60 0h60v60H60zM0 60h60v60H0z"/><path d="M12.72 32.97h1.08l1.22-4.9 1.2 4.9h1.08l2.16-7.4h-1.4l-1.3 5.17-1.25-5.16h-1l-1.26 5.16-1.3-5.16h-1.4zm12.6-5.54H24.1v.74a2 2 0 0 0-1.8-.87c-1.65 0-2.83 1.23-2.83 2.92 0 1.67 1.17 2.88 2.8 2.88a2.2 2.2 0 0 0 1.83-.85v.72h1.22zm-2.89 1.09c.95 0 1.63.7 1.63 1.72 0 .4-.16.86-.4 1.14-.27.33-.7.5-1.2.5-.98 0-1.65-.66-1.65-1.63 0-1.01.67-1.73 1.62-1.73m3.88 4.45h1.33v-3.08c-.03-.82.4-1.28 1.2-1.31V27.3h-.1c-.58 0-.86.16-1.2.67v-.54H26.3zm3.17 0h1.33v-2.61c0-.74.05-1.06.22-1.33q.33-.5.98-.51.53 0 .8.35c.18.24.26.65.26 1.39v2.7h1.33V30c0-1-.1-1.47-.4-1.9q-.56-.79-1.73-.8a2 2 0 0 0-1.56.66v-.53h-1.23zm6 0h1.33v-5.54h-1.33zm0-6.14h1.33v-1.25h-1.33zm2.4 6.14h1.33v-2.61c0-.74.05-1.06.22-1.33q.33-.5.98-.51.53 0 .8.35c.18.24.26.65.26 1.39v2.7h1.33V30c0-1-.1-1.47-.4-1.9q-.56-.79-1.73-.8a2 2 0 0 0-1.56.66v-.53h-1.23zm10.3-5.54v.7q-.62-.83-1.8-.83c-1.58 0-2.74 1.22-2.74 2.86 0 1.7 1.14 2.94 2.72 2.94.78 0 1.3-.25 1.82-.86q-.07 1.78-1.55 1.77c-.62 0-1-.16-1.39-.6h-1.5c.44 1.12 1.48 1.77 2.84 1.77 1 0 1.8-.35 2.31-1.01.4-.51.56-1.2.56-2.25v-4.5zm-1.66 1.09c.95 0 1.6.68 1.6 1.7 0 1-.62 1.66-1.58 1.66-.95 0-1.56-.65-1.56-1.66 0-1.02.61-1.7 1.54-1.7" aria-label="Warning" font-family="URW Gothic" font-size="10" font-weight="600" style="-inkscape-font-specification:"URW Gothic Semi-Bold""/><g stroke-width="0" aria-label="No Data" font-family="URW Gothic" font-size="10" font-weight="600" style="-inkscape-font-specification:"URW Gothic Oblique";text-align:center" text-anchor="middle"><path d="M83.98 87.39h1.37v-5.37l3.1 5.37h1.52V80H88.6v5.38L85.54 80h-1.56zm9.95-5.67a2.93 2.93 0 0 0-2.93 2.9c0 1.6 1.32 2.9 2.94 2.9s2.95-1.3 2.95-2.86a2.9 2.9 0 0 0-2.96-2.94m.01 1.22c.9 0 1.61.75 1.61 1.68s-.72 1.68-1.6 1.68c-.9 0-1.6-.75-1.6-1.7 0-.91.71-1.66 1.6-1.66M79.24 99.89h1.72c1.12 0 1.79-.11 2.39-.41 1.14-.56 1.8-1.78 1.8-3.27 0-1.41-.57-2.56-1.6-3.18a5 5 0 0 0-2.62-.53h-1.7zm1.37-1.31v-4.77h.27c.88 0 1.34.08 1.77.3.7.36 1.13 1.15 1.13 2.1 0 .89-.41 1.68-1.07 2.04-.41.22-.98.33-1.8.33zm11.1-4.23h-1.22v.74a2 2 0 0 0-1.81-.87c-1.64 0-2.82 1.23-2.82 2.92 0 1.67 1.17 2.88 2.79 2.88a2.2 2.2 0 0 0 1.84-.85v.72h1.22zm-2.9 1.09c.96 0 1.64.71 1.64 1.72 0 .4-.16.86-.4 1.14-.27.33-.7.5-1.21.5-.97 0-1.64-.66-1.64-1.63 0-1.01.67-1.73 1.62-1.73m4.26 4.45h1.33v-4.33h.8v-1.21h-.8V92.5h-1.33v1.85h-.65v1.21h.65zm8.33-5.54h-1.22v.74a2 2 0 0 0-1.81-.87c-1.64 0-2.82 1.23-2.82 2.92 0 1.67 1.17 2.88 2.79 2.88a2.2 2.2 0 0 0 1.84-.85v.72h1.22zm-2.9 1.09c.96 0 1.64.71 1.64 1.72 0 .4-.16.86-.4 1.14q-.42.5-1.21.5c-.97 0-1.64-.66-1.64-1.63 0-1.01.67-1.73 1.62-1.73" style="-inkscape-font-specification:"URW Gothic Semi-Bold""/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
1
src/lib/assets/img/stop-error.svg
Normal file
1
src/lib/assets/img/stop-error.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 2496 3495"><path stroke="#000" stroke-miterlimit="10" d="M-29-28h2552v4439H-29z"/><path fill="#fff" stroke="#000" stroke-miterlimit="10" stroke-width=".9" d="M0 0h2495v3494H0z"/><path d="m814 2164-137 2q-2-28-11-46-28-54-120-54-52 0-83 21a65 65 0 0 0-32 56q0 27 18 40 21 17 97 26 158 19 213 65 61 51 61 137 0 97-71 154-71 56-193 56-124 0-199-62-77-62-80-167h140q3 52 41 82 37 29 102 29 56 0 90-23 33-22 33-61 0-17-9-31-20-31-93-40-122-16-167-33a175 175 0 0 1-117-164q0-92 68-146 69-54 181-54 122 0 192 57 72 57 76 156m104-216h126v154h108v101h-108v251q0 34 11 42 11 9 88 9h8v107l-36 1h-89q-61-4-88-37-12-17-16-41t-4-115v-217h-72v-101h72zm508 140q111 0 180 72t69 190q0 122-66 194-67 72-180 72-112 0-180-71-67-70-67-190 0-121 67-194 68-73 177-73m2 106q-57 0-91 44t-34 119q0 70 34 112 35 42 91 42 57 0 90-42 32-42 32-115t-33-117q-33-43-89-43m311-92h122v33q49-47 129-47 102 0 166 74 65 73 65 190 0 119-63 191-63 73-167 73-78 0-124-46v184h-128zm233 92q-66 0-91 51-16 31-16 121 0 61 16 94 25 50 95 50 56 0 88-41 32-42 32-115 0-74-34-117-33-43-90-43"/><circle cx="1247.7" cy="1049.7" r="708.7" fill="#e31837"/><path fill="#1a1a1a" d="M-17 2908h2530v42H-17z"/><path fill="#1a1a1a" d="M978 3051v46H857v77h94v44h-94v79h121v46H799v-292zm39 292v-219h33q9 0 12 3 4 4 5 11l3 22q10-18 23-29t30-11q15 0 24 7l-4 42-3 6-6 1-9-1-12-1-14 2-10 7q-5 4-9 10l-7 14v136zm161 0v-219h33q8 0 12 3 3 4 4 11l3 22q10-18 23-29t31-11q14 0 23 7l-4 42q0 4-3 6l-5 1-10-1-11-1q-8 0-14 2l-11 7q-5 4-8 10l-7 14v136zm252-222q24 0 44 8 20 7 34 22t21 36 8 46-8 47-21 36-34 22-44 8-44-8q-20-7-34-22-15-15-22-36-8-21-8-47t8-46 22-36 34-22 44-8m0 183q25 0 37-18t12-52-12-53-37-17-38 17-12 53 12 52 38 18m145 39v-219h33q8 0 12 3 3 4 4 11l3 22q10-18 23-29 14-11 31-11 14 0 24 7l-5 42q0 4-2 6l-6 1-10-1-11-1q-8 0-14 2l-11 7-8 10-7 14v136z" aria-label="Error" font-family="Carlito" font-size="451" font-weight="700" style="line-height:.9;-inkscape-font-specification:"Carlito Bold";text-align:center;white-space:pre" text-anchor="middle"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
95
src/lib/components/ui/Button.svelte
Normal file
95
src/lib/components/ui/Button.svelte
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLButtonAttributes, HTMLAnchorAttributes } from 'svelte/elements';
|
||||||
|
type ButtonColor = 'brand' | 'accent' | 'bg';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: import('svelte').Snippet;
|
||||||
|
href?: string;
|
||||||
|
color?: ButtonColor;
|
||||||
|
onclick?: (e: MouseEvent) => void;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, href, color = 'bg', onclick, ...rest }: Props = $props();
|
||||||
|
|
||||||
|
const isLink = $derived(!!href);
|
||||||
|
const isExternal = $derived(href?.startsWith('http'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isLink}
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
class="hitbox-wrapper"
|
||||||
|
target={isExternal ? '_blank' : undefined}
|
||||||
|
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||||
|
{...rest}
|
||||||
|
><span class="btn {color}">
|
||||||
|
{@render children?.()}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button class="hitbox-wrapper" {onclick} {...rest}>
|
||||||
|
<span class="btn {color}">{@render children?.()}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hitbox-wrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 48px;
|
||||||
|
min-width: 98px;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: fit-content;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 1.2rem;
|
||||||
|
min-width: 90px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: none;
|
||||||
|
box-shadow: var(--shadow-small);
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
font-size: 0.93rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.05ch;
|
||||||
|
transition:
|
||||||
|
all 0.1s ease,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: var(--color-title);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
color: rgb(30, 30, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg {
|
||||||
|
background-color: var(--color-bg-light);
|
||||||
|
color: var(--color-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hitbox-wrapper:hover .btn {
|
||||||
|
filter: brightness(1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hitbox-wrapper:active .btn {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
59
src/lib/components/ui/Loading.svelte
Normal file
59
src/lib/components/ui/Loading.svelte
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
message = "Loading..."
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="track">
|
||||||
|
<div class="shuttle"></div>
|
||||||
|
</div>
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
width: 75%;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
width: 160px;
|
||||||
|
height: 3px;
|
||||||
|
background-color: var(--color-title);
|
||||||
|
border-radius: 4px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shuttle {
|
||||||
|
position: absolute;
|
||||||
|
width: 50%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(90deg, #1abc9c 0%, #3498db 100%);
|
||||||
|
animation: data-travel 1.6s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--color-title);
|
||||||
|
animation: pulse 2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes data-travel {
|
||||||
|
0% { left: -50%; }
|
||||||
|
100% { left: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {opacity: 1;}
|
||||||
|
50% {opacity: 0.4;}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
220
src/lib/components/ui/LocationSearchBox.svelte
Normal file
220
src/lib/components/ui/LocationSearchBox.svelte
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Textbox from '$lib/components/ui/Textbox.svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
import { LOCATIONS } from '$lib/locations-object.svelte';
|
||||||
|
import type { ApiLocationFilter } from '@owlboard/api-schema-types';
|
||||||
|
|
||||||
|
let { value = $bindable() } = $props();
|
||||||
|
|
||||||
|
let showResults = $state(false);
|
||||||
|
let selectedIndex = $state(-1);
|
||||||
|
|
||||||
|
const MAX_RESULTS = 5;
|
||||||
|
|
||||||
|
function tokenize(query: string) {
|
||||||
|
return query.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = $derived.by(() => {
|
||||||
|
if (value.length < 3) return [];
|
||||||
|
|
||||||
|
const tokens = tokenize(value);
|
||||||
|
const lowerQuery = value.toLowerCase().trim();
|
||||||
|
|
||||||
|
return LOCATIONS.data
|
||||||
|
.filter((r) => tokens.every((t) => r.s.includes(t)))
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Priority One - Exact CRS Match
|
||||||
|
const aExactCrs = a.c?.toLowerCase() === lowerQuery;
|
||||||
|
const bExactCrs = b.c?.toLowerCase() === lowerQuery;
|
||||||
|
if (aExactCrs && !bExactCrs) return -1;
|
||||||
|
if (!aExactCrs && bExactCrs) return 1;
|
||||||
|
|
||||||
|
// Priority Two - Exact Name Match
|
||||||
|
const aNameLow = a.n.toLowerCase();
|
||||||
|
const bNameLow = b.n.toLowerCase();
|
||||||
|
const aExactName = aNameLow === lowerQuery;
|
||||||
|
const bExactName = bNameLow === lowerQuery;
|
||||||
|
if (aExactName !== bExactName) return aExactName ? -1 : 1;
|
||||||
|
|
||||||
|
// Priority Three - Name starts with Query
|
||||||
|
const aStarts = aNameLow.startsWith(lowerQuery);
|
||||||
|
const bStarts = bNameLow.startsWith(lowerQuery);
|
||||||
|
if (aStarts !== bStarts) return aStarts ? -1 : 1;
|
||||||
|
|
||||||
|
// Priority Four - 'Stations' with CRS
|
||||||
|
if (!!a.c && !b.c) return -1;
|
||||||
|
if (!a.c && !!b.c) return 1;
|
||||||
|
|
||||||
|
// Alphabetical Sort
|
||||||
|
return a.n.localeCompare(b.n);
|
||||||
|
})
|
||||||
|
.slice(0, MAX_RESULTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (results) selectedIndex = -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide results when click outside of container
|
||||||
|
$effect(() => {
|
||||||
|
if (showResults) {
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.closest('.location-search')) {
|
||||||
|
showResults = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', onClick);
|
||||||
|
return () => document.removeEventListener('click', onClick);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function choose(loc: ApiLocationFilter.LocationFilterObject) {
|
||||||
|
showResults = false;
|
||||||
|
selectedIndex = -1;
|
||||||
|
value = '';
|
||||||
|
console.log('Selected Location: ', JSON.stringify(loc));
|
||||||
|
const queryString = loc.c || loc.t;
|
||||||
|
goto(`/board?loc=${queryString.toLowerCase()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (!results.length) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex = Math.max(selectedIndex - 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && selectedIndex >= 0) {
|
||||||
|
choose(results[selectedIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="location-search">
|
||||||
|
<Textbox
|
||||||
|
bind:value
|
||||||
|
placeholder="Enter Location"
|
||||||
|
oninput={() => (showResults = true)}
|
||||||
|
onkeydown={handleKey}
|
||||||
|
capital
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if showResults && results.length}
|
||||||
|
<ul
|
||||||
|
id="location-results"
|
||||||
|
popover={showResults && results.length ? 'manual' : null}
|
||||||
|
role="listbox"
|
||||||
|
class="suggestions"
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
|
>
|
||||||
|
{#each results as loc, i}
|
||||||
|
<li class="result-item" class:selected={i === selectedIndex} onclick={() => choose(loc)}>
|
||||||
|
<div class="crs-badge-container">
|
||||||
|
{#if loc.c}
|
||||||
|
<span class="crs-badge">{loc.c}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="details">
|
||||||
|
<span class="name">{loc.n || loc.t}</span>
|
||||||
|
<span class="tiploc">{loc.t}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.location-search {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions[popover] {
|
||||||
|
position: absolute;
|
||||||
|
inset: unset;
|
||||||
|
margin: 0;
|
||||||
|
margin-top: 3px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 350px;
|
||||||
|
top: 100%;
|
||||||
|
background-color: var(--color-title);
|
||||||
|
color: var(--color-bg-dark);
|
||||||
|
box-shadow: var(--shadow-std);
|
||||||
|
display: block;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions:not([popover]) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 48px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item.selected,
|
||||||
|
.result-item:hover {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: var(--color-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crs-badge {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-title);
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crs-badge.empty {
|
||||||
|
filter: opacity(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:hover .crs-badge {
|
||||||
|
filter: brightness(1.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiploc {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
55
src/lib/components/ui/NoResults.svelte
Normal file
55
src/lib/components/ui/NoResults.svelte
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import noResult from '$lib/assets/img/no-data.svg';
|
||||||
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
let { title = "No results", message = "Try checking your search term"} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="no-results-state">
|
||||||
|
<img src={noResult} class="image" height="200" width="200" alt="" role="presentation">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p>{message}</p>
|
||||||
|
<div class="btn-container">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onclick={() => history.length > 1 ? history.back() : window.location.href = '/'}
|
||||||
|
color="accent"
|
||||||
|
>
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.no-results-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
padding: 5rem;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: 'URW-Gothic', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
margin-bottom: 1.95rem;
|
||||||
|
max-width: 90%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
95
src/lib/components/ui/Textbox.svelte
Normal file
95
src/lib/components/ui/Textbox.svelte
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
interface Props extends HTMLInputAttributes {
|
||||||
|
value?: string;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url';
|
||||||
|
error?: string;
|
||||||
|
uppercase?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background-color: var(--color-title);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: var(--color-bg-dark);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
outline: none;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow-std);
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-caps {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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
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>
|
||||||
78
src/lib/components/ui/TocStyle.svelte
Normal file
78
src/lib/components/ui/TocStyle.svelte
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<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>
|
||||||
140
src/lib/components/ui/TrainService.svelte
Normal file
140
src/lib/components/ui/TrainService.svelte
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts';
|
||||||
|
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { quintOut} from 'svelte/easing';
|
||||||
|
import { formatUkTime } from '$lib/utils/time';
|
||||||
|
import TocStyle from '$lib/components/ui/TocStyle.svelte';
|
||||||
|
|
||||||
|
let { service }: { service: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse} = $props();
|
||||||
|
let isExpanded = $state(false);
|
||||||
|
let loadingDetails = $state(false)
|
||||||
|
let details = $state(null);
|
||||||
|
|
||||||
|
const toggleExpand = async (rid: string) => {
|
||||||
|
if (isExpanded) {
|
||||||
|
isExpanded = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details) {
|
||||||
|
isExpanded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingDetails = true;
|
||||||
|
try {
|
||||||
|
const result = await OwlClient.trains.getByRid(service.r)
|
||||||
|
details = result.data;
|
||||||
|
isExpanded = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.Error("Failde to load train details")
|
||||||
|
} finally {
|
||||||
|
loading = 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;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="train-service">
|
||||||
|
<button class="summary" onclick={toggleExpand} type="button" aria-expanded={isExpanded}>
|
||||||
|
<div class="operator-summary">
|
||||||
|
<TocStyle toc={service.o} />
|
||||||
|
</div>
|
||||||
|
<div class="main-text-summary">
|
||||||
|
<div class="time-summary">
|
||||||
|
{OriginDepartureSummary}
|
||||||
|
</div>
|
||||||
|
<div class="location-summary">
|
||||||
|
{service.ot}
|
||||||
|
</div>
|
||||||
|
<div class="location-summary to-summary">
|
||||||
|
to
|
||||||
|
</div>
|
||||||
|
<div class="location-summary">
|
||||||
|
{service.dt}
|
||||||
|
</div>
|
||||||
|
<!-- Add arrow icon to signify drop-down -->
|
||||||
|
<!-- ADD LOADING STATE -->
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{#if isExpanded && details}
|
||||||
|
<div transition:slide={{duration: 800, easing: quintOut}} class="detauls-content">
|
||||||
|
<!-- Here goes the data formatting! -->
|
||||||
|
{JSON.stringify(details)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.train-service {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: var(--shadow-std);
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
transition: 0.2s all;
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.train-service:hover {
|
||||||
|
filter:brightness(1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
min-height: 48px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-summary {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-text-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-summary {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-summary {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--color-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.to-summary {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: oblique;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
102
src/lib/components/ui/cards/BaseCard.svelte
Normal file
102
src/lib/components/ui/cards/BaseCard.svelte
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { IconHelpCircle } from '@tabler/icons-svelte';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
header?: string;
|
||||||
|
helpText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, header = '', helpText }: Props = $props();
|
||||||
|
|
||||||
|
let showHelp = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
{#if header || helpText}
|
||||||
|
<header class="card-header">
|
||||||
|
<div class="header-content">
|
||||||
|
{header}
|
||||||
|
</div>
|
||||||
|
{#if helpText}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="help-toggle"
|
||||||
|
onclick={() => (showHelp = !showHelp)}
|
||||||
|
aria-label="Show Help"
|
||||||
|
>
|
||||||
|
<IconHelpCircle
|
||||||
|
size={26}
|
||||||
|
stroke={2.25}
|
||||||
|
color={showHelp ? 'var(--color-brand)' : 'var(--color-title)'}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if showHelp && helpText}
|
||||||
|
<div class="help-drawer" transition:slide={{ duration: 400 }}>
|
||||||
|
<p>{helpText}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
background: var(--color-accent);
|
||||||
|
position: relative;
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: visible;
|
||||||
|
width: 95%;
|
||||||
|
max-width: 600px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
color: var(--color-brand);
|
||||||
|
padding: 10px 0;
|
||||||
|
box-shadow: var(--shadow-std);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: help;
|
||||||
|
opacity: 0.6;
|
||||||
|
z-index: 2;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s,
|
||||||
|
transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-toggle:hover {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-drawer {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
padding: 4px 16px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: auto;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--color-title);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
45
src/lib/components/ui/cards/HeadcodeSearchCard.svelte
Normal file
45
src/lib/components/ui/cards/HeadcodeSearchCard.svelte
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
|
||||||
|
import Textbox from '$lib/components/ui/Textbox.svelte';
|
||||||
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
|
||||||
|
let headcode = $state('');
|
||||||
|
|
||||||
|
function handleSearch(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!headcode.trim()) return;
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
searchParams.append('h', headcode.trim().toUpperCase());
|
||||||
|
|
||||||
|
goto(`/trains?${searchParams.toString()}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BaseCard header={'Search Train & PIS'}>
|
||||||
|
|
||||||
|
<form onsubmit={handleSearch} class="card-content">
|
||||||
|
<Textbox
|
||||||
|
placeholder="Enter Headcode"
|
||||||
|
bind:value={headcode}
|
||||||
|
maxLength={4}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!headcode}
|
||||||
|
>Search</Button>
|
||||||
|
</form>
|
||||||
|
</BaseCard>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card-content {
|
||||||
|
text-align: center;
|
||||||
|
width: 90%;
|
||||||
|
margin: auto;
|
||||||
|
padding: 10px 0 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
25
src/lib/components/ui/cards/LocationBoardCard.svelte
Normal file
25
src/lib/components/ui/cards/LocationBoardCard.svelte
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
|
||||||
|
import LocationSearchBox from '$lib/components/ui/LocationSearchBox.svelte';
|
||||||
|
|
||||||
|
let locationValue = $state('');
|
||||||
|
|
||||||
|
function resetSearchBox() {
|
||||||
|
value = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BaseCard header={'Live Arrivals & Departures'}>
|
||||||
|
<div class="card-content">
|
||||||
|
<LocationSearchBox bind:value={locationValue} />
|
||||||
|
</div>
|
||||||
|
</BaseCard>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card-content {
|
||||||
|
text-align: center;
|
||||||
|
width: 90%;
|
||||||
|
margin: auto;
|
||||||
|
padding: 10px 0 10px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
73
src/lib/components/ui/cards/NearbyStationsCard.svelte
Normal file
73
src/lib/components/ui/cards/NearbyStationsCard.svelte
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
|
||||||
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
|
||||||
|
import { nearestStationsState } from '$lib/geohash.svelte';
|
||||||
|
|
||||||
|
const flipDuration = 300;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BaseCard header={'Nearby Stations'}>
|
||||||
|
<div class="card-content">
|
||||||
|
{#if nearestStationsState.error && nearestStationsState.list.length === 0}
|
||||||
|
<p class="msg">{nearestStationsState.error}</p>
|
||||||
|
{:else if nearestStationsState.loading && nearestStationsState.list.length === 0}
|
||||||
|
<p class="msg">Locating stations...</p>
|
||||||
|
{:else}
|
||||||
|
<div class="stations-flex">
|
||||||
|
{#each nearestStationsState.list as station (station.c)}
|
||||||
|
<div
|
||||||
|
class="btn-container"
|
||||||
|
animate:flip={{ duration: flipDuration }}
|
||||||
|
in:fade|global={{ duration: 200 }}
|
||||||
|
>
|
||||||
|
<Button href={`/board?loc=${station.c}`}
|
||||||
|
><span class="stn-name">{station.n}</span></Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</BaseCard>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
width: 90%;
|
||||||
|
min-height: 98px;
|
||||||
|
margin: auto;
|
||||||
|
padding: 10px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stations-flex {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.1rem 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-container {
|
||||||
|
display: block;
|
||||||
|
width: fit-content;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stn-name {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
71
src/lib/components/ui/cards/QuickLinksCard.svelte
Normal file
71
src/lib/components/ui/cards/QuickLinksCard.svelte
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
|
||||||
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
|
||||||
|
import { quickLinks } from '$lib/quick-links.svelte';
|
||||||
|
|
||||||
|
const flipDuration = 300;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BaseCard header={'Quick Links'}>
|
||||||
|
<div class="card-content">
|
||||||
|
{#if quickLinks.list.length === 0}
|
||||||
|
<p class="msg">Your most viewed stations will appear here</p>
|
||||||
|
{:else}
|
||||||
|
<div class="stations-flex">
|
||||||
|
{#each quickLinks.list as station (station.id)}
|
||||||
|
<div
|
||||||
|
class="btn-container"
|
||||||
|
animate:flip={{ duration: flipDuration }}
|
||||||
|
in:fade|global={{ duration: 200 }}
|
||||||
|
>
|
||||||
|
<Button href={`/board?loc=${station.id}`}
|
||||||
|
><span class="stn-name">{station.id}</span></Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</BaseCard>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
width: 90%;
|
||||||
|
min-height: 98px;
|
||||||
|
margin: auto;
|
||||||
|
padding: 10px 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stations-flex {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.1rem 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-container {
|
||||||
|
display: block;
|
||||||
|
width: fit-content;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stn-name {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
60
src/lib/components/ui/cards/pis/PisCode.svelte
Normal file
60
src/lib/components/ui/cards/pis/PisCode.svelte
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
|
||||||
|
import Textbox from '$lib/components/ui/Textbox.svelte';
|
||||||
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
|
||||||
|
let { onsearch }: { onsearch: (c: string) => void } = $props();
|
||||||
|
|
||||||
|
let codeValue = $state('');
|
||||||
|
|
||||||
|
function resetValues(): void {
|
||||||
|
codeValue = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BaseCard header={'Find by Code'}>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="textbox-container">
|
||||||
|
<div class="textbox-item-wrapper">
|
||||||
|
<Textbox
|
||||||
|
placeholder={'Code'}
|
||||||
|
uppercase={true}
|
||||||
|
type={'number'}
|
||||||
|
max={9999}
|
||||||
|
bind:value={codeValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="button-wrapper">
|
||||||
|
<Button onclick={() => onsearch(codeValue.toString())}>Search</Button>
|
||||||
|
<Button onclick={resetValues}>Reset</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseCard>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card-content {
|
||||||
|
text-align: center;
|
||||||
|
width: 90%;
|
||||||
|
margin: auto;
|
||||||
|
padding: 10px 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textbox-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textbox-item-wrapper {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
59
src/lib/components/ui/cards/pis/PisStartEndCard.svelte
Normal file
59
src/lib/components/ui/cards/pis/PisStartEndCard.svelte
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
|
||||||
|
import Textbox from '$lib/components/ui/Textbox.svelte';
|
||||||
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
|
||||||
|
let { onsearch }: { onsearch: (s: string, e: string) => void } = $props();
|
||||||
|
|
||||||
|
let startValue = $state('');
|
||||||
|
let endValue = $state('');
|
||||||
|
|
||||||
|
function resetValues(): void {
|
||||||
|
startValue = '';
|
||||||
|
endValue = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BaseCard header={'Find by Start/End CRS'}>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="textbox-container">
|
||||||
|
<div class="textbox-item-wrapper">
|
||||||
|
<Textbox placeholder={'Start'} uppercase={true} maxLength={3} bind:value={startValue} />
|
||||||
|
</div>
|
||||||
|
<div class="textbox-item-wrapper">
|
||||||
|
<Textbox placeholder={'End'} uppercase={true} maxLength={3} bind:value={endValue} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="button-wrapper">
|
||||||
|
<Button onclick={() => onsearch(startValue, endValue)}>Search</Button>
|
||||||
|
<Button onclick={resetValues}>Reset</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseCard>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card-content {
|
||||||
|
text-align: center;
|
||||||
|
width: 90%;
|
||||||
|
margin: auto;
|
||||||
|
padding: 10px 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textbox-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textbox-item-wrapper {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
96
src/lib/geohash.svelte.ts
Normal file
96
src/lib/geohash.svelte.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { OwlClient, ValidationError, ApiError } from './owlClient';
|
||||||
|
import type { ApiStationsNearestStations } from '@owlboard/owlboard-ts';
|
||||||
|
|
||||||
|
class NearestStationsState {
|
||||||
|
list = $state<ApiStationsNearestStations.StationsNearestStations[]>([]);
|
||||||
|
currentHash = $state('');
|
||||||
|
loading = $state(true);
|
||||||
|
error = $state<string | null>(null);
|
||||||
|
|
||||||
|
private initGeoConfig: PositionOptions = {
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
timeout: 500,
|
||||||
|
maximumAge: Infinity,
|
||||||
|
}
|
||||||
|
|
||||||
|
private geoConfig: PositionOptions = {
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
timeout: 20000,
|
||||||
|
maximumAge: 30000,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (typeof window !== 'undefined' && 'geolocation' in navigator) {
|
||||||
|
this.jumpstart();
|
||||||
|
this.initWatcher();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private jumpstart() {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude),
|
||||||
|
(err) => this.handleError(err),
|
||||||
|
this.initGeoConfig
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initWatcher() {
|
||||||
|
navigator.geolocation.watchPosition(
|
||||||
|
(pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude),
|
||||||
|
(err) => this.handleError(err),
|
||||||
|
this.geoConfig
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleUpdate(lat: number, lon: number) {
|
||||||
|
const newHash = OwlClient.stationData.generateGeohash(lat, lon);
|
||||||
|
if (newHash !== this.currentHash) {
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await OwlClient.stationData.getNearestStations(newHash);
|
||||||
|
this.list = result.data;
|
||||||
|
this.error = null;
|
||||||
|
this.currentHash = newHash;
|
||||||
|
} catch (e) {
|
||||||
|
this.handleApiError(e);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError(err: GeolocationPositionError) {
|
||||||
|
if (err.code === 1) {
|
||||||
|
this.error = 'Location access denied by device';
|
||||||
|
} else {
|
||||||
|
this.error = 'Waiting for GPS signal...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleApiError(e: unknown) {
|
||||||
|
if (e instanceof ValidationError) {
|
||||||
|
this.error = `Request Error: ${e.reason} (Field: ${e.field})`;
|
||||||
|
} else if (e instanceof ApiError) {
|
||||||
|
switch (e.status) {
|
||||||
|
case 404:
|
||||||
|
this.error = 'No stations found nearby';
|
||||||
|
break;
|
||||||
|
case 429:
|
||||||
|
this.error = 'Too many requests, will retry';
|
||||||
|
break;
|
||||||
|
case 500:
|
||||||
|
this.error = 'Server Error, will retry';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.error = `Service error: ${e.code}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.error = 'Connection lost, waiting for signal';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('OwlBoard API Error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nearestStationsState = new NearestStationsState();
|
||||||
83
src/lib/global.css
Normal file
83
src/lib/global.css
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/* OwlBoard Global CSS */
|
||||||
|
|
||||||
|
/* * URW Gothic is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
* Copyright (c) 2014,2015 by (URW)++ Design & Development
|
||||||
|
*/
|
||||||
|
@font-face {
|
||||||
|
font-family: 'URW Gothic';
|
||||||
|
src: url('/type/urwgothic/urwgothic-book-webfont.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* * URW Gothic is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
* Copyright (c) 2014,2015 by (URW)++ Design & Development
|
||||||
|
*/
|
||||||
|
@font-face {
|
||||||
|
font-family: 'URW Gothic';
|
||||||
|
src: url('/type/urwgothic/urwgothic-bookoblique-webfont.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* * URW Gothic is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
* Copyright (c) 2014,2015 by (URW)++ Design & Development
|
||||||
|
*/
|
||||||
|
@font-face {
|
||||||
|
font-family: 'URW Gothic';
|
||||||
|
src: url('/type/urwgothic/urwgothic-demi-webfont.woff2') format('woff2');
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* * URW Gothic is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
* Copyright (c) 2014,2015 by (URW)++ Design & Development
|
||||||
|
*/
|
||||||
|
@font-face {
|
||||||
|
font-family: 'URW Gothic';
|
||||||
|
src: url('/type/urwgothic/urwgothic-demioblique-webfont.woff2') format('woff2');
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Brand Colours */
|
||||||
|
--color-brand: #4fd1d1;
|
||||||
|
--color-accent: #3c6f79;
|
||||||
|
--color-title: #ebebeb;
|
||||||
|
--color-bg-light: #404c55;
|
||||||
|
--color-bg-dark: #2b343c;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--color-shadow: hsla(210, 20%, 5%, 0.35);
|
||||||
|
--shadow-std: 0 4px 12px var(--color-shadow);
|
||||||
|
--shadow-small: 0 4px 6px var(--color-shadow);
|
||||||
|
--shadow-up: 0 -4px 12px var(--color-shadow);
|
||||||
|
--shadow-right: 4px 0 12px var(--color-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: white;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-mos-osx-font-smoothing: grayscale;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
33
src/lib/locations-object.svelte.ts
Normal file
33
src/lib/locations-object.svelte.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { OwlClient } from './owlClient';
|
||||||
|
import type { ApiLocationFilter } from '@owlboard/owlboard-ts';
|
||||||
|
|
||||||
|
class LocationStore {
|
||||||
|
data = $state<ApiLocationFilter.LocationFilterObject[]>([]);
|
||||||
|
loaded = $state(false);
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (this.loaded) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetch = await OwlClient.locationFilter.getLocationFilterData();
|
||||||
|
this.data = fetch.data;
|
||||||
|
this.loaded = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load locations', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
find(id: string | null): ApiLocationFilter.LocationFilterObject | undefined {
|
||||||
|
if (!id) return undefined;
|
||||||
|
|
||||||
|
const query = id.toUpperCase().trim();
|
||||||
|
|
||||||
|
console.log(query);
|
||||||
|
|
||||||
|
return this.data.find((loc) => {
|
||||||
|
return loc.t === query || loc.c === query;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOCATIONS = new LocationStore();
|
||||||
21
src/lib/owlClient.ts
Normal file
21
src/lib/owlClient.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { OwlBoardClient, ValidationError, ApiError } from '@owlboard/owlboard-ts';
|
||||||
|
import { browser, dev } from '$app/environment';
|
||||||
|
|
||||||
|
// Import the runes containing the API Key config Here...
|
||||||
|
|
||||||
|
const baseUrl: string = browser ? window.location.origin : '';
|
||||||
|
|
||||||
|
const getBaseUrl = () => {
|
||||||
|
if (!browser) return '';
|
||||||
|
|
||||||
|
if (dev) return 'https://test.owlboard.info';
|
||||||
|
|
||||||
|
return window.location.origin;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OwlClient = new OwlBoardClient(
|
||||||
|
getBaseUrl()
|
||||||
|
// API Key Here when ready!!!
|
||||||
|
);
|
||||||
|
|
||||||
|
export { ValidationError, ApiError };
|
||||||
60
src/lib/quick-links.svelte.ts
Normal file
60
src/lib/quick-links.svelte.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
export interface QuickLink {
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
lastAccessed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETURNED_LENGTH: number = 9;
|
||||||
|
const MAX_SCORE: number = 50;
|
||||||
|
const MAX_ENTRIES: number = RETURNED_LENGTH * 4;
|
||||||
|
|
||||||
|
class QuickLinksService {
|
||||||
|
#links = $state<QuickLink[]>([]);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const saved = localStorage.getItem('ql');
|
||||||
|
if (saved) {
|
||||||
|
this.#links = JSON.parse(saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get list(): QuickLink[] {
|
||||||
|
return this.#links.slice(0, RETURNED_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordVisit(id: string) {
|
||||||
|
if (id == '') return;
|
||||||
|
const existing = this.#links.find((l) => l.id === id);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.score += 1;
|
||||||
|
existing.lastAccessed = Date.now();
|
||||||
|
} else {
|
||||||
|
this.#links.push({
|
||||||
|
id: id,
|
||||||
|
score: 1,
|
||||||
|
lastAccessed: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score decay - if MAX_SCORE reached, divide all by two
|
||||||
|
if (this.#links.some((l) => l.score > MAX_SCORE)) {
|
||||||
|
this.#links.forEach((l) => {
|
||||||
|
l.score = Math.max(1, Math.floor(l.score / 2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort & Prune
|
||||||
|
const sorted = [...this.#links].sort(
|
||||||
|
(a, b) => b.score - a.score || b.lastAccessed - a.lastAccessed
|
||||||
|
);
|
||||||
|
|
||||||
|
this.#links = sorted.slice(0, MAX_ENTRIES);
|
||||||
|
|
||||||
|
localStorage.setItem('ql', JSON.stringify(this.#links));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const quickLinks = new QuickLinksService();
|
||||||
17
src/lib/utils/time.ts
Normal file
17
src/lib/utils/time.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Converts ISO/JSON time to UK-formatted HH:MM string.
|
||||||
|
* Ensures Europe/London timezone irrespective of browser timezone.
|
||||||
|
*/
|
||||||
|
export function formatUkTime(dateStr: string | Date | undefined): string {
|
||||||
|
if (!dateStr) return '--:--';
|
||||||
|
const date = typeof dateStr === 'string' ? new Date(dateStr): dateStr;
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) return '--:--';
|
||||||
|
|
||||||
|
return date.toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
timeZone: 'Europe/London',
|
||||||
|
});
|
||||||
|
}
|
||||||
80
src/routes/+error.svelte
Normal file
80
src/routes/+error.svelte
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
|
import stopErr from '$lib/assets/img/stop-error.svg';
|
||||||
|
import noResult from '$lib/assets/img/no-data.svg';
|
||||||
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="error-wrapper">
|
||||||
|
{#if page.status == 404}
|
||||||
|
<!-- Warning no data image -->
|
||||||
|
<img class="err-img" src={noResult} alt="" role="presentation" width="200" height="200" />
|
||||||
|
{:else}
|
||||||
|
<!-- STOP Error image -->
|
||||||
|
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
|
||||||
|
{/if}
|
||||||
|
<h2 class="label">{page.status}</h2>
|
||||||
|
|
||||||
|
<p class="error-message">
|
||||||
|
{page.error?.message ?? 'An unexpected derailment occurred.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if page.error?.owlCode}
|
||||||
|
<div class="debug-info">
|
||||||
|
<code>Ref: {page.error.owlCode}</code>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button href={'/'} color={'accent'}>Return to Home</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.error-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: calc(100vh - 140px);
|
||||||
|
padding: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-img {
|
||||||
|
height: 40%;
|
||||||
|
width: auto;
|
||||||
|
max-width: 180px;
|
||||||
|
max-height: 252px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 600px), (max-width: 270px) {
|
||||||
|
.err-img {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: #ff4444;
|
||||||
|
letter-spacing: 0.2rem;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--color-title);
|
||||||
|
max-width: 300px;
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-info {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 5px 15px;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,14 +1,66 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { slide, fade } from 'svelte/transition';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { navigating } from '$app/state';
|
||||||
|
import { LOCATIONS } from '$lib/locations-object.svelte';
|
||||||
|
import { nearestStationsState } from '$lib/geohash.svelte';
|
||||||
|
|
||||||
|
import Loading from '$lib/components/ui/Loading.svelte';
|
||||||
|
import TimezoneWarning from '$lib/components/ui/TimezoneWarning.svelte';
|
||||||
|
|
||||||
|
import '$lib/global.css';
|
||||||
|
|
||||||
import favicon from '$lib/assets/round-logo.svg';
|
|
||||||
import logoText from '$lib/assets/round-logo-text.svg';
|
import logoText from '$lib/assets/round-logo-text.svg';
|
||||||
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 { IconHome, IconDialpad, IconSettings, IconHelp, IconDots } from '@tabler/icons-svelte';
|
||||||
|
|
||||||
|
onMount(() => LOCATIONS.init());
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
const isHome = $derived(page.url.pathname === '/');
|
// Navigation State
|
||||||
const pageTitle = $derived(page.data.title ?? 'No Title');
|
|
||||||
|
const moreIcon = IconDots;
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: 'Home', path: '/', icon: IconHome },
|
||||||
|
{ label: 'PIS', path: '/pis/', icon: IconDialpad },
|
||||||
|
{ label: 'Options', path: '/preferences/', icon: IconSettings },
|
||||||
|
{ label: 'About', path: '/about/', icon: IconHelp }
|
||||||
|
];
|
||||||
|
|
||||||
|
let navWidth = $state(0);
|
||||||
|
let menuOpen = $state(false);
|
||||||
|
const ITEM_WIDTH = 70;
|
||||||
|
const MORE_BUTTON_WIDTH = 70;
|
||||||
|
|
||||||
|
const activePath = $derived(page.url?.pathname ?? '');
|
||||||
|
|
||||||
|
const visibleCount = $derived.by(() => {
|
||||||
|
if (navWidth === 0) return navItems.length;
|
||||||
|
const available = navWidth;
|
||||||
|
const totalItems = navItems.length;
|
||||||
|
const countWithoutMore = Math.floor(available / ITEM_WIDTH);
|
||||||
|
|
||||||
|
if (countWithoutMore >= totalItems) return totalItems;
|
||||||
|
|
||||||
|
const availableWithMore = navWidth - MORE_BUTTON_WIDTH;
|
||||||
|
const countWithMore = Math.floor(availableWithMore / ITEM_WIDTH);
|
||||||
|
|
||||||
|
if (totalItems - countWithMore === 1) {
|
||||||
|
if (available >= (countWithMore + 1) * ITEM_WIDTH) {
|
||||||
|
return totalItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return countWithMore;
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleItems = $derived(navItems.slice(0, visibleCount));
|
||||||
|
const hiddenItems = $derived(navItems.slice(visibleCount));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -17,72 +69,94 @@
|
|||||||
|
|
||||||
<header>
|
<header>
|
||||||
<a href="/" aria-label="OwlBoard Home" class="logo-link">
|
<a href="/" aria-label="OwlBoard Home" class="logo-link">
|
||||||
{#if isHome}
|
{#if page.data.title}
|
||||||
<img src={logoText} alt="OwlBoard Logo" height="55" />
|
|
||||||
{:else}
|
|
||||||
<img src={logoPlain} alt="OwlBoard Icon" height="55" />
|
<img src={logoPlain} alt="OwlBoard Icon" height="55" />
|
||||||
|
{:else}
|
||||||
|
<img src={logoText} alt="OwlBoard Logo" height="55" />
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{#if !isHome}
|
{#if page.data.title}
|
||||||
<h1 class="page-title">{pageTitle}</h1>
|
<h1 class="page-title">{page.data.title}</h1>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
<TimezoneWarning />
|
||||||
|
|
||||||
|
{#if navigating && navigating.to}
|
||||||
|
<Loading />
|
||||||
|
{:else}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<nav>
|
<nav bind:clientWidth={navWidth}>
|
||||||
<!-- Dynamic Nav Elements Here! -->
|
<!-- Dynamic Nav Elements Here! -->
|
||||||
|
{#each visibleItems as item}
|
||||||
|
{@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')}
|
||||||
|
<a
|
||||||
|
href={item.path}
|
||||||
|
class="nav-item"
|
||||||
|
class:active={isActive}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
onclick={() => (menuOpen = false)}
|
||||||
|
>
|
||||||
|
<item.icon size={24} stroke={isActive ? 2 : 1.5} class="nav-icon" />
|
||||||
|
|
||||||
|
<span class="label">{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
{#if hiddenItems.length > 0}
|
||||||
|
<div class="more-menu-wrapper">
|
||||||
|
<button
|
||||||
|
class="nav-item more-btn"
|
||||||
|
onclick={() => (menuOpen = !menuOpen)}
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
>
|
||||||
|
<IconDots size={24} />
|
||||||
|
<span class="label">More</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if menuOpen}
|
||||||
|
<div
|
||||||
|
class="backdrop"
|
||||||
|
onclick={() => (menuOpen = false)}
|
||||||
|
transition:fade={{ duration: 150 }}
|
||||||
|
></div>
|
||||||
|
<div class="menu-popover" transition:slide={{ axis: 'y', duration: 250 }}>
|
||||||
|
{#each hiddenItems as item}
|
||||||
|
{@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')}
|
||||||
|
<a
|
||||||
|
href={item.path}
|
||||||
|
class="menu-popover-item"
|
||||||
|
onclick={() => (menuOpen = false)}
|
||||||
|
class:active={isActive}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<item.icon size={20} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="viewport-guard">
|
||||||
|
<img src={logoPlain} alt="OwlBoard Logo" width="100" height="100" />
|
||||||
|
<h1 class="viewport-guard-title">Narrow Gauge Detected</h1>
|
||||||
|
<p>
|
||||||
|
Just as trains need the right track width, our data needs a bit more room to stay on the rails.
|
||||||
|
Please expand your view to at least 300px to view the app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
|
||||||
/* Brand Colours */
|
|
||||||
--color-brand: #4fd1d1;
|
|
||||||
--color-accent: #3c6f79;
|
|
||||||
--color-title: #ebebeb;
|
|
||||||
--color-bg-light: #404c55;
|
|
||||||
--color-bg-dark: #2b343c;
|
|
||||||
|
|
||||||
/* Shadows */
|
|
||||||
--color-shadow: hsla(210, 20%, 5%, 0.35);
|
|
||||||
--shadow-std: 0 4px 12px var(--color-shadow);
|
|
||||||
--shadow-up: 0 -4px 12px var(--color-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(body) {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
header, nav {
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 80px;
|
|
||||||
width: 100%;
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
box-sizing: border-box;
|
|
||||||
left: 0;
|
|
||||||
position: fixed;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 1rem;
|
|
||||||
display: flex;
|
|
||||||
z-index: 100;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
header {
|
||||||
top: 0;
|
top: 0;
|
||||||
|
height: 80px;
|
||||||
box-shadow: var(--shadow-std);
|
box-shadow: var(--shadow-std);
|
||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
|
||||||
bottom: 0;
|
|
||||||
box-shadow: var(--shadow-up);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-link {
|
.logo-link {
|
||||||
width: auto;
|
width: auto;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -96,17 +170,177 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: clamp(0.9rem, 2.5vw + 0.8rem, 2rem);
|
||||||
|
font-style: normal;
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
color: var(--color-title);
|
color: var(--color-title);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
header,
|
||||||
|
nav {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
box-sizing: border-box;
|
||||||
|
left: 0;
|
||||||
|
position: fixed;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 1rem;
|
||||||
|
display: flex;
|
||||||
|
z-index: 100;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
padding-top: 80px;
|
padding-top: 80px;
|
||||||
padding-bottom: 80px;
|
padding-bottom: 60px;
|
||||||
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));
|
background-image: radial-gradient(var(--color-bg-dark), var(--color-bg-light));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
bottom: 0;
|
||||||
|
height: 60px;
|
||||||
|
box-shadow: var(--shadow-up);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item,
|
||||||
|
.more-menu-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 70px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
color: var(--color-title);
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover,
|
||||||
|
.menu-popover-item:hover {
|
||||||
|
filter: brightness(1.9);
|
||||||
|
background-color: #ffffff1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active,
|
||||||
|
.menu-popover-item.active {
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 40%;
|
||||||
|
height: 3px;
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
|
box-shadow: var(--shadow-std);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active .label,
|
||||||
|
.menu-popover-item.active .label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-top: 4px;
|
||||||
|
letter-spacing: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 60px;
|
||||||
|
width: 100vw;
|
||||||
|
height: calc(100dvh - 60px);
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popover {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
right: 10px;
|
||||||
|
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
min-width: 140px;
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popover-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
position: relative;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popover-item.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 60%;
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
box-shadow: var(--shadow-right);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport-guard {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 299px) {
|
||||||
|
.viewport-guard {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--color-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport-guard p {
|
||||||
|
width: 90%;
|
||||||
|
margin: auto;
|
||||||
|
padding-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header,
|
||||||
|
main,
|
||||||
|
nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
4
src/routes/+layout.ts
Normal file
4
src/routes/+layout.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const prerender = true;
|
||||||
|
export const trailingSlash = 'always';
|
||||||
|
export const csr = true;
|
||||||
|
export const ssr = false;
|
||||||
@@ -1 +1,24 @@
|
|||||||
<h2>OwlBoard</h2>
|
<script lang="ts">
|
||||||
|
import LocationBoardCard from '$lib/components/ui/cards/LocationBoardCard.svelte';
|
||||||
|
import HeadcodeSearchCard from '$lib/components/ui/cards/HeadcodeSearchCard.svelte';
|
||||||
|
import NearbyStationsCard from '$lib/components/ui/cards/NearbyStationsCard.svelte';
|
||||||
|
import QuickLinksCard from '$lib/components/ui/cards/QuickLinksCard.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card-container">
|
||||||
|
<LocationBoardCard />
|
||||||
|
<HeadcodeSearchCard />
|
||||||
|
<NearbyStationsCard />
|
||||||
|
<QuickLinksCard />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
104
src/routes/about/+page.svelte
Normal file
104
src/routes/about/+page.svelte
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import logo from '$lib/assets/round-logo.svg';
|
||||||
|
|
||||||
|
let isSpinning = $state(false);
|
||||||
|
|
||||||
|
function handleLogoTap() {
|
||||||
|
if (isSpinning) return;
|
||||||
|
isSpinning = true;
|
||||||
|
setTimeout(() => (isSpinning = false), 800);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="logo-container">
|
||||||
|
<img
|
||||||
|
class="logo"
|
||||||
|
src={logo}
|
||||||
|
alt="OwlBoard Logo"
|
||||||
|
onclick={handleLogoTap}
|
||||||
|
class:animate={isSpinning}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="about">
|
||||||
|
<p class="copy">© 2022-2026 Frederick Boniface</p>
|
||||||
|
<p class="tagline">Created by train crew, for train crew</p>
|
||||||
|
<p class="amble">
|
||||||
|
OwlBoard was created in 2022, evolving from 'Athena' which just provided 'Quick Links' to Tiger
|
||||||
|
departure boards. The aim was to provide fast and easy access to the information we need on a
|
||||||
|
daily basis.
|
||||||
|
</p>
|
||||||
|
<p class="amble">
|
||||||
|
Why OwlBoard? The name was chosen as an evolution of its predecessor, 'Athena'; owls are
|
||||||
|
associated with the equivalent Roman Goddess - Minerva - as well as with wisdom. This also links
|
||||||
|
to Bath, where the app has been built and is run, relating to the 'Minerva Owl' sculpture trail
|
||||||
|
in the city, with many of the sculptures still in the area.
|
||||||
|
</p>
|
||||||
|
<p class="opensource">
|
||||||
|
Some components that combine to form OwlBoard are open-source, see the <a
|
||||||
|
href="https://git.fjla.uk"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener">Git reposititories</a
|
||||||
|
> for more info.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="data-sourcing">
|
||||||
|
<p>
|
||||||
|
Data is sourced from multiple providers, including <a
|
||||||
|
href="https://nationalrail.co.uk"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">National Rail Enquiries</a
|
||||||
|
> and Network Rail along side OwlBoard's own data
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.logo-container {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes owl-spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
15% {
|
||||||
|
transform: rotate(-20deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
padding-top: 25px;
|
||||||
|
margin: auto;
|
||||||
|
height: auto;
|
||||||
|
width: clamp(80px, 20vw, 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo.animate {
|
||||||
|
animation: owl-spin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin: auto;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 650px;
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
section:last-child {
|
||||||
|
padding-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
src/routes/about/+page.ts
Normal file
5
src/routes/about/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const load = () => {
|
||||||
|
return {
|
||||||
|
title: 'About'
|
||||||
|
};
|
||||||
|
};
|
||||||
34
src/routes/board/+page.svelte
Normal file
34
src/routes/board/+page.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { quickLinks } from '$lib/quick-links.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
// Update 'QuickLinks'
|
||||||
|
$effect(() => {
|
||||||
|
if (data.BoardLocation) {
|
||||||
|
const id = data.BoardLocation?.c ?? data.BoardLocation?.t;
|
||||||
|
if (id) {
|
||||||
|
// Untrack, as we do not need to handle changes to quickLinks - this is WRITE_ONLY
|
||||||
|
untrack(() => {
|
||||||
|
quickLinks.recordVisit(id);
|
||||||
|
console.log(`QuickLink visit recorded: ${JSON.stringify(data.BoardLocation)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section>Live board are not yet implemented on the server</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
section {
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
width: 90%;
|
||||||
|
margin: auto;
|
||||||
|
padding-top: 25px;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
39
src/routes/board/+page.ts
Normal file
39
src/routes/board/+page.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { LOCATIONS } from '$lib/locations-object.svelte';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ url }) => {
|
||||||
|
const locId = url.searchParams.get('loc');
|
||||||
|
|
||||||
|
if (!LOCATIONS.loaded) {
|
||||||
|
await LOCATIONS.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
let title: string = '';
|
||||||
|
|
||||||
|
if (!locId) {
|
||||||
|
error(400, {
|
||||||
|
message: 'Location not provided',
|
||||||
|
owlCode: 'NO_LOCATION_IN_PATH'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let BoardLocation;
|
||||||
|
|
||||||
|
if (locId) {
|
||||||
|
BoardLocation = LOCATIONS.find(locId);
|
||||||
|
|
||||||
|
if (BoardLocation) {
|
||||||
|
title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep';
|
||||||
|
} else {
|
||||||
|
error(404, {
|
||||||
|
message: `Location (${locId.toUpperCase()}) not found`,
|
||||||
|
owlCode: 'INVALID_LOCATION_CODE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
BoardLocation
|
||||||
|
};
|
||||||
|
};
|
||||||
164
src/routes/pis/+page.svelte
Normal file
164
src/routes/pis/+page.svelte
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import HeadcodeSearchCard from '$lib/components/ui/cards/HeadcodeSearchCard.svelte';
|
||||||
|
import PisStartEndCard from '$lib/components/ui/cards/pis/PisStartEndCard.svelte';
|
||||||
|
import PisCode from '$lib/components/ui/cards/pis/PisCode.svelte';
|
||||||
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
import type { ApiPisObject } from '@owlboard/owlboard-ts';
|
||||||
|
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
|
||||||
|
import TocStyle from '$lib/components/ui/TocStyle.svelte';
|
||||||
|
|
||||||
|
let results = $state<ApiPisObject.PisObjects[]>([]);
|
||||||
|
let resultsLoaded = $state<boolean>(false);
|
||||||
|
let errorState = $state<{ status: number; message: string } | null>(null);
|
||||||
|
|
||||||
|
async function handleStartEndSearch(start: string, end: string): Promise<void> {
|
||||||
|
console.log(`PIS Search: ${start}-${end}`);
|
||||||
|
errorState = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await OwlClient.pis.getByStartEndCrs(start, end);
|
||||||
|
results = response.data || [];
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ValidationError) {
|
||||||
|
errorState = { status: 400, message: e.message };
|
||||||
|
} else if (e instanceof ApiError) {
|
||||||
|
console.log(e);
|
||||||
|
errorState = { status: 20, message: e.message };
|
||||||
|
} else {
|
||||||
|
errorState = { status: 0, message: `Unknown Error: ${e.message}` };
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
resultsLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCodeSearch(code: string) {
|
||||||
|
console.log(`PIS Search: ${code}`);
|
||||||
|
errorState = null;
|
||||||
|
try {
|
||||||
|
const response = await OwlClient.pis.getByCode(code);
|
||||||
|
results = response.data || [];
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ValidationError) {
|
||||||
|
errorState = { status: 400, message: e.message };
|
||||||
|
} else if (e instanceof ApiError) {
|
||||||
|
console.log(e);
|
||||||
|
errorState = { status: 20, message: e.message };
|
||||||
|
} else {
|
||||||
|
errorState = { status: 0, message: `Unknown Error: ${e.message}` };
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
resultsLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResults() {
|
||||||
|
console.log('Clearing Results');
|
||||||
|
resultsLoaded = false;
|
||||||
|
results = [];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !resultsLoaded}
|
||||||
|
<div class="card-container">
|
||||||
|
<HeadcodeSearchCard />
|
||||||
|
<PisStartEndCard onsearch={handleStartEndSearch} />
|
||||||
|
<PisCode onsearch={handleCodeSearch} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="result-container">
|
||||||
|
{#if errorState}
|
||||||
|
<span class="errCode">Error: {errorState.status}</span>
|
||||||
|
<span class="errMsg">{errorState.message}</span>
|
||||||
|
{:else if results.length}
|
||||||
|
<h2 class="result-title">
|
||||||
|
{results.length} Result{#if results.length > 1}s{/if} found
|
||||||
|
</h2>
|
||||||
|
<table class="result-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:16%">TOC</th>
|
||||||
|
<th style="width:14%">Code</th>
|
||||||
|
<th style="width:70%">Locations</th>
|
||||||
|
</tr></thead
|
||||||
|
>
|
||||||
|
{#each results as result}
|
||||||
|
<tbody
|
||||||
|
><tr>
|
||||||
|
<td><TocStyle toc={result.toc || ''} /></td>
|
||||||
|
<td>{result.code}</td>
|
||||||
|
<td class="locations-row">{result.crsStops?.join(' ') || ''}</td>
|
||||||
|
</tr></tbody
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</table>
|
||||||
|
{:else}
|
||||||
|
<p class="no-results">No matching results</p>
|
||||||
|
{/if}
|
||||||
|
<div class="reset-button-container">
|
||||||
|
<Button onclick={clearResults}>Reset</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-container {
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-accent);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 20px 0 20px 0;
|
||||||
|
margin: auto;
|
||||||
|
margin-top: 25px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: var(--shadow-std);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-table {
|
||||||
|
width: 90%;
|
||||||
|
max-width: 350px;
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0 20px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-row {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errCode {
|
||||||
|
color: rgb(255, 54, 54);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-button-container {
|
||||||
|
padding: 20px 0 3px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
src/routes/pis/+page.ts
Normal file
5
src/routes/pis/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const load = () => {
|
||||||
|
return {
|
||||||
|
title: 'PIS Codes'
|
||||||
|
};
|
||||||
|
};
|
||||||
34
src/routes/trains/+page.svelte
Normal file
34
src/routes/trains/+page.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import NoResults from '$lib/components/ui/NoResults.svelte';
|
||||||
|
import TrainService from '$lib/components/ui/TrainService.svelte';
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{#if data.results.length === 0}
|
||||||
|
<NoResults
|
||||||
|
message={"No trains found on this date with this headcode."}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="result-boxes">
|
||||||
|
{#each data.results as service (service.r)}
|
||||||
|
<TrainService {service} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.result-boxes {
|
||||||
|
width: 95%;
|
||||||
|
margin: auto;
|
||||||
|
padding-top: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
src/routes/trains/+page.ts
Normal file
64
src/routes/trains/+page.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
|
||||||
|
import type { ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ url }) => {
|
||||||
|
const headcode = url.searchParams.get('h');
|
||||||
|
let dateParam = url.searchParams.get('d');
|
||||||
|
const toc = url.searchParams.get('t') || "";
|
||||||
|
|
||||||
|
const date: string | Date = (dateParam === "" || dateParam === null)
|
||||||
|
? new Date()
|
||||||
|
: dateParam;
|
||||||
|
|
||||||
|
if (!headcode) {
|
||||||
|
throw error(400, {
|
||||||
|
message: 'Headcode not provided',
|
||||||
|
owlCode: 'INVALID_DATA'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declared outside of the try so that it can be used in both the try and catch blocks
|
||||||
|
let results: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse[]
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await OwlClient.trains.getByHeadcode(headcode, date, toc);
|
||||||
|
|
||||||
|
results = (response.data);
|
||||||
|
return {
|
||||||
|
title: headcode.toUpperCase(),
|
||||||
|
results: results,
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof ValidationError) {
|
||||||
|
throw error(400, {
|
||||||
|
message: e.message,
|
||||||
|
owlCode: 'VALIDATION_ERROR',
|
||||||
|
});
|
||||||
|
} else if (e instanceof ApiError) {
|
||||||
|
// Check if NO_RESULTS error, and return empty array if that is the case
|
||||||
|
if (e.code === "NOT_FOUND") {
|
||||||
|
return {
|
||||||
|
title: headcode.toUpperCase(),
|
||||||
|
results: []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw error(e.status, {
|
||||||
|
message: e.message,
|
||||||
|
owlCode: 'API_ERROR',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (e instanceof Error) {
|
||||||
|
throw error(500, {
|
||||||
|
message: e.message,
|
||||||
|
owlCode: 'GEN_ERROR',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
throw error(500, {
|
||||||
|
message: "Unexpected error",
|
||||||
|
owlCode: "UNKNOWN_ERR",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
static/type/urwgothic/LICENSE
Normal file
93
static/type/urwgothic/LICENSE
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright (c) 2014,2015 by (URW)++ Design & Development
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
BIN
static/type/urwgothic/urwgothic-book-webfont.woff2
Normal file
BIN
static/type/urwgothic/urwgothic-book-webfont.woff2
Normal file
Binary file not shown.
BIN
static/type/urwgothic/urwgothic-bookoblique-webfont.woff2
Normal file
BIN
static/type/urwgothic/urwgothic-bookoblique-webfont.woff2
Normal file
Binary file not shown.
BIN
static/type/urwgothic/urwgothic-demi-webfont.woff2
Normal file
BIN
static/type/urwgothic/urwgothic-demi-webfont.woff2
Normal file
Binary file not shown.
BIN
static/type/urwgothic/urwgothic-demioblique-webfont.woff2
Normal file
BIN
static/type/urwgothic/urwgothic-demioblique-webfont.woff2
Normal file
Binary file not shown.
@@ -1,6 +1,20 @@
|
|||||||
import adapter from '@sveltejs/adapter-static';
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = { kit: { adapter: adapter() } };
|
const config = {
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
pages: 'build',
|
||||||
|
assets: 'build',
|
||||||
|
fallback: 'index.html',
|
||||||
|
precompress: 'true',
|
||||||
|
strict: 'true'
|
||||||
|
}),
|
||||||
|
prerender: {
|
||||||
|
// Temporary option during testing
|
||||||
|
handleHttpError: 'ignore'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": false,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"moduleResolution": "bundler"
|
"moduleResolution": "bundler"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user