Compare commits
6 Commits
v3.0.0-dev
...
v3.0.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c0d385772 | |||
| a07315cec2 | |||
| f3393f3c07 | |||
| b649af1925 | |||
| aa1a989139 | |||
| 304b523127 |
@@ -19,47 +19,60 @@
|
|||||||
{#if isLink}
|
{#if isLink}
|
||||||
<a
|
<a
|
||||||
{href}
|
{href}
|
||||||
class="btn {color}"
|
class="hitbox-wrapper"
|
||||||
target={isExternal ? '_blank' : undefined}
|
target={isExternal ? '_blank' : undefined}
|
||||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
><span class="btn {color}">
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="btn {color}" {onclick} {...rest}>
|
<button class="hitbox-wrapper" {onclick} {...rest}>
|
||||||
{@render children?.()}
|
<span class="btn {color}">{@render children?.()}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.btn {
|
.hitbox-wrapper {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.4rem 1.2rem;
|
|
||||||
width: fit-content;
|
|
||||||
min-width: 90px;
|
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 20px;
|
padding: 0 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
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;
|
-webkit-tap-highlight-color: transparent;
|
||||||
touch-action: manipulation;
|
}
|
||||||
|
.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 {
|
.accent {
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
color: var(--color-title);
|
color: var(--color-title);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -72,11 +85,11 @@
|
|||||||
color: var(--color-title);
|
color: var(--color-title);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.hitbox-wrapper:hover .btn {
|
||||||
filter: brightness(1.5);
|
filter: brightness(1.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.active {
|
.hitbox-wrapper:active .btn {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -33,9 +33,21 @@
|
|||||||
if (aExactCrs && !bExactCrs) return -1;
|
if (aExactCrs && !bExactCrs) return -1;
|
||||||
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;
|
if (!a.c && !!b.c) return 1;
|
||||||
|
|
||||||
// Alphabetical Sort
|
// Alphabetical Sort
|
||||||
return a.n.localeCompare(b.n);
|
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,8 +22,7 @@
|
|||||||
<div
|
<div
|
||||||
class="btn-container"
|
class="btn-container"
|
||||||
animate:flip={{ duration: flipDuration }}
|
animate:flip={{ duration: flipDuration }}
|
||||||
in:fade={{ duration: 200, delay: 100 }}
|
in:fade|global={{ duration: 200 }}
|
||||||
out:fade={{ duration: 150 }}
|
|
||||||
>
|
>
|
||||||
<Button href={`/board?loc=${station.c}`}
|
<Button href={`/board?loc=${station.c}`}
|
||||||
><span class="stn-name">{station.n}</span></Button
|
><span class="stn-name">{station.n}</span></Button
|
||||||
@@ -37,8 +36,13 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.card-content {
|
.card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
|
min-height: 98px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
padding: 10px 0 10px 0;
|
padding: 10px 0 10px 0;
|
||||||
}
|
}
|
||||||
@@ -46,7 +50,7 @@
|
|||||||
.stations-flex {
|
.stations-flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.75rem;
|
gap: 0.1rem 0.5rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
@@ -55,6 +55,7 @@
|
|||||||
/* Shadows */
|
/* Shadows */
|
||||||
--color-shadow: hsla(210, 20%, 5%, 0.35);
|
--color-shadow: hsla(210, 20%, 5%, 0.35);
|
||||||
--shadow-std: 0 4px 12px var(--color-shadow);
|
--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-up: 0 -4px 12px var(--color-shadow);
|
||||||
--shadow-right: 4px 0 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 { LOCATIONS } from '$lib/locations-object.svelte';
|
||||||
import { nearestStationsState } from '$lib/geohash.svelte';
|
import { nearestStationsState } from '$lib/geohash.svelte';
|
||||||
|
|
||||||
|
import TimezoneWarning from '$lib/components/ui/TimezoneWarning.svelte';
|
||||||
|
|
||||||
import '$lib/global.css';
|
import '$lib/global.css';
|
||||||
|
|
||||||
import logoText from '$lib/assets/round-logo-text.svg';
|
import logoText from '$lib/assets/round-logo-text.svg';
|
||||||
@@ -78,6 +80,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
<TimezoneWarning />
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import LocationBoardCard from '$lib/components/ui/cards/LocationBoardCard.svelte';
|
import LocationBoardCard from '$lib/components/ui/cards/LocationBoardCard.svelte';
|
||||||
import NearbyStationsCard from '$lib/components/ui/cards/NearbyStationsCard.svelte';
|
import NearbyStationsCard from '$lib/components/ui/cards/NearbyStationsCard.svelte';
|
||||||
|
import QuickLinksCard from '$lib/components/ui/cards/QuickLinksCard.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card-container">
|
<div class="card-container">
|
||||||
<LocationBoardCard />
|
<LocationBoardCard />
|
||||||
<NearbyStationsCard />
|
<NearbyStationsCard />
|
||||||
|
<QuickLinksCard />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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>
|
<section>Live board are not yet implemented on the server</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ export const load: PageLoad = async ({ url }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (locId) {
|
let BoardLocation;
|
||||||
const location = LOCATIONS.find(locId);
|
|
||||||
|
|
||||||
if (location) {
|
if (locId) {
|
||||||
title = location.n || location.t;
|
BoardLocation = LOCATIONS.find(locId);
|
||||||
|
|
||||||
|
if (BoardLocation) {
|
||||||
|
title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep';
|
||||||
} else {
|
} else {
|
||||||
error(404, {
|
error(404, {
|
||||||
message: `Location (${locId.toUpperCase()}) not found`,
|
message: `Location (${locId.toUpperCase()}) not found`,
|
||||||
@@ -32,6 +34,6 @@ export const load: PageLoad = async ({ url }) => {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
location
|
BoardLocation
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user