Compare commits
36 Commits
d7f6d957b7
...
v3.0.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
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 |
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: 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-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>
|
||||
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>
|
||||
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>
|
||||
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 }}
|
||||
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 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>
|
||||
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();
|
||||
@@ -4,78 +4,80 @@
|
||||
* 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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
: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;
|
||||
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);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: var(--color-brand);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
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();
|
||||
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 TimezoneWarning from '$lib/components/ui/TimezoneWarning.svelte';
|
||||
|
||||
import '$lib/global.css';
|
||||
|
||||
@@ -7,22 +14,52 @@
|
||||
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';
|
||||
import { IconHome, IconDialpad, IconSettings, IconHelp, IconDots } from '@tabler/icons-svelte';
|
||||
|
||||
onMount(() => LOCATIONS.init());
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
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},
|
||||
]
|
||||
|
||||
const activePath = $derived(page.url?.pathname ?? '');
|
||||
const isHome = $derived(page.url.pathname === '/');
|
||||
|
||||
// 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 }
|
||||
];
|
||||
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
@@ -31,49 +68,90 @@
|
||||
|
||||
<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">{page?.data?.title ?? ""}</h1>
|
||||
{#if page.data.title}
|
||||
<h1 class="page-title">{page.data.title}</h1>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{@render children()}
|
||||
<TimezoneWarning />
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<nav>
|
||||
<nav bind:clientWidth={navWidth}>
|
||||
<!-- Dynamic Nav Elements Here! -->
|
||||
{#each navItems as item}
|
||||
{@const isActive = activePath === item.path}
|
||||
<a
|
||||
{#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}
|
||||
>
|
||||
<item.icon
|
||||
size={24}
|
||||
stroke={isActive ? 2 : 1.5}
|
||||
class="nav-icon"
|
||||
/>
|
||||
onclick={() => (menuOpen = false)}
|
||||
>
|
||||
<item.icon size={24} stroke={isActive ? 2 : 1.5} class="nav-icon" />
|
||||
|
||||
<span class="label">{item.label}</span>
|
||||
</a>
|
||||
</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>
|
||||
header {
|
||||
header {
|
||||
top: 0;
|
||||
height: 80px;
|
||||
box-shadow: var(--shadow-std);
|
||||
}
|
||||
.logo-link {
|
||||
.logo-link {
|
||||
width: auto;
|
||||
display: block;
|
||||
height: 55px;
|
||||
@@ -88,12 +166,15 @@
|
||||
.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);
|
||||
@@ -108,13 +189,6 @@
|
||||
gap: 0rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
bottom: 0;
|
||||
height: 60px;
|
||||
box-shadow: var(--shadow-up);
|
||||
}
|
||||
|
||||
|
||||
main {
|
||||
padding-top: 80px;
|
||||
padding-bottom: 60px;
|
||||
@@ -124,22 +198,43 @@
|
||||
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;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
color: var(--color-title);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
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);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.nav-item.active::before {
|
||||
@@ -153,7 +248,8 @@
|
||||
box-shadow: var(--shadow-std);
|
||||
}
|
||||
|
||||
.nav-item.active .label {
|
||||
.nav-item.active .label,
|
||||
.menu-popover-item.active .label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -164,4 +260,81 @@
|
||||
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>
|
||||
|
||||
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,22 @@
|
||||
<h2>OwlBoard</h2>
|
||||
<script lang="ts">
|
||||
import LocationBoardCard from '$lib/components/ui/cards/LocationBoardCard.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 />
|
||||
<NearbyStationsCard />
|
||||
<QuickLinksCard />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
padding: 20px 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,75 +1,104 @@
|
||||
<script lang="ts">
|
||||
import logo from '$lib/assets/round-logo.svg'
|
||||
import logo from '$lib/assets/round-logo.svg';
|
||||
|
||||
let isSpinning = $state(false);
|
||||
let isSpinning = $state(false);
|
||||
|
||||
function handleLogoTap() {
|
||||
if (isSpinning) return;
|
||||
isSpinning = true;
|
||||
setTimeout(() => isSpinning = false, 800);
|
||||
}
|
||||
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} />
|
||||
<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="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>
|
||||
<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>
|
||||
<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;
|
||||
}
|
||||
.logo-container {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes owl-spin {
|
||||
0% {transform: rotate(0deg);}
|
||||
15% {transform: rotate(-20deg);}
|
||||
100% {transform: rorate(360deg);}
|
||||
}
|
||||
@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 {
|
||||
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;
|
||||
}
|
||||
.logo.animate {
|
||||
animation: owl-spin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
section {
|
||||
margin: auto;
|
||||
width: 75%;
|
||||
font-family: 'URW Gothic', sans-serif;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
section {
|
||||
margin: auto;
|
||||
width: 90%;
|
||||
max-width: 650px;
|
||||
font-family: 'URW Gothic', sans-serif;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
section:last-child {
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const load = () => {
|
||||
return {
|
||||
title: "About",
|
||||
};
|
||||
};
|
||||
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
|
||||
};
|
||||
};
|
||||
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'
|
||||
};
|
||||
};
|
||||
@@ -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