Add LocationSearchCard and add to homepage for testing.

Run `npm run format`
This commit is contained in:
2026-03-16 20:31:28 +00:00
parent f5c3775f59
commit 35877ae8ac
11 changed files with 427 additions and 283 deletions

View File

@@ -3,7 +3,7 @@ run-name: ${{ gitea.actor }} is building and pushing
on: on:
create: create:
tags: "*" tags: '*'
env: env:
GITEA_DOMAIN: git.fjla.uk GITEA_DOMAIN: git.fjla.uk

View File

@@ -1,112 +1,221 @@
<script lang="ts"> <script lang="ts">
import Textbox from '$lib/components/ui/Textbox.svelte'; import Textbox from '$lib/components/ui/Textbox.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { goto } from '$app/navigation';
interface LocationRecord { interface LocationRecord {
n: string; // name n: string; // name
t: string; // tiploc t: string; // tiploc
c?: string; // crs c?: string; // crs
s: string; // search string s: string; // search string
} }
let value = $state(""); let { value = $bindable() } = $props();
let results = $state<LocationRecord[]>([]);
let locations: LocationRecord[] = [];
let showResults = $state(false); let results = $state<LocationRecord[]>([]);
let selectedIndex = $state(-1); let locations: LocationRecord[] = [];
const MAX_RESULTS = 10; let showResults = $state(false);
let selectedIndex = $state(-1);
async function loadLocations() { const MAX_RESULTS = 5;
const res = await fetch("/api/tiplocs");
locations = await res.json();
}
onMount(loadLocations); async function loadLocations() {
const res = await fetch('/api/tiplocs');
locations = await res.json();
}
function tokenize(query: string) { onMount(loadLocations);
return query
.toLowerCase()
.trim()
.split(/\s+/)
.filter(Boolean);
}
function search(query: string) { function tokenize(query: string) {
if (query.length < 3) { return query.toLowerCase().trim().split(/\s+/).filter(Boolean);
results = []; }
return;
}
const tokens = tokenize(query); function search(query: string) {
if (query.length < 3) {
results = [];
return;
}
results = locations const tokens = tokenize(query);
.filter(r => tokens.every(t => r.s.includes(t))) const lowerQuery = query.toLowerCase().trim();
.slice(0, MAX_RESULTS);
}
$effect(() => { results = locations
search(value); .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;
function choose(loc: LocationRecord) { // Sort matching CRS first
value = loc.n; if (aIsCrs && !bIsCrs) return -1;
showResults = false; if (!aIsCrs && bIsCrs) return 1;
selectedIndex = -1;
console.log("Selected Location: ", JSON.stringify(loc)) // Alphabetical Sort
} return a.n.localeCompare(b.n);
})
.slice(0, MAX_RESULTS);
}
function handleKey(e: KeyboardEvent) { $effect(() => {
if (!results.length) return; search(value);
});
if (e.key === "ArrowDown") { // Hide results when click outside of container
e.preventDefault(); $effect(() => {
selectedIndex = Math.min(selectedIndex + 1, results.length - 1); if (showResults) {
} const onClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.location-search')) {
showResults = false;
}
};
if (e.key === "ArrowUp") { document.addEventListener('click', onClick);
e.preventDefault(); return () => document.removeEventListener('click', onClick);
selectedIndex = Math.max(selectedIndex - 1, 0); }
} });
if (e.key === "Enter" && selectedIndex >= 0) { function choose(loc: LocationRecord) {
choose(results[selectedIndex]); showResults = false;
} selectedIndex = -1;
} value = '';
console.log('Selected Location: ', JSON.stringify(loc));
goto(`/board?stn=${loc.c.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> </script>
<div class="location-search"> <div class="location-search">
<Textbox <Textbox
bind:value bind:value
placeholder="Enter Location" placeholder="Enter Location"
oninput={() => showResults = true} oninput={() => (showResults = true)}
onkeydown={handleKey} onkeydown={handleKey}
capital capital
/> />
{#if showResults && results.length} {#if showResults && results.length}
<ul class="suggestions"> <ul
{#each results as loc, i} id="location-results"
<li popover={showResults && results.length ? 'manual' : null}
class:selected={i === selectedIndex} role="listbox"
onclick={() => choose(loc)} class="suggestions"
> transition:fade={{ duration: 200 }}
<span class="name">{loc.n}</span> >
{#each results as loc, i}
{#if loc.c} <li class="result-item" class:selected={i === selectedIndex} onclick={() => choose(loc)}>
<span class="crs">{loc.c}</span> <div class="crs-badge-container">
{/if} {#if loc.c}
</li> <span class="crs-badge">{loc.c}</span>
{/each} {/if}
</ul> </div>
{/if} <div class="details">
<span class="name">{loc.n || loc.t}</span>
<span class="tiploc">{loc.t}</span>
</div>
</li>
{/each}
</ul>
{/if}
</div> </div>
<style> <style>
.suggestions { .location-search {
background-color: var(--color-title); position: relative;
color: var(--color-bg-dark); 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> </style>

View File

@@ -1,94 +1,94 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { HTMLInputAttributes } from 'svelte/elements'; import type { HTMLInputAttributes } from 'svelte/elements';
interface Props extends HTMLInputAttributes { interface Props extends HTMLInputAttributes {
value?: string; value?: string;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url'; type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url';
error?: string; error?: string;
uppercase?: boolean; uppercase?: boolean;
[key: string]: any; [key: string]: any;
} }
let { let {
value = $bindable(''), value = $bindable(''),
label, label,
placeholder = '', placeholder = '',
type = 'text', type = 'text',
error = '', error = '',
uppercase = false, uppercase = false,
...rest ...rest
}: Props = $props(); }: Props = $props();
let isFocussed = $state(false); let isFocussed = $state(false);
</script> </script>
<div class="input-wrapper" class:focussed={isFocussed} class:has-error={!!error}> <div class="input-wrapper" class:focussed={isFocussed} class:has-error={!!error}>
{#if label} {#if label}
<label for="adaptive-input">{label}</label> <label for="adaptive-input">{label}</label>
{/if} {/if}
<input <input
id="adaptive-input" id="adaptive-input"
class:all-caps={uppercase} class:all-caps={uppercase}
{type} {type}
{placeholder} {placeholder}
bind:value={value} bind:value
onfocus={() => isFocussed = true} onfocus={() => (isFocussed = true)}
onblur={() => isFocussed = false} onblur={() => (isFocussed = false)}
{...rest} {...rest}
/> />
{#if error} {#if error}
<span class="error-message" transition:fade>{error}</span> <span class="error-message" transition:fade>{error}</span>
{/if} {/if}
</div> </div>
<style> <style>
.input-wrapper { .input-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
width: 100%; width: 100%;
font-family: 'URW Gothic', sans-serif; font-family: 'URW Gothic', sans-serif;
} }
label { label {
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 400; font-weight: 400;
color: var(--color-title) color: var(--color-title);
} }
input { input {
min-height: 48px; min-height: 40px;
padding: 0 16px; padding: 0 16px;
background-color: var(--color-title); background-color: var(--color-title);
border: 2px solid transparent; border: 2px solid transparent;
border-radius: 20px; border-radius: 20px;
color: var(--color-bg-dark); color: var(--color-bg-dark);
font-size: 1.5rem; font-size: 1.2rem;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
outline: none; outline: none;
text-align: center; text-align: center;
} }
.all-caps { .all-caps {
text-transform: uppercase; text-transform: uppercase;
} }
.focussed input { .focussed input {
border-color: var(--color-bg-light); border-color: var(--color-bg-light);
} }
.has-error input { .has-error input {
border-color: #ff4d4d; border-color: #ff4d4d;
} }
.error-message { .error-message {
color: #ff4d4d; color: #ff4d4d;
font-size: 1rem; font-size: 1rem;
text-align: center; text-align: center;
} }
</style> </style>

View File

@@ -1,94 +1,101 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { IconHelpCircle } from '@tabler/icons-svelte'; import { IconHelpCircle } from '@tabler/icons-svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
interface Props { interface Props {
children: Snippet; children: Snippet;
header?: string; header?: string;
helpText?: string; helpText?: string;
} }
let { let { children, header = '', helpText }: Props = $props();
children,
header = "",
helpText,
}: Props = $props();
let showHelp = $state(false); let showHelp = $state(false);
</script> </script>
<div class="card"> <div class="card">
{#if header || helpText} {#if header || helpText}
<header class="card-header"> <header class="card-header">
<div class="header-content"> <div class="header-content">
{header} {header}
</div> </div>
{#if helpText} {#if helpText}
<button <button
type="button" type="button"
class="help-toggle" class="help-toggle"
onclick={() => showHelp = !showHelp} onclick={() => (showHelp = !showHelp)}
aria-label="Show Help" aria-label="Show Help"
> >
<IconHelpCircle size={26} stroke={2.25} color={showHelp ? 'var(--color-brand)' : 'var(--color-title)'} /> <IconHelpCircle
</button> size={26}
{/if} stroke={2.25}
</header> color={showHelp ? 'var(--color-brand)' : 'var(--color-title)'}
/>
</button>
{/if}
</header>
{#if showHelp && helpText} {#if showHelp && helpText}
<div class="help-drawer" transition:slide={{ duration: 400 }}> <div class="help-drawer" transition:slide={{ duration: 400 }}>
<p>{helpText}</p> <p>{helpText}</p>
</div> </div>
{/if} {/if}
{/if} {/if}
<div class="card-body"> <div class="card-body">
{@render children?.()} {@render children?.()}
</div> </div>
</div> </div>
<style> <style>
.card { .card {
background: var(--color-accent); background: var(--color-accent);
position: relative; position: relative;
border-radius: 20px; border-radius: 20px;
overflow: hidden; overflow: visible;
width: 95%; width: 95%;
max-width: 600px; max-width: 600px;
text-align: center; text-align: center;
font-family: 'URW Gothic', sans-serif; font-family: 'URW Gothic', sans-serif;
color: var(--color-title); color: var(--color-brand);
} padding: 10px 0;
}
.header-content { flex: 1; .header-content {
font-size: 1.5rem; font-weight: 600; } flex: 1;
margin: 0;
font-size: 1.3rem;
font-weight: 600;
}
.help-toggle { .help-toggle {
position: absolute; position: absolute;
top: 8px; top: 8px;
right: 8px; right: 8px;
background: none; background: none;
border: none; border: none;
padding: 4px; padding: 4px;
cursor: help; cursor: help;
opacity: 0.6; opacity: 0.6;
z-index: 2; z-index: 2;
transition: opacity 0.2s, transform 0.2s; transition:
} opacity 0.2s,
transform 0.2s;
}
.help-toggle:hover { .help-toggle:hover {
opacity: 1; opacity: 1;
transform: scale(1.1); transform: scale(1.1);
} }
.help-drawer { .help-drawer {
background-color: var(--color-accent); background-color: var(--color-accent);
padding: 4px 16px; padding: 4px 16px;
font-size: 0.95rem; font-size: 0.95rem;
line-height: 1.2; line-height: 1.2;
margin: auto; margin: auto;
border-bottom: 1px solid rgba(255,255,255,0.05); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
color: var(--color-title); color: var(--color-title);
} }
</style> </style>

View 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>

View File

@@ -58,7 +58,7 @@
font-size: 1.1rem; font-size: 1.1rem;
color: var(--color-title); color: var(--color-title);
max-width: 300px; max-width: 300px;
margin-top: 5px; margin-top: 5px;
margin-bottom: 30px; margin-bottom: 30px;
} }

View File

@@ -34,7 +34,7 @@
if (navWidth === 0) return navItems.length; if (navWidth === 0) return navItems.length;
const available = navWidth; const available = navWidth;
const totalItems = navItems.length; const totalItems = navItems.length;
const countWithoutMore = Math.floor(available/ ITEM_WIDTH); const countWithoutMore = Math.floor(available / ITEM_WIDTH);
if (countWithoutMore >= totalItems) return totalItems; if (countWithoutMore >= totalItems) return totalItems;
@@ -128,10 +128,11 @@
</nav> </nav>
<div class="viewport-guard"> <div class="viewport-guard">
<img src={logoPlain} alt="OwlBoard Logo" width=100 height=100> <img src={logoPlain} alt="OwlBoard Logo" width="100" height="100" />
<h1 class="viewport-guard-title">Narrow Gauge Detected</h1> <h1 class="viewport-guard-title">Narrow Gauge Detected</h1>
<p> <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. 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> </p>
</div> </div>
@@ -193,7 +194,8 @@
box-shadow: var(--shadow-up); box-shadow: var(--shadow-up);
} }
.nav-item, .more-menu-wrapper { .nav-item,
.more-menu-wrapper {
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
@@ -318,7 +320,9 @@
padding-top: 30px; padding-top: 30px;
} }
header, main, nav { header,
main,
nav {
display: none; display: none;
} }
} }

View File

@@ -1,22 +1,18 @@
<script lang="ts"> <script lang="ts">
import Button from '$lib/components/ui/Button.svelte'; import LocationBoardCard from '$lib/components/ui/cards/LocationBoardCard.svelte';
import LocationSearchBox from '$lib/components/ui/LocationSearchBox.svelte';
import Textbox from '$lib/components/ui/Textbox.svelte';
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
function test() {
console.log('Button Clicked');
}
</script> </script>
<br /><br /><br /> <div class="card-container">
<Button>Default</Button> <LocationBoardCard />
<Button color={'brand'} onclick={test}>Brand</Button> </div>
<Button color={'accent'}>Accent</Button>
<Textbox placeholder={"Textbox am I"} uppercase={true} error={""} />
<BaseCard header={"Hello"} helpText={"This is help text"}>Hello</BaseCard> <style>
.card-container {
<LocationSearchBox /> display: flex;
align-items: center;
<h2>OwlBoard</h2> flex-direction: column;
gap: 20px;
justify-content: center;
padding: 20px 10px;
}
</style>

View File

@@ -29,7 +29,10 @@
daily basis. daily basis.
</p> </p>
<p class="amble"> <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. 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>
<p class="opensource"> <p class="opensource">
Some components that combine to form OwlBoard are open-source, see the <a Some components that combine to form OwlBoard are open-source, see the <a

View File

@@ -2,19 +2,19 @@ import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
kit: { kit: {
adapter: adapter({ adapter: adapter({
pages: 'build', pages: 'build',
assets: 'build', assets: 'build',
fallback: 'index.html', fallback: 'index.html',
precompress: 'true', precompress: 'true',
strict: 'true' strict: 'true'
}), }),
prerender: { prerender: {
// Temporary option during testing // Temporary option during testing
handleHttpError: 'ignore' handleHttpError: 'ignore'
} }
} }
}; };
export default config; export default config;