Compare commits
20 Commits
ea2468092c
...
v3.0.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| d9b60daa8b | |||
| 2f0a6b9646 | |||
| 1165c02e26 | |||
| 45dd5a1cf5 | |||
| e47bebe7d4 | |||
| b7007d2fb3 | |||
| 64bc5b979d | |||
| 3240560a0b | |||
| a327582629 | |||
| 35877ae8ac | |||
| f5c3775f59 | |||
| e5d3c0a3a7 | |||
| 1b0b93b34b | |||
| 66ed33343c | |||
| 50e56159b7 | |||
| 6152afda3b | |||
| 061598a0ad | |||
| 54ea6ebf59 | |||
| d7f6d957b7 | |||
| 9e3d6856d9 |
39
.gitea/workflows/build-push.yaml
Normal file
39
.gitea/workflows/build-push.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Build and Push container image
|
||||||
|
run-name: ${{ gitea.actor }} is building and pushing
|
||||||
|
|
||||||
|
on:
|
||||||
|
create:
|
||||||
|
tags: '*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_DOMAIN: git.fjla.uk
|
||||||
|
GITEA_REGISTRY_USER: owlbot
|
||||||
|
RESULT_IMAGE_NAME: owlboard/web-pwa
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
options: --privileged
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.GITEA_DOMAIN }}
|
||||||
|
username: ${{ env.GITEA_REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
- name: Build and Push image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.GITEA_DOMAIN }}/${{ env.RESULT_IMAGE_NAME }}:${{ gitea.ref_name }}
|
||||||
|
${{ env.GITEA_DOMAIN }}/${{ env.RESULT_IMAGE_NAME }}:latest
|
||||||
1
.npmrc
1
.npmrc
@@ -1 +1,2 @@
|
|||||||
engine-strict=true
|
engine-strict=true
|
||||||
|
@owlboard:registry=https://git.fjla.uk/api/packages/OwlBoard/npm/
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
WORKDIR /web-content
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.description="SvelteKit Static build - Designed to be an InitContainer to copy files from to the output web server container's filesystem"
|
||||||
|
|
||||||
|
COPY --from=builder /app/build ./
|
||||||
|
CMD ["sh", "-c", "cp -R /web-content/* /target-dir/"]
|
||||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -1178,9 +1178,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/kit": {
|
"node_modules/@sveltejs/kit": {
|
||||||
"version": "2.53.4",
|
"version": "2.55.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.4.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz",
|
||||||
"integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==",
|
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1189,7 +1189,7 @@
|
|||||||
"@types/cookie": "^0.6.0",
|
"@types/cookie": "^0.6.0",
|
||||||
"acorn": "^8.14.1",
|
"acorn": "^8.14.1",
|
||||||
"cookie": "^0.6.0",
|
"cookie": "^0.6.0",
|
||||||
"devalue": "^5.6.3",
|
"devalue": "^5.6.4",
|
||||||
"esm-env": "^1.2.2",
|
"esm-env": "^1.2.2",
|
||||||
"kleur": "^4.1.5",
|
"kleur": "^4.1.5",
|
||||||
"magic-string": "^0.30.5",
|
"magic-string": "^0.30.5",
|
||||||
@@ -2036,9 +2036,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/devalue": {
|
"node_modules/devalue": {
|
||||||
"version": "5.6.3",
|
"version": "5.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
|
||||||
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
|
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/es-module-lexer": {
|
"node_modules/es-module-lexer": {
|
||||||
@@ -2452,9 +2452,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.3",
|
"version": "3.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
|
||||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,8 +4,11 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<meta name="title" content="OwlBoard | Your fasted route to live and reference data" />
|
<meta name="title" content="OwlBoard | Your fastest route to live and reference data" />
|
||||||
<meta name="description" content="Live station departures, Live train tracking, PIS Codes & more" />
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Live station departures, Live train tracking, PIS Codes & more"
|
||||||
|
/>
|
||||||
<meta name="theme-color" content="#4fd1d1" />
|
<meta name="theme-color" content="#4fd1d1" />
|
||||||
<link rel="canonical" href="https://owlboard.info" />
|
<link rel="canonical" href="https://owlboard.info" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
|
|||||||
1
src/lib/assets/img/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 |
82
src/lib/components/ui/Button.svelte
Normal file
82
src/lib/components/ui/Button.svelte
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<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="btn {color}"
|
||||||
|
target={isExternal ? '_blank' : undefined}
|
||||||
|
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button class="btn {color}" {onclick} {...rest}>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.4rem 1.2rem;
|
||||||
|
width: fit-content;
|
||||||
|
min-width: 90px;
|
||||||
|
min-height: 48px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
letter-spacing: 0.05ch;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: var(--shadow-std);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
filter: brightness(1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
204
src/lib/components/ui/LocationSearchBox.svelte
Normal file
204
src/lib/components/ui/LocationSearchBox.svelte
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<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.ts';
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
// Check if query matches CRS
|
||||||
|
const aIsCrs = a.c?.toLowerCase() === lowerQuery;
|
||||||
|
const bIsCrs = b.c?.toLowerCase() === lowerQuery;
|
||||||
|
|
||||||
|
// Sort matching CRS first
|
||||||
|
if (aIsCrs && !bIsCrs) return -1;
|
||||||
|
if (!aIsCrs && bIsCrs) 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: LocationRecord) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiploc {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
94
src/lib/components/ui/Textbox.svelte
Normal file
94
src/lib/components/ui/Textbox.svelte
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
101
src/lib/components/ui/cards/BaseCard.svelte
Normal file
101
src/lib/components/ui/cards/BaseCard.svelte
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
52
src/lib/components/ui/cards/pis/PisCode.svelte
Normal file
52
src/lib/components/ui/cards/pis/PisCode.svelte
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<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 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>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>
|
||||||
57
src/lib/components/ui/cards/pis/PisStartEndCard.svelte
Normal file
57
src/lib/components/ui/cards/pis/PisStartEndCard.svelte
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<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 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>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>
|
||||||
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);
|
||||||
|
}
|
||||||
37
src/lib/locations-object.svelte.ts
Normal file
37
src/lib/locations-object.svelte.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
interface LocationRecord {
|
||||||
|
n: string; // name
|
||||||
|
t: string; // tiploc
|
||||||
|
c?: string; // crs
|
||||||
|
s: string; // search string
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocationStore {
|
||||||
|
data = $state<LocationRecord[]>([]);
|
||||||
|
loaded = $state(false);
|
||||||
|
|
||||||
|
async init(fetcher = fetch) {
|
||||||
|
if (this.loaded) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetcher('/api/tiplocs');
|
||||||
|
this.data = await res.json();
|
||||||
|
this.loaded = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load locations', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
find(id: string | null): LocationRecord | 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();
|
||||||
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,26 +1,62 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { slide, fade } from 'svelte/transition';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
import { LOCATIONS } from '$lib/locations-object.svelte.ts';
|
||||||
|
|
||||||
|
import '$lib/global.css';
|
||||||
|
|
||||||
import logoText from '$lib/assets/round-logo-text.svg';
|
import logoText from '$lib/assets/round-logo-text.svg';
|
||||||
import logoPlain from '$lib/assets/round-logo.svg';
|
import logoPlain from '$lib/assets/round-logo.svg';
|
||||||
import favicon from '$lib/assets/round-logo.svg';
|
import 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(fetch));
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
const navItems = [
|
// Navigation State
|
||||||
{ 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 === '/');
|
|
||||||
|
|
||||||
|
|
||||||
const moreIcon = IconDots;
|
const moreIcon = IconDots;
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: 'Home', path: '/', icon: IconHome },
|
||||||
|
{ label: 'PIS', path: '/pis/', icon: IconDialpad },
|
||||||
|
{ label: 'Options', path: '/preferences/', icon: IconSettings },
|
||||||
|
{ label: 'About', path: '/about/', icon: IconHelp }
|
||||||
|
];
|
||||||
|
|
||||||
|
let navWidth = $state(0);
|
||||||
|
let menuOpen = $state(false);
|
||||||
|
const ITEM_WIDTH = 70;
|
||||||
|
const MORE_BUTTON_WIDTH = 70;
|
||||||
|
|
||||||
|
const activePath = $derived(page.url?.pathname ?? '');
|
||||||
|
|
||||||
|
const visibleCount = $derived.by(() => {
|
||||||
|
if (navWidth === 0) return navItems.length;
|
||||||
|
const available = navWidth;
|
||||||
|
const totalItems = navItems.length;
|
||||||
|
const countWithoutMore = Math.floor(available / ITEM_WIDTH);
|
||||||
|
|
||||||
|
if (countWithoutMore >= totalItems) return totalItems;
|
||||||
|
|
||||||
|
const availableWithMore = navWidth - MORE_BUTTON_WIDTH;
|
||||||
|
const countWithMore = Math.floor(availableWithMore / ITEM_WIDTH);
|
||||||
|
|
||||||
|
if (totalItems - countWithMore === 1) {
|
||||||
|
if (available >= (countWithMore + 1) * ITEM_WIDTH) {
|
||||||
|
return totalItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return countWithMore;
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleItems = $derived(navItems.slice(0, visibleCount));
|
||||||
|
const hiddenItems = $derived(navItems.slice(visibleCount));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -29,72 +65,89 @@
|
|||||||
|
|
||||||
<header>
|
<header>
|
||||||
<a href="/" aria-label="OwlBoard Home" class="logo-link">
|
<a href="/" aria-label="OwlBoard Home" class="logo-link">
|
||||||
{#if isHome}
|
{#if page.data.title}
|
||||||
<img src={logoText} alt="OwlBoard Logo" height="55" />
|
|
||||||
{:else}
|
|
||||||
<img src={logoPlain} alt="OwlBoard Icon" height="55" />
|
<img src={logoPlain} alt="OwlBoard Icon" height="55" />
|
||||||
|
{:else}
|
||||||
|
<img src={logoText} alt="OwlBoard Logo" height="55" />
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{#if !isHome}
|
{#if page.data.title}
|
||||||
<h1 class="page-title">{page?.data?.title ?? ""}</h1>
|
<h1 class="page-title">{page.data.title}</h1>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<nav>
|
<nav bind:clientWidth={navWidth}>
|
||||||
<!-- Dynamic Nav Elements Here! -->
|
<!-- Dynamic Nav Elements Here! -->
|
||||||
{#each navItems as item}
|
{#each visibleItems as item}
|
||||||
{@const isActive = activePath === item.path}
|
{@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')}
|
||||||
<a
|
<a
|
||||||
href={item.path}
|
href={item.path}
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
class:active={isActive}
|
class:active={isActive}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
>
|
onclick={() => (menuOpen = false)}
|
||||||
<item.icon
|
>
|
||||||
size={24}
|
<item.icon size={24} stroke={isActive ? 2 : 1.5} class="nav-icon" />
|
||||||
stroke={isActive ? 2.5 : 2}
|
|
||||||
class="nav-icon"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span class="label">{item.label}</span>
|
<span class="label">{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if hiddenItems.length > 0}
|
||||||
|
<div class="more-menu-wrapper">
|
||||||
|
<button
|
||||||
|
class="nav-item more-btn"
|
||||||
|
onclick={() => (menuOpen = !menuOpen)}
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
>
|
||||||
|
<IconDots size={24} />
|
||||||
|
<span class="label">More</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if menuOpen}
|
||||||
|
<div
|
||||||
|
class="backdrop"
|
||||||
|
onclick={() => (menuOpen = false)}
|
||||||
|
transition:fade={{ duration: 150 }}
|
||||||
|
></div>
|
||||||
|
<div class="menu-popover" transition:slide={{ axis: 'y', duration: 250 }}>
|
||||||
|
{#each hiddenItems as item}
|
||||||
|
{@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')}
|
||||||
|
<a
|
||||||
|
href={item.path}
|
||||||
|
class="menu-popover-item"
|
||||||
|
onclick={() => (menuOpen = false)}
|
||||||
|
class:active={isActive}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<item.icon size={20} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="viewport-guard">
|
||||||
|
<img src={logoPlain} alt="OwlBoard Logo" width="100" height="100" />
|
||||||
|
<h1 class="viewport-guard-title">Narrow Gauge Detected</h1>
|
||||||
|
<p>
|
||||||
|
Just as trains need the right track width, our data needs a bit more room to stay on the rails.
|
||||||
|
Please expand your view to at least 300px to view the app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
header {
|
||||||
/* 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;
|
top: 0;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
box-shadow: var(--shadow-std);
|
box-shadow: var(--shadow-std);
|
||||||
}
|
}
|
||||||
.logo-link {
|
.logo-link {
|
||||||
width: auto;
|
width: auto;
|
||||||
display: block;
|
display: block;
|
||||||
height: 55px;
|
height: 55px;
|
||||||
@@ -107,11 +160,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: clamp(0.9rem, 2.5vw + 0.8rem, 2rem);
|
||||||
|
font-style: normal;
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
color: var(--color-title);
|
color: var(--color-title);
|
||||||
}
|
}
|
||||||
header, nav {
|
header,
|
||||||
|
nav {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
@@ -126,13 +184,6 @@
|
|||||||
gap: 0rem;
|
gap: 0rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
|
||||||
bottom: 0;
|
|
||||||
height: 60px;
|
|
||||||
box-shadow: var(--shadow-up);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
main {
|
main {
|
||||||
padding-top: 80px;
|
padding-top: 80px;
|
||||||
padding-bottom: 60px;
|
padding-bottom: 60px;
|
||||||
@@ -142,21 +193,43 @@
|
|||||||
background-image: radial-gradient(var(--color-bg-dark), var(--color-bg-light));
|
background-image: radial-gradient(var(--color-bg-dark), var(--color-bg-light));
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
nav {
|
||||||
|
display: flex;
|
||||||
|
bottom: 0;
|
||||||
|
height: 60px;
|
||||||
|
box-shadow: var(--shadow-up);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item,
|
||||||
|
.more-menu-wrapper {
|
||||||
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
width: 70px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
color: rgba(255,255,255,0.4);
|
border: 0;
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
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;
|
color: #ffffff;
|
||||||
background: rgba(255,255,255,0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active::before {
|
.nav-item.active::before {
|
||||||
@@ -170,10 +243,93 @@
|
|||||||
box-shadow: var(--shadow-std);
|
box-shadow: var(--shadow-std);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-item.active .label,
|
||||||
|
.menu-popover-item.active .label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 0.65rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 700;
|
font-family: 'URW Gothic', sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
margin-top: 4px;
|
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>
|
</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,18 @@
|
|||||||
<h2>OwlBoard</h2>
|
<script lang="ts">
|
||||||
|
import LocationBoardCard from '$lib/components/ui/cards/LocationBoardCard.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card-container">
|
||||||
|
<LocationBoardCard />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,2 +1,104 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
</script>
|
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 Roman Goddess as well as with wisdom. The name also links to Bath, where the
|
||||||
|
app has been built and is run, representing the 'Minerva Owl' sculpture trail in the city, with
|
||||||
|
many of the sculptures still in the area.
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const load = () => {
|
export const load = () => {
|
||||||
return {
|
return {
|
||||||
title: "About",
|
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(fetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
33
src/routes/pis/+page.svelte
Normal file
33
src/routes/pis/+page.svelte
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PisStartEndCard from '$lib/components/ui/cards/pis/PisStartEndCard.svelte';
|
||||||
|
import PisCode from '$lib/components/ui/cards/pis/PisCode.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card-container">
|
||||||
|
<PisStartEndCard />
|
||||||
|
<PisCode />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-container">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
</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'
|
||||||
|
};
|
||||||
|
};
|
||||||
106
static/api/tiplocs
Normal file
106
static/api/tiplocs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
[
|
||||||
|
{"n":"Manchester Piccadilly","t":"MANPICD","c":"MAN","s":"manchester piccadilly man manpicd"},
|
||||||
|
{"n":"Manchester Victoria","t":"MCV","c":"MCV","s":"manchester victoria mcv"},
|
||||||
|
{"n":"Manchester Oxford Road","t":"MCOR","c":"MCO","s":"manchester oxford road mco mcor"},
|
||||||
|
{"n":"Manchester Airport","t":"MANAPTL","c":"MIA","s":"manchester airport mia manaptl"},
|
||||||
|
{"n":"London Euston","t":"EUSTON","c":"EUS","s":"london euston eus euston"},
|
||||||
|
{"n":"London Kings Cross","t":"KGX","c":"KGX","s":"london kings cross kgx kingscross"},
|
||||||
|
{"n":"London St Pancras International","t":"STPANCR","c":"STP","s":"london st pancras international stp stpancr"},
|
||||||
|
{"n":"London Paddington","t":"PADTON","c":"PAD","s":"london paddington pad padton"},
|
||||||
|
{"n":"London Victoria","t":"VIC","c":"VIC","s":"london victoria vic"},
|
||||||
|
{"n":"London Liverpool Street","t":"LIVST","c":"LST","s":"london liverpool street lst livst"},
|
||||||
|
{"n":"London Bridge","t":"LONGBR","c":"LBG","s":"london bridge lbg longbr"},
|
||||||
|
{"n":"Birmingham New Street","t":"BHMNEWST","c":"BHM","s":"birmingham new street bhm bhmnewst bham"},
|
||||||
|
{"n":"Birmingham Moor Street","t":"BHMMRS","c":"BMO","s":"birmingham moor street bmo bhmmrs"},
|
||||||
|
{"n":"Birmingham Snow Hill","t":"BHMSH","c":"BSW","s":"birmingham snow hill bsw bhmsh"},
|
||||||
|
{"n":"Leeds","t":"LEEDS","c":"LDS","s":"leeds lds"},
|
||||||
|
{"n":"York","t":"YORK","c":"YRK","s":"york yrk"},
|
||||||
|
{"n":"Liverpool Lime Street","t":"LIVLST","c":"LIV","s":"liverpool lime street liv livlst"},
|
||||||
|
{"n":"Liverpool Central","t":"LIVCEN","c":"LVC","s":"liverpool central lvc livcen"},
|
||||||
|
{"n":"Sheffield","t":"SHEFFLD","c":"SHF","s":"sheffield shf sheffld"},
|
||||||
|
{"n":"Nottingham","t":"NOTTM","c":"NOT","s":"nottingham not nottm"},
|
||||||
|
{"n":"Derby","t":"DERBY","c":"DBY","s":"derby dby"},
|
||||||
|
{"n":"Leicester","t":"LEICEST","c":"LEI","s":"leicester lei leicest"},
|
||||||
|
{"n":"Bristol Temple Meads","t":"BRSTLTM","c":"BRI","s":"bristol temple meads bri brstltm"},
|
||||||
|
{"n":"Cardiff Central","t":"CDFCEN","c":"CDF","s":"cardiff central cdf cdfcen"},
|
||||||
|
{"n":"Newcastle","t":"NEWCAST","c":"NCL","s":"newcastle ncl newcast"},
|
||||||
|
{"n":"Edinburgh Waverley","t":"EDINBUR","c":"EDB","s":"edinburgh waverley edb edinbur"},
|
||||||
|
{"n":"Glasgow Central","t":"GLASCEN","c":"GLC","s":"glasgow central glc glascen"},
|
||||||
|
{"n":"Glasgow Queen Street","t":"GLAQS","c":"GLQ","s":"glasgow queen street glq glaqs"},
|
||||||
|
{"n":"Reading","t":"READING","c":"RDG","s":"reading rdg"},
|
||||||
|
{"n":"Oxford","t":"OXFORD","c":"OXF","s":"oxford oxf"},
|
||||||
|
{"n":"Cambridge","t":"CAMBRDG","c":"CBG","s":"cambridge cbg cambrdg"},
|
||||||
|
{"n":"Peterborough","t":"PBOUGH","c":"PBO","s":"peterborough pbo pbough"},
|
||||||
|
{"n":"Doncaster","t":"DONCAST","c":"DON","s":"doncaster don doncast"},
|
||||||
|
{"n":"Crewe","t":"CREWE","c":"CRE","s":"crewe cre"},
|
||||||
|
{"n":"Preston","t":"PRESTON","c":"PRE","s":"preston pre"},
|
||||||
|
{"n":"Blackpool North","t":"BPLNOR","c":"BPN","s":"blackpool north bpn bplnor"},
|
||||||
|
{"n":"Bolton","t":"BOLTON","c":"BON","s":"bolton bon"},
|
||||||
|
{"n":"Huddersfield","t":"HUDDSFD","c":"HUD","s":"huddersfield hud huddsfd"},
|
||||||
|
{"n":"Stockport","t":"STOCKPT","c":"SPT","s":"stockport spt stockpt"},
|
||||||
|
{"n":"Wigan North Western","t":"WIGNW","c":"WGN","s":"wigan north western wgn wignw"},
|
||||||
|
{"n":"Bath Spa","t":"BATHSPA","c":"BTH","s":"bath spa bth bathspa"},
|
||||||
|
{"n":"Exeter St Davids","t":"EXD","c":"EXD","s":"exeter st davids exd"},
|
||||||
|
{"n":"Plymouth","t":"PLYMTH","c":"PLY","s":"plymouth ply plymth"},
|
||||||
|
{"n":"Truro","t":"TRURO","c":"TRU","s":"truro tru"},
|
||||||
|
{"n":"Aberdeen","t":"ABERDN","c":"ABD","s":"aberdeen abd aberdn"},
|
||||||
|
{"n":"Inverness","t":"INVNESS","c":"INV","s":"inverness inv invness"},
|
||||||
|
{"n":"Perth","t":"PERTH","c":"PTH","s":"perth pth"},
|
||||||
|
{"n":"Dundee","t":"DUNDEE","c":"DEE","s":"dundee dee"},
|
||||||
|
{"n":"Stirling","t":"STIRLNG","c":"STG","s":"stirling stg stirlng"},
|
||||||
|
{"n":"Falkirk Grahamston","t":"FLKGRA","c":"FKG","s":"falkirk grahamston fkg flkgra"},
|
||||||
|
{"n":"Motherwell","t":"MOTHRWL","c":"MTH","s":"motherwell mth mothrwl"},
|
||||||
|
{"n":"Paisley Gilmour Street","t":"PAISGL","c":"PYG","s":"paisley gilmour street pyg paisgl"},
|
||||||
|
{"n":"Greenock Central","t":"GRNOCK","c":"GKC","s":"greenock central gkc grnock"},
|
||||||
|
{"n":"Ayr","t":"AYR","c":"AYR","s":"ayr"},
|
||||||
|
{"n":"Carlisle","t":"CARLISL","c":"CAR","s":"carlisle car carlisl"},
|
||||||
|
{"n":"Penrith North Lakes","t":"PNRITH","c":"PNR","s":"penrith north lakes pnr pnrith"},
|
||||||
|
{"n":"Kendal","t":"KENDAL","c":"KEN","s":"kendal ken"},
|
||||||
|
{"n":"Windermere","t":"WNDRMRE","c":"WDM","s":"windermere wdm wndrme"},
|
||||||
|
{"n":"Lancaster","t":"LANCAST","c":"LAN","s":"lancaster lan lancast"},
|
||||||
|
{"n":"Chester","t":"CHESTER","c":"CTR","s":"chester ctr"},
|
||||||
|
{"n":"Warrington Bank Quay","t":"WRRGBQ","c":"WBQ","s":"warrington bank quay wbq wrrgbq"},
|
||||||
|
{"n":"Warrington Central","t":"WRRGCN","c":"WAC","s":"warrington central wac wrrgcn"},
|
||||||
|
{"n":"Runcorn","t":"RUNCORN","c":"RUN","s":"runcorn run"},
|
||||||
|
{"n":"Widnes","t":"WIDNES","c":"WID","s":"widnes wid"},
|
||||||
|
{"n":"Southport","t":"STHPORT","c":"SOP","s":"southport sop sthport"},
|
||||||
|
{"n":"Ormskirk","t":"ORMSKRK","c":"OMS","s":"ormskirk oms ormskrk"},
|
||||||
|
{"n":"Blackburn","t":"BLKBRN","c":"BBN","s":"blackburn bbn blkbrn"},
|
||||||
|
{"n":"Burnley Manchester Road","t":"BURNMR","c":"BYM","s":"burnley manchester road bym burnmr"},
|
||||||
|
{"n":"Rochdale","t":"ROCHDAL","c":"RCD","s":"rochdale rcd rochdal"},
|
||||||
|
{"n":"Oldham Mumps","t":"OLDMUM","c":"OMM","s":"oldham mumps omm oldmum"},
|
||||||
|
{"n":"Ashton-under-Lyne","t":"ASHTON","c":"AHN","s":"ashton under lyne ahn ashton"},
|
||||||
|
{"n":"Stalybridge","t":"STALYBG","c":"SYB","s":"stalybridge syb stalybg"},
|
||||||
|
{"n":"Macclesfield","t":"MACCLFD","c":"MAC","s":"macclesfield mac macclfd"},
|
||||||
|
{"n":"Congleton","t":"CONGLTN","c":"CNG","s":"congleton cng conglt"},
|
||||||
|
{"n":"Stoke-on-Trent","t":"STOKETR","c":"SOT","s":"stoke on trent sot stoketr"},
|
||||||
|
{"n":"Stafford","t":"STAFFRD","c":"STA","s":"stafford sta staffrd"},
|
||||||
|
{"n":"Tamworth","t":"TAMWTH","c":"TAM","s":"tamworth tam tamwth"},
|
||||||
|
{"n":"Nuneaton","t":"NUNEATN","c":"NUN","s":"nuneaton nun nuneatn"},
|
||||||
|
{"n":"Coventry","t":"COVNTRY","c":"COV","s":"coventry cov covntry"},
|
||||||
|
{"n":"Rugby","t":"RUGBY","c":"RUG","s":"rugby rug"},
|
||||||
|
{"n":"Milton Keynes Central","t":"MKCEN","c":"MKC","s":"milton keynes central mkc mkcen"},
|
||||||
|
{"n":"Birmingham Washwood Heath Junction","t":"BWHJCT","c":"","s":"birmingham washwood heath junction bwhjct"},
|
||||||
|
{"n":"Manchester Trafford Park Yard","t":"MTRYD","c":"","s":"manchester trafford park yard mtryd"},
|
||||||
|
{"n":"London Willesden Junction","t":"WLSDJCT","c":"","s":"london willesden junction wlsdjct"},
|
||||||
|
{"n":"Leeds Neville Hill Depot","t":"NVHLDP","c":"","s":"leeds neville hill depot nvhldp"},
|
||||||
|
{"n":"York Holgate Junction","t":"YHGJCT","c":"","s":"york holgate junction yhgjct"},
|
||||||
|
{"n":"Crewe Basford Hall Junction","t":"CBHJCT","c":"","s":"crewe basford hall junction cbhjct"},
|
||||||
|
{"n":"Doncaster Decoy Sidings","t":"DCDSID","c":"","s":"doncaster decoy sidings dcdsid"},
|
||||||
|
{"n":"Liverpool Edge Hill Yard","t":"LEHYD","c":"","s":"liverpool edge hill yard lehyd"},
|
||||||
|
{"n":"Bristol East Junction","t":"BREJCT","c":"","s":"bristol east junction brejct"},
|
||||||
|
{"n":"Glasgow Polmadie Depot","t":"GLPDEP","c":"","s":"glasgow polmadie depot glpdep"},
|
||||||
|
{"n":"Newcastle Manors Junction","t":"NCMJCT","c":"","s":"newcastle manors junction ncmjct"},
|
||||||
|
{"n":"Edinburgh Haymarket Sidings","t":"EHSID","c":"","s":"edinburgh haymarket sidings ehsid"},
|
||||||
|
{"n":"Reading South Junction","t":"RDSJCT","c":"","s":"reading south junction rdsjct"},
|
||||||
|
{"n":"Oxford Rewley Road Depot","t":"OXRDEP","c":"","s":"oxford rewley road depot oxrdep"},
|
||||||
|
{"n":"Cambridge Coldham Lane Junction","t":"CCLJCT","c":"","s":"cambridge coldham lane junction ccljct"},
|
||||||
|
{"n":"Watford North Junction","t":"WFNJCT","c":"","s":"watford north junction wfnjct"},
|
||||||
|
{"n":"Luton Airport Sidings","t":"LUTSID","c":"","s":"luton airport sidings lutsid"},
|
||||||
|
{"n":"Stevenage Hitchin Junction","t":"STHJC","c":"","s":"stevenage hitchin junction sthjc"},
|
||||||
|
{"n":"Chelmsford New Hall Junction","t":"CHNJCT","c":"","s":"chelmsford new hall junction chnjct"},
|
||||||
|
{"n":"","t":"BPWY532","c":"","s":"bpwy532"},
|
||||||
|
{"n":"Ipswich Derby Road Depot","t":"IPDRDP","c":"","s":"ipswich derby road depot ipdrdp"},
|
||||||
|
{"n":"Rhoose Cardiff International Airport","c":"RIA","t":"RHOOSE","s":"rhoose cardiff international airport ria"},
|
||||||
|
{"n":"Southampton Airport Parkway","c":"SOA","t":"SOTAPT","s":"southampton airport parkway soa sotapt"}
|
||||||
|
]
|
||||||
93
static/type/urwgothic/LICENSE
Normal file
93
static/type/urwgothic/LICENSE
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright (c) 2014,2015 by (URW)++ Design & Development
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
BIN
static/type/urwgothic/urwgothic-book-webfont.woff2
Normal file
BIN
static/type/urwgothic/urwgothic-book-webfont.woff2
Normal file
Binary file not shown.
BIN
static/type/urwgothic/urwgothic-bookoblique-webfont.woff2
Normal file
BIN
static/type/urwgothic/urwgothic-bookoblique-webfont.woff2
Normal file
Binary file not shown.
BIN
static/type/urwgothic/urwgothic-demi-webfont.woff2
Normal file
BIN
static/type/urwgothic/urwgothic-demi-webfont.woff2
Normal file
Binary file not shown.
BIN
static/type/urwgothic/urwgothic-demioblique-webfont.woff2
Normal file
BIN
static/type/urwgothic/urwgothic-demioblique-webfont.woff2
Normal file
Binary file not shown.
@@ -1,6 +1,20 @@
|
|||||||
import adapter from '@sveltejs/adapter-static';
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = { kit: { adapter: adapter() } };
|
const config = {
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
pages: 'build',
|
||||||
|
assets: 'build',
|
||||||
|
fallback: 'index.html',
|
||||||
|
precompress: 'true',
|
||||||
|
strict: 'true'
|
||||||
|
}),
|
||||||
|
prerender: {
|
||||||
|
// Temporary option during testing
|
||||||
|
handleHttpError: 'ignore'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
Reference in New Issue
Block a user