Compare commits
6 Commits
v3.0.0-dev
...
v3.0.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| f7b1b7fe0d | |||
| 8c0d385772 | |||
| a07315cec2 | |||
| f3393f3c07 | |||
| b649af1925 | |||
| aa1a989139 |
@@ -59,10 +59,10 @@
|
||||
height: 36px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
box-shadow: var(--shadow-std);
|
||||
box-shadow: var(--shadow-small);
|
||||
font-family: 'URW Gothic', sans-serif;
|
||||
font-size: 0.93rem;
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.05ch;
|
||||
transition:
|
||||
all 0.1s ease,
|
||||
@@ -72,6 +72,7 @@
|
||||
.accent {
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-title);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brand {
|
||||
|
||||
@@ -33,9 +33,21 @@
|
||||
if (aExactCrs && !bExactCrs) return -1;
|
||||
if (!aExactCrs && bExactCrs) return 1;
|
||||
|
||||
// Priority Two - 'Stations' with CRS
|
||||
// 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;
|
||||
if (!a.c && !!b.c) return 1;
|
||||
|
||||
// Alphabetical Sort
|
||||
return a.n.localeCompare(b.n);
|
||||
|
||||
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>
|
||||
@@ -22,7 +22,7 @@
|
||||
<div
|
||||
class="btn-container"
|
||||
animate:flip={{ duration: flipDuration }}
|
||||
transition:fade|global={{ duration: 300 }}
|
||||
in:fade|global={{ duration: 200 }}
|
||||
>
|
||||
<Button href={`/board?loc=${station.c}`}
|
||||
><span class="stn-name">{station.n}</span></Button
|
||||
|
||||
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>
|
||||
@@ -7,10 +7,16 @@ class NearestStationsState {
|
||||
loading = $state(true);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
private initGeoConfig: PositionOptions = {
|
||||
enableHighAccuracy: false,
|
||||
timeout: 500,
|
||||
maximumAge: Infinity,
|
||||
}
|
||||
|
||||
private geoConfig: PositionOptions = {
|
||||
enableHighAccuracy: false,
|
||||
timeout: 30000,
|
||||
maximumAge: 120000
|
||||
timeout: 20000,
|
||||
maximumAge: 30000,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
@@ -24,7 +30,7 @@ class NearestStationsState {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude),
|
||||
(err) => this.handleError(err),
|
||||
this.geoConfig
|
||||
this.initGeoConfig
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
/* 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);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -6,6 +6,8 @@
|
||||
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';
|
||||
|
||||
import logoText from '$lib/assets/round-logo-text.svg';
|
||||
@@ -78,6 +80,7 @@
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<TimezoneWarning />
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<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>
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
<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>
|
||||
|
||||
@@ -18,11 +18,13 @@ export const load: PageLoad = async ({ url }) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (locId) {
|
||||
const location = LOCATIONS.find(locId);
|
||||
let BoardLocation;
|
||||
|
||||
if (location) {
|
||||
title = location.n || location.t;
|
||||
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`,
|
||||
@@ -32,6 +34,6 @@ export const load: PageLoad = async ({ url }) => {
|
||||
}
|
||||
return {
|
||||
title,
|
||||
location
|
||||
BoardLocation
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user