Compare commits
33 Commits
d82592cebf
...
v3.0.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
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
|
||||
@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/"]
|
||||
76
package-lock.json
generated
76
package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.2",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@owlboard/owlboard-ts": "^3.0.0-dev.202603302258",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
@@ -778,6 +779,24 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@owlboard/api-schema-types": {
|
||||
"version": "3.0.2-alpha3",
|
||||
"resolved": "https://git.fjla.uk/api/packages/OwlBoard/npm/%40owlboard%2Fapi-schema-types/-/3.0.2-alpha3/api-schema-types-3.0.2-alpha3.tgz",
|
||||
"integrity": "sha512-3NFP21QdSfjziwlGQixlNnUWC55HlKZGyWANIOimWu0FZejWQWExJiaAVfb6m3Sbv+zvQMu3B8mzcMCcGadZCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@owlboard/owlboard-ts": {
|
||||
"version": "3.0.0-dev.202603302258",
|
||||
"resolved": "https://git.fjla.uk/api/packages/OwlBoard/npm/%40owlboard%2Fowlboard-ts/-/3.0.0-dev.202603302258/owlboard-ts-3.0.0-dev.202603302258.tgz",
|
||||
"integrity": "sha512-uJRoahtqnkmkPg8QsIWhtxhAibWY8xG25fhjzzdDFNE/2+uCj1ev5iD+t0O1dkE7ic3yTcMZqDitEspW216bjw==",
|
||||
"dev": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@owlboard/api-schema-types": "^3.0.2-alpha3",
|
||||
"latlon-geohash": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
@@ -1178,9 +1197,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/kit": {
|
||||
"version": "2.53.4",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.4.tgz",
|
||||
"integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==",
|
||||
"version": "2.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz",
|
||||
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1189,7 +1208,7 @@
|
||||
"@types/cookie": "^0.6.0",
|
||||
"acorn": "^8.14.1",
|
||||
"cookie": "^0.6.0",
|
||||
"devalue": "^5.6.3",
|
||||
"devalue": "^5.6.4",
|
||||
"esm-env": "^1.2.2",
|
||||
"kleur": "^4.1.5",
|
||||
"magic-string": "^0.30.5",
|
||||
@@ -1540,9 +1559,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1863,9 +1882,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2036,9 +2055,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/devalue": {
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
|
||||
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
|
||||
"version": "5.6.4",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
|
||||
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
@@ -2452,9 +2471,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -2646,6 +2665,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/latlon-geohash": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/latlon-geohash/-/latlon-geohash-2.0.0.tgz",
|
||||
"integrity": "sha512-OKBswTwrvTdtenV+9C9euBmvgGuqyjJNAzpQCarRz1m8/pYD2nz9fKkXmLs2S3jeXaLi3Ry76twQplKKUlgS/g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@@ -2883,9 +2909,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3010,9 +3036,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-load-config/node_modules/yaml": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
|
||||
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
@@ -3825,9 +3851,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.2",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@owlboard/owlboard-ts": "^3.0.0-dev.202603302258",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="title" content="OwlBoard | Your fasted route to live and reference data" />
|
||||
<meta name="description" content="Live station departures, Live train tracking, PIS Codes & more" />
|
||||
<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="theme-color" content="#4fd1d1" />
|
||||
<link rel="canonical" href="https://owlboard.info" />
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
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 |
94
src/lib/components/ui/Button.svelte
Normal file
94
src/lib/components/ui/Button.svelte
Normal file
@@ -0,0 +1,94 @@
|
||||
<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: 48px;
|
||||
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-std);
|
||||
font-family: 'URW Gothic', sans-serif;
|
||||
font-size: 0.93rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05ch;
|
||||
transition:
|
||||
all 0.1s ease,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.accent {
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-title);
|
||||
}
|
||||
|
||||
.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>
|
||||
208
src/lib/components/ui/LocationSearchBox.svelte
Normal file
208
src/lib/components/ui/LocationSearchBox.svelte
Normal file
@@ -0,0 +1,208 @@
|
||||
<script lang="ts">
|
||||
import Textbox from '$lib/components/ui/Textbox.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { LOCATIONS } from '$lib/locations-object.svelte';
|
||||
import type { ApiLocationFilter } from '@owlboard/api-schema-types';
|
||||
|
||||
let { value = $bindable() } = $props();
|
||||
|
||||
let showResults = $state(false);
|
||||
let selectedIndex = $state(-1);
|
||||
|
||||
const MAX_RESULTS = 5;
|
||||
|
||||
function tokenize(query: string) {
|
||||
return query.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
let results = $derived.by(() => {
|
||||
if (value.length < 3) return [];
|
||||
|
||||
const tokens = tokenize(value);
|
||||
const lowerQuery = value.toLowerCase().trim();
|
||||
|
||||
return LOCATIONS.data
|
||||
.filter((r) => tokens.every((t) => r.s.includes(t)))
|
||||
.sort((a, b) => {
|
||||
// Priority One - Exact CRS Match
|
||||
const aExactCrs = a.c?.toLowerCase() === lowerQuery;
|
||||
const bExactCrs = b.c?.toLowerCase() === lowerQuery;
|
||||
if (aExactCrs && !bExactCrs) return -1;
|
||||
if (!aExactCrs && bExactCrs) return 1;
|
||||
|
||||
// Priority Two - 'Stations' with CRS
|
||||
if (!!a.c && !b.c) return -1;
|
||||
if (!a.c & !!b.c) return 1;
|
||||
|
||||
// Alphabetical Sort
|
||||
return a.n.localeCompare(b.n);
|
||||
})
|
||||
.slice(0, MAX_RESULTS);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (results) selectedIndex = -1;
|
||||
});
|
||||
|
||||
// Hide results when click outside of container
|
||||
$effect(() => {
|
||||
if (showResults) {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.location-search')) {
|
||||
showResults = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', onClick);
|
||||
return () => document.removeEventListener('click', onClick);
|
||||
}
|
||||
});
|
||||
|
||||
function choose(loc: ApiLocationFilter.LocationFilterObject) {
|
||||
showResults = false;
|
||||
selectedIndex = -1;
|
||||
value = '';
|
||||
console.log('Selected Location: ', JSON.stringify(loc));
|
||||
const queryString = loc.c || loc.t;
|
||||
goto(`/board?loc=${queryString.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (!results.length) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.max(selectedIndex - 1, 0);
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && selectedIndex >= 0) {
|
||||
choose(results[selectedIndex]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="location-search">
|
||||
<Textbox
|
||||
bind:value
|
||||
placeholder="Enter Location"
|
||||
oninput={() => (showResults = true)}
|
||||
onkeydown={handleKey}
|
||||
capital
|
||||
/>
|
||||
|
||||
{#if showResults && results.length}
|
||||
<ul
|
||||
id="location-results"
|
||||
popover={showResults && results.length ? 'manual' : null}
|
||||
role="listbox"
|
||||
class="suggestions"
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
{#each results as loc, i}
|
||||
<li class="result-item" class:selected={i === selectedIndex} onclick={() => choose(loc)}>
|
||||
<div class="crs-badge-container">
|
||||
{#if loc.c}
|
||||
<span class="crs-badge">{loc.c}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="details">
|
||||
<span class="name">{loc.n || loc.t}</span>
|
||||
<span class="tiploc">{loc.t}</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.location-search {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.suggestions[popover] {
|
||||
position: absolute;
|
||||
inset: unset;
|
||||
margin: 0;
|
||||
margin-top: 3px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
max-height: 350px;
|
||||
top: 100%;
|
||||
background-color: var(--color-title);
|
||||
color: var(--color-bg-dark);
|
||||
box-shadow: var(--shadow-std);
|
||||
display: block;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.suggestions:not([popover]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
font-family: 'URW Gothic', sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
min-height: 48px;
|
||||
transition: all 0.15s;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.result-item.selected,
|
||||
.result-item:hover {
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-title);
|
||||
}
|
||||
|
||||
.crs-badge {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-title);
|
||||
padding: 3px 6px;
|
||||
border-radius: 10px;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.crs-badge.empty {
|
||||
filter: opacity(0);
|
||||
}
|
||||
|
||||
.result-item:hover .crs-badge {
|
||||
filter: brightness(1.3);
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.tiploc {
|
||||
text-align: right;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
56
src/lib/components/ui/TocStyle.svelte
Normal file
56
src/lib/components/ui/TocStyle.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
toc: string;
|
||||
}
|
||||
|
||||
let { toc }: Props = $props();
|
||||
|
||||
let code = $derived(toc.toUpperCase());
|
||||
</script>
|
||||
|
||||
<div class="toc-container {code}">
|
||||
{code}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toc-container {
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
font-weight: 800;
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.GW {
|
||||
/* Great Western Railway */
|
||||
background: #004225;
|
||||
color: #e2e2e2;
|
||||
}
|
||||
|
||||
.GR {
|
||||
/* LNER */
|
||||
background-color: #c00000;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.VT {
|
||||
/* Avanti West Coast */
|
||||
background-color: #004354;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.SW {
|
||||
/* South Western Railway */
|
||||
background-color: #2a3389;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.XC {
|
||||
/* CrossCountry */
|
||||
background-color: #660000;
|
||||
color: #e4d5b1;
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
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 }}
|
||||
transition:fade|global={{ duration: 300 }}
|
||||
>
|
||||
<Button href={`/board?loc=${station.c}`}
|
||||
><span class="stn-name">{station.n}</span></Button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<style>
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
width: 90%;
|
||||
min-height: 98px;
|
||||
margin: auto;
|
||||
padding: 10px 0 10px 0;
|
||||
}
|
||||
|
||||
.stations-flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.1rem 0.5rem;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.btn-container {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.msg {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-title);
|
||||
}
|
||||
|
||||
.stn-name {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
90
src/lib/geohash.svelte.ts
Normal file
90
src/lib/geohash.svelte.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { OwlClient, ValidationError, ApiError } from './owlClient';
|
||||
import type { ApiStationsNearestStations } from '@owlboard/owlboard-ts';
|
||||
|
||||
class NearestStationsState {
|
||||
list = $state<ApiStationsNearestStations.StationsNearestStations[]>([]);
|
||||
currentHash = $state('');
|
||||
loading = $state(true);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
private geoConfig: PositionOptions = {
|
||||
enableHighAccuracy: false,
|
||||
timeout: 30000,
|
||||
maximumAge: 120000
|
||||
};
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== 'undefined' && 'geolocation' in navigator) {
|
||||
this.jumpstart();
|
||||
this.initWatcher();
|
||||
}
|
||||
}
|
||||
|
||||
private jumpstart() {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude),
|
||||
(err) => this.handleError(err),
|
||||
this.geoConfig
|
||||
);
|
||||
}
|
||||
|
||||
private initWatcher() {
|
||||
navigator.geolocation.watchPosition(
|
||||
(pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude),
|
||||
(err) => this.handleError(err),
|
||||
this.geoConfig
|
||||
);
|
||||
}
|
||||
|
||||
private async handleUpdate(lat: number, lon: number) {
|
||||
const newHash = OwlClient.stationData.generateGeohash(lat, lon);
|
||||
if (newHash !== this.currentHash) {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const result = await OwlClient.stationData.getNearestStations(newHash);
|
||||
this.list = result.data;
|
||||
this.error = null;
|
||||
this.currentHash = newHash;
|
||||
} catch (e) {
|
||||
this.handleApiError(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(err: GeolocationPositionError) {
|
||||
if (err.code === 1) {
|
||||
this.error = 'Location access denied by device';
|
||||
} else {
|
||||
this.error = 'Waiting for GPS signal...';
|
||||
}
|
||||
}
|
||||
|
||||
private handleApiError(e: unknown) {
|
||||
if (e instanceof ValidationError) {
|
||||
this.error = `Request Error: ${e.reason} (Field: ${e.field})`;
|
||||
} else if (e instanceof ApiError) {
|
||||
switch (e.status) {
|
||||
case 404:
|
||||
this.error = 'No stations found nearby';
|
||||
break;
|
||||
case 429:
|
||||
this.error = 'Too many requests, will retry';
|
||||
break;
|
||||
case 500:
|
||||
this.error = 'Server Error, will retry';
|
||||
break;
|
||||
default:
|
||||
this.error = `Service error: ${e.code}`;
|
||||
}
|
||||
} else {
|
||||
this.error = 'Connection lost, waiting for signal';
|
||||
}
|
||||
|
||||
console.error('OwlBoard API Error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export const nearestStationsState = new NearestStationsState();
|
||||
82
src/lib/global.css
Normal file
82
src/lib/global.css
Normal file
@@ -0,0 +1,82 @@
|
||||
/* 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-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 };
|
||||
74
src/routes/+error.svelte
Normal file
74
src/routes/+error.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
|
||||
import stopErr from '$lib/assets/img/stop-error.svg';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
</script>
|
||||
|
||||
<div class="error-wrapper">
|
||||
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
|
||||
|
||||
<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,5 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { slide, fade } from 'svelte/transition';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { LOCATIONS } from '$lib/locations-object.svelte';
|
||||
import { nearestStationsState } from '$lib/geohash.svelte';
|
||||
|
||||
import '$lib/global.css';
|
||||
|
||||
import logoText from '$lib/assets/round-logo-text.svg';
|
||||
import logoPlain from '$lib/assets/round-logo.svg';
|
||||
@@ -7,20 +14,50 @@
|
||||
|
||||
import { IconHome, IconDialpad, IconSettings, IconHelp, IconDots } from '@tabler/icons-svelte';
|
||||
|
||||
onMount(() => LOCATIONS.init());
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Navigation State
|
||||
|
||||
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},
|
||||
]
|
||||
{ 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 isHome = $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);
|
||||
|
||||
const moreIcon = IconDots;
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
@@ -29,14 +66,14 @@
|
||||
|
||||
<header>
|
||||
<a href="/" aria-label="OwlBoard Home" class="logo-link">
|
||||
{#if isHome}
|
||||
<img src={logoText} alt="OwlBoard Logo" height="55" />
|
||||
{:else}
|
||||
{#if page.data.title}
|
||||
<img src={logoPlain} alt="OwlBoard Icon" height="55" />
|
||||
{:else}
|
||||
<img src={logoText} alt="OwlBoard Logo" height="55" />
|
||||
{/if}
|
||||
</a>
|
||||
{#if !isHome}
|
||||
<h1 class="page-title">{headerState?.title ?? ""}</h1>
|
||||
{#if page.data.title}
|
||||
<h1 class="page-title">{page.data.title}</h1>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
@@ -44,51 +81,68 @@
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<nav>
|
||||
<nav bind:clientWidth={navWidth}>
|
||||
<!-- Dynamic Nav Elements Here! -->
|
||||
{#each navItems as item}
|
||||
{@const isActive = activePath === item.path}
|
||||
{#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.5 : 2}
|
||||
class="nav-icon"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
: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 {
|
||||
top: 0;
|
||||
height: 80px;
|
||||
@@ -107,11 +161,17 @@
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: 'URW Gothic', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: clamp(0.9rem, 2.5vw + 0.8rem, 2rem);
|
||||
font-style: normal;
|
||||
margin-left: 5px;
|
||||
padding-bottom: 2px;
|
||||
color: var(--color-title);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
header, nav {
|
||||
header,
|
||||
nav {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
background-color: var(--color-accent);
|
||||
@@ -126,13 +186,6 @@
|
||||
gap: 0rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
bottom: 0;
|
||||
height: 60px;
|
||||
box-shadow: var(--shadow-up);
|
||||
}
|
||||
|
||||
|
||||
main {
|
||||
padding-top: 80px;
|
||||
padding-bottom: 60px;
|
||||
@@ -142,19 +195,41 @@
|
||||
background-image: radial-gradient(var(--color-bg-dark), var(--color-bg-light));
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
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%;
|
||||
color: rgba(255,255,255,0.4);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
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.active {
|
||||
.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);
|
||||
}
|
||||
@@ -170,10 +245,93 @@
|
||||
box-shadow: var(--shadow-std);
|
||||
}
|
||||
|
||||
.nav-item.active .label,
|
||||
.menu-popover-item.active .label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
font-family: 'URW Gothic', sans-serif;
|
||||
font-weight: 400;
|
||||
margin-top: 4px;
|
||||
letter-spacing: 0.2rem;
|
||||
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>
|
||||
|
||||
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,20 @@
|
||||
<h2>OwlBoard</h2>
|
||||
<script lang="ts">
|
||||
import LocationBoardCard from '$lib/components/ui/cards/LocationBoardCard.svelte';
|
||||
import NearbyStationsCard from '$lib/components/ui/cards/NearbyStationsCard.svelte';
|
||||
</script>
|
||||
|
||||
<div class="card-container">
|
||||
<LocationBoardCard />
|
||||
<NearbyStationsCard />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
padding: 20px 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,104 @@
|
||||
<script lang="ts">
|
||||
import headerState from '$lib/Header/Header.svelte.ts'
|
||||
headerState.title = "About"
|
||||
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'
|
||||
};
|
||||
};
|
||||
13
src/routes/board/+page.svelte
Normal file
13
src/routes/board/+page.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<section>Live board are not yet implemented on the server</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
font-family: 'URW Gothic', sans-serif;
|
||||
text-align: center;
|
||||
font-size: 2rem;
|
||||
width: 90%;
|
||||
margin: auto;
|
||||
padding-top: 25px;
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
37
src/routes/board/+page.ts
Normal file
37
src/routes/board/+page.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { LOCATIONS } from '$lib/locations-object.svelte';
|
||||
import type { PageLoad } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
const locId = url.searchParams.get('loc');
|
||||
|
||||
if (!LOCATIONS.loaded) {
|
||||
await LOCATIONS.init();
|
||||
}
|
||||
|
||||
let title: string = '';
|
||||
|
||||
if (!locId) {
|
||||
error(400, {
|
||||
message: 'Location not provided',
|
||||
owlCode: 'NO_LOCATION_IN_PATH'
|
||||
});
|
||||
}
|
||||
|
||||
if (locId) {
|
||||
const location = LOCATIONS.find(locId);
|
||||
|
||||
if (location) {
|
||||
title = location.n || location.t;
|
||||
} else {
|
||||
error(404, {
|
||||
message: `Location (${locId.toUpperCase()}) not found`,
|
||||
owlCode: 'INVALID_LOCATION_CODE'
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
title,
|
||||
location
|
||||
};
|
||||
};
|
||||
162
src/routes/pis/+page.svelte
Normal file
162
src/routes/pis/+page.svelte
Normal file
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import PisStartEndCard from '$lib/components/ui/cards/pis/PisStartEndCard.svelte';
|
||||
import PisCode from '$lib/components/ui/cards/pis/PisCode.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import type { ApiPisObject } from '@owlboard/owlboard-ts';
|
||||
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
|
||||
import TocStyle from '$lib/components/ui/TocStyle.svelte';
|
||||
|
||||
let results = $state<ApiPisObject.PisObjects[]>([]);
|
||||
let resultsLoaded = $state<boolean>(false);
|
||||
let errorState = $state<{ status: number; message: string } | null>(null);
|
||||
|
||||
async function handleStartEndSearch(start: string, end: string): Promise<void> {
|
||||
console.log(`PIS Search: ${start}-${end}`);
|
||||
errorState = null;
|
||||
|
||||
try {
|
||||
const response = await OwlClient.pis.getByStartEndCrs(start, end);
|
||||
results = response.data || [];
|
||||
} catch (e) {
|
||||
if (e instanceof ValidationError) {
|
||||
errorState = { status: 400, message: e.message };
|
||||
} else if (e instanceof ApiError) {
|
||||
console.log(e);
|
||||
errorState = { status: 20, message: e.message };
|
||||
} else {
|
||||
errorState = { status: 0, message: `Unknown Error: ${e.message}` };
|
||||
}
|
||||
} finally {
|
||||
resultsLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCodeSearch(code: string) {
|
||||
console.log(`PIS Search: ${code}`);
|
||||
errorState = null;
|
||||
try {
|
||||
const response = await OwlClient.pis.getByCode(code);
|
||||
results = response.data || [];
|
||||
} catch (e) {
|
||||
if (e instanceof ValidationError) {
|
||||
errorState = { status: 400, message: e.message };
|
||||
} else if (e instanceof ApiError) {
|
||||
console.log(e);
|
||||
errorState = { status: 20, message: e.message };
|
||||
} else {
|
||||
errorState = { status: 0, message: `Unknown Error: ${e.message}` };
|
||||
}
|
||||
} finally {
|
||||
resultsLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
console.log('Clearing Results');
|
||||
resultsLoaded = false;
|
||||
results = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !resultsLoaded}
|
||||
<div class="card-container">
|
||||
<PisStartEndCard onsearch={handleStartEndSearch} />
|
||||
<PisCode onsearch={handleCodeSearch} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="result-container">
|
||||
{#if errorState}
|
||||
<span class="errCode">Error: {errorState.status}</span>
|
||||
<span class="errMsg">{errorState.message}</span>
|
||||
{:else if results.length}
|
||||
<h2 class="result-title">
|
||||
{results.length} Result{#if results.length > 1}s{/if} found
|
||||
</h2>
|
||||
<table class="result-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:16%">TOC</th>
|
||||
<th style="width:14%">Code</th>
|
||||
<th style="width:70%">Locations</th>
|
||||
</tr></thead
|
||||
>
|
||||
{#each results as result}
|
||||
<tbody
|
||||
><tr>
|
||||
<td><TocStyle toc={result.toc || ''} /></td>
|
||||
<td>{result.code}</td>
|
||||
<td class="locations-row">{result.crsStops?.join(' ') || ''}</td>
|
||||
</tr></tbody
|
||||
>
|
||||
{/each}
|
||||
</table>
|
||||
{:else}
|
||||
<p class="no-results">No matching results</p>
|
||||
{/if}
|
||||
<div class="reset-button-container">
|
||||
<Button onclick={clearResults}>Reset</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.card-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.result-container {
|
||||
font-family: 'URW Gothic', sans-serif;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
background: var(--color-accent);
|
||||
border-radius: 15px;
|
||||
padding: 20px 0 20px 0;
|
||||
margin: auto;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 25px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: var(--shadow-std);
|
||||
}
|
||||
|
||||
.result-title {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.result-table {
|
||||
width: 90%;
|
||||
max-width: 350px;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
table-layout: fixed;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 20px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.locations-row {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
text-align: left;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.errCode {
|
||||
color: rgb(255, 54, 54);
|
||||
font-weight: 600;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.reset-button-container {
|
||||
padding: 20px 0 3px 0;
|
||||
}
|
||||
</style>
|
||||
5
src/routes/pis/+page.ts
Normal file
5
src/routes/pis/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const load = () => {
|
||||
return {
|
||||
title: 'PIS Codes'
|
||||
};
|
||||
};
|
||||
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';
|
||||
|
||||
/** @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;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"sourceMap": false,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user