adds trails page

This commit is contained in:
Christian Beutel
2024-02-04 22:48:04 +01:00
parent c370690747
commit ec33723bdc
19 changed files with 799 additions and 30 deletions

6
web/package-lock.json generated
View File

@@ -16,6 +16,7 @@
"leaflet-gpx": "^1.7.0",
"leaflet.awesome-markers": "^2.0.5",
"meilisearch": "^0.37.0",
"nouislider": "^15.7.1",
"photoswipe": "^5.4.3",
"pocketbase": "^0.21.0",
"yup": "^1.3.3"
@@ -2247,6 +2248,11 @@
"node": ">=0.10.0"
}
},
"node_modules/nouislider": {
"version": "15.7.1",
"resolved": "https://registry.npmjs.org/nouislider/-/nouislider-15.7.1.tgz",
"integrity": "sha512-5N7C1ru/i8y3dg9+Z6ilj6+m1EfabvOoaRa7ztpxBSKKRZso4vA52DGSbBJjw5XLtFr/LZ9SgGAXqyVtlVHO5w=="
},
"node_modules/npm-run-path": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz",

View File

@@ -39,6 +39,7 @@
"leaflet-gpx": "^1.7.0",
"leaflet.awesome-markers": "^2.0.5",
"meilisearch": "^0.37.0",
"nouislider": "^15.7.1",
"photoswipe": "^5.4.3",
"pocketbase": "^0.21.0",
"yup": "^1.3.3"

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import * as noUiSlider from "noUiSlider";
import "nouislider/dist/nouislider.css";
import { onMount } from "svelte";
export let minValue = 0;
export let maxValue = 100;
export let currentMin = minValue;
export let currentMax = maxValue;
let sliderContainer: any;
onMount(() => {
const updateValues = (values: string[]) => {
currentMin = parseFloat(values[0]);
currentMax = parseFloat(values[1]);
};
noUiSlider.create(sliderContainer, {
start: [currentMin, currentMax],
connect: true,
range: {
min: minValue,
max: maxValue,
},
});
sliderContainer.noUiSlider.on("update", updateValues);
});
</script>
<div class="my-4" id="slider" bind:this={sliderContainer}></div>
<style>
:global(.noUi-horizontal) {
height: 6px;
}
:global(.noUi-connect) {
@apply bg-primary;
}
:global(.noUi-horizontal .noUi-handle) {
@apply rounded-full w-7 h-7 -top-3;
}
:global(.noUi-handle::before) {
@apply content-none;
}
:global(.noUi-handle::after) {
@apply content-none;
}
</style>

View File

@@ -30,7 +30,7 @@
<button class="flex items-center justify-center" on:click={toggleMenu} type="button">
<slot>
<i
class="fa fa-ellipsis-vertical text-{size} hover:bg-gray-300 px-[14px] py-2 rounded-full hover:bg-opacity-50"
class="fa fa-ellipsis-vertical text-{size} hover:bg-gray-300 hover:bg-opacity-50 px-[14px] py-2 rounded-full "
></i>
</slot>
</button>

View File

@@ -0,0 +1,41 @@
<script context="module" lang="ts">
export type RadioItem = {
text: string;
value: string;
icon?: string;
};
</script>
<script lang="ts">
import { createEventDispatcher } from "svelte";
export let items: RadioItem[];
export let name: string;
export let selected: number = 0;
const dispatch = createEventDispatcher();
function handleRadioChange(radioIndex: number) {
selected = radioIndex;
dispatch('change', items[selected])
}
</script>
{#each items as item, i}
<div class="flex items-center mb-4">
<input
id="{name}-radio-{i}"
name="{name}-radio"
type="radio"
checked={i == selected}
value={item.value}
class="w-4 h-4 text-primary bg-gray-100 border-gray-300 focus:ring-gray-400 focus:ring-2"
on:change={() => handleRadioChange(i)}
/>
<label
for="{name}-radio-{i}"
class="ms-2 text-sm text-gray-900 dark:text-gray-300"
>{item.text}</label
>
</div>
{/each}

View File

@@ -0,0 +1,110 @@
<script context="module" lang="ts">
export type SearchItem = {
text: string;
description?: string;
value: any;
icon: string;
};
</script>
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { fade } from "svelte/transition";
import TextField from "./text_field.svelte";
export let maxSearchLength: number = 5;
export let value: string = "";
export let items: SearchItem[] = [];
export let placeholder: string = "Search...";
export let large: boolean = false;
const dispatch = createEventDispatcher();
let lastSearch: string = "";
let searching: boolean = false;
let typingTimer!: any;
$: dropDownOpen = value.length > 0 && items.length > 0 && searching;
function onSearchType() {
clearTimeout(typingTimer);
if (Math.abs(value.length - lastSearch.length) > maxSearchLength) {
update(value);
return;
}
typingTimer = setTimeout(() => {
update(value);
}, 500);
}
function update(q: string) {
lastSearch = q;
dispatch("update", q);
}
function handleItemClick(item: SearchItem) {
searching = false;
dispatch("click", item);
}
function clear() {
value = "";
update(value);
}
</script>
<div class="relative text-gray-600">
<span class="absolute top-1/2 -translate-y-1/2 left-0 pl-4">
<i class="fa fa-search" class:text-2xl={large}></i>
</span>
{#if value.length > 0}
<button
class="absolute top-1/2 -translate-y-1/2 right-0 h-6 w-6 mr-4 hover:bg-gray-300 hover:bg-opacity-50 rounded-full"
on:click={clear}
in:fade={{ duration: 150 }}
out:fade={{ duration: 150 }}
>
<i class="fa fa-close text-sm"></i>
</button>
{/if}
<TextField
type="search"
name="q"
autocomplete="off"
extraClasses="{large ? 'pl-14 text-2xl min-w-80 w-[33vw] max-w-[532px]' : 'pl-10'}"
{placeholder}
bind:value
on:input={onSearchType}
on:focusin={() => (searching = true)}
></TextField>
{#if dropDownOpen}
<ul
class="menu absolute bg-white border rounded-xl shadow-md overflow-hidden text-black w-full"
class:none={!dropDownOpen}
style="z-index: 1001"
>
{#each items as item}
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<li
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-gray-100 focus:bg-gray-200 transition-colors"
tabindex="0"
on:mouseup|stopPropagation={() => handleItemClick(item)}
on:keydown|stopPropagation={() => handleItemClick(item)}
>
<i class="fa fa-{item.icon} mr-6"></i>
<div>
<p>{item.text}</p>
{#if item.description}
<p class="text-sm text-gray-500">
{item.description}
</p>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</div>

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import noUiSlider from "noUiSlider";
import "nouislider/dist/nouislider.css";
import { onMount } from "svelte";
export let minValue = 0;
export let maxValue = 100;
export let currentValue = maxValue / 2;
let sliderContainer: any;
onMount(() => {
const updateValues = (values: string[]) => {
currentValue = parseFloat(values[0]);
};
noUiSlider.create(sliderContainer, {
start: currentValue,
connect: [true, false],
range: {
min: minValue,
max: maxValue,
},
});
sliderContainer.noUiSlider.on("update", updateValues);
});
</script>
<div class="my-4" id="slider" bind:this={sliderContainer}></div>
<style>
:global(.noUi-horizontal) {
height: 6px;
}
:global(.noUi-connect) {
@apply bg-primary;
}
:global(.noUi-horizontal .noUi-handle) {
@apply rounded-full w-7 h-7 -top-3;
}
:global(.noUi-handle::before) {
@apply content-none;
}
:global(.noUi-handle::after) {
@apply content-none;
}
</style>

View File

@@ -6,8 +6,8 @@
export let error: string = "";
export let icon: string = "";
export let extraClasses: string = "";
export let type: "text" | "password" = "text";
export let type: "text" | "password" | "search" = "text";
export let autocomplete: "on" | "off" = "on"
function typeAction(node: HTMLInputElement) {
node.type = type;
}
@@ -28,9 +28,13 @@
class="bg-gray-50 border rounded-md p-3 transition-colors focus:border-primary focus:outline-none focus:ring-0 w-full {extraClasses}"
class:border-red-400={error.length > 0}
class:bg-red-50={error.length > 0}
{autocomplete}
use:typeAction
bind:value
on:change
on:input
on:focusin
on:focusout
{placeholder}
/>
</div>

View File

@@ -21,7 +21,7 @@
<a href="/"><LogoText></LogoText></a>
{#if $currentUser}
<menu class="flex gap-8">
<a class="font-semibold" href="">Trails</a>
<a class="font-semibold" href="/trails">Trails</a>
<a class="font-semibold" href="">Map</a>
<a class="font-semibold" href="">Categories</a>
<a class="font-semibold" href="">Favorites</a>

View File

@@ -5,6 +5,7 @@
import Dropdown from "../base/dropdown.svelte";
export let trail: Trail;
export let mode: "show" | "edit" = "show";
const dropdownItems = [
{ text: "Edit", value: "edit" },
@@ -12,15 +13,15 @@
];
</script>
<div class="trail-card rounded-2xl shadow-md w-72 cursor-pointer">
<div class="w-full h-48 overflow-hidden rounded-t-2xl">
<div class="trail-card rounded-2xl shadow-md sm:w-72 cursor-pointer">
<div class="w-full min-h-40 max-h-48 overflow-hidden rounded-t-2xl">
<img src={trail.thumbnail} alt="" />
</div>
<div class="p-4">
<div>
<div class="flex justify-between items-center">
<h4 class="font-semibold text-lg">{trail.name}</h4>
{#if $currentUser && $currentUser.id == trail.author}
{#if $currentUser && $currentUser.id == trail.author && mode == "edit"}
<Dropdown on:change items={dropdownItems}></Dropdown>
{/if}
</div>

View File

@@ -85,4 +85,22 @@ const trailSchema = object<SummitLog>({
description: string().optional()
});
export { Trail, trailSchema };
interface TrailFilter {
q: string,
category: string[],
near: {
lat?: number,
lon?: number,
distance: number
}
distanceMin: number,
distanceMax: number,
eleavationGainMin: number;
elevationGainMax: number;
completed?: boolean;
}
export { Trail, trailSchema };
export type { TrailFilter };

View File

@@ -90,7 +90,7 @@ export async function trails_create(trail: Trail, formData: { [key: string]: any
model = await pb
.collection("trails")
.update<Trail>(model.id!, { thumbnail: thumbnail });
.update<Trail>(model.id!, { thumbnail: thumbnail }, { expand: "category" });
index_trail(model);
@@ -190,6 +190,8 @@ export async function trails_delete(trail: Trail) {
}
}
ms.index('trails').deleteDocument(trail.id!)
const success = await pb
.collection("trails")
.delete(trail.id!);

View File

@@ -0,0 +1,247 @@
export const country_codes = {
AF: 'Afghanistan',
AX: 'Aland Islands',
AL: 'Albania',
DZ: 'Algeria',
AS: 'American Samoa',
AD: 'Andorra',
AO: 'Angola',
AI: 'Anguilla',
AQ: 'Antarctica',
AG: 'Antigua And Barbuda',
AR: 'Argentina',
AM: 'Armenia',
AW: 'Aruba',
AU: 'Australia',
AT: 'Austria',
AZ: 'Azerbaijan',
BS: 'Bahamas',
BH: 'Bahrain',
BD: 'Bangladesh',
BB: 'Barbados',
BY: 'Belarus',
BE: 'Belgium',
BZ: 'Belize',
BJ: 'Benin',
BM: 'Bermuda',
BT: 'Bhutan',
BO: 'Bolivia',
BA: 'Bosnia And Herzegovina',
BW: 'Botswana',
BV: 'Bouvet Island',
BR: 'Brazil',
IO: 'British Indian Ocean Territory',
BN: 'Brunei Darussalam',
BG: 'Bulgaria',
BF: 'Burkina Faso',
BI: 'Burundi',
KH: 'Cambodia',
CM: 'Cameroon',
CA: 'Canada',
CV: 'Cape Verde',
KY: 'Cayman Islands',
CF: 'Central African Republic',
TD: 'Chad',
CL: 'Chile',
CN: 'China',
CX: 'Christmas Island',
CC: 'Cocos (Keeling) Islands',
CO: 'Colombia',
KM: 'Comoros',
CG: 'Congo',
CD: 'Congo, Democratic Republic',
CK: 'Cook Islands',
CR: 'Costa Rica',
CI: 'Cote D\'Ivoire',
HR: 'Croatia',
CU: 'Cuba',
CY: 'Cyprus',
CZ: 'Czech Republic',
DK: 'Denmark',
DJ: 'Djibouti',
DM: 'Dominica',
DO: 'Dominican Republic',
EC: 'Ecuador',
EG: 'Egypt',
SV: 'El Salvador',
GQ: 'Equatorial Guinea',
ER: 'Eritrea',
EE: 'Estonia',
ET: 'Ethiopia',
FK: 'Falkland Islands (Malvinas)',
FO: 'Faroe Islands',
FJ: 'Fiji',
FI: 'Finland',
FR: 'France',
GF: 'French Guiana',
PF: 'French Polynesia',
TF: 'French Southern Territories',
GA: 'Gabon',
GM: 'Gambia',
GE: 'Georgia',
DE: 'Germany',
GH: 'Ghana',
GI: 'Gibraltar',
GR: 'Greece',
GL: 'Greenland',
GD: 'Grenada',
GP: 'Guadeloupe',
GU: 'Guam',
GT: 'Guatemala',
GG: 'Guernsey',
GN: 'Guinea',
GW: 'Guinea-Bissau',
GY: 'Guyana',
HT: 'Haiti',
HM: 'Heard Island & Mcdonald Islands',
VA: 'Holy See (Vatican City State)',
HN: 'Honduras',
HK: 'Hong Kong',
HU: 'Hungary',
IS: 'Iceland',
IN: 'India',
ID: 'Indonesia',
IR: 'Iran, Islamic Republic Of',
IQ: 'Iraq',
IE: 'Ireland',
IM: 'Isle Of Man',
IL: 'Israel',
IT: 'Italy',
JM: 'Jamaica',
JP: 'Japan',
JE: 'Jersey',
JO: 'Jordan',
KZ: 'Kazakhstan',
KE: 'Kenya',
KI: 'Kiribati',
KR: 'Korea',
KW: 'Kuwait',
KG: 'Kyrgyzstan',
LA: 'Lao People\'s Democratic Republic',
LV: 'Latvia',
LB: 'Lebanon',
LS: 'Lesotho',
LR: 'Liberia',
LY: 'Libyan Arab Jamahiriya',
LI: 'Liechtenstein',
LT: 'Lithuania',
LU: 'Luxembourg',
MO: 'Macao',
MK: 'Macedonia',
MG: 'Madagascar',
MW: 'Malawi',
MY: 'Malaysia',
MV: 'Maldives',
ML: 'Mali',
MT: 'Malta',
MH: 'Marshall Islands',
MQ: 'Martinique',
MR: 'Mauritania',
MU: 'Mauritius',
YT: 'Mayotte',
MX: 'Mexico',
FM: 'Micronesia, Federated States Of',
MD: 'Moldova',
MC: 'Monaco',
MN: 'Mongolia',
ME: 'Montenegro',
MS: 'Montserrat',
MA: 'Morocco',
MZ: 'Mozambique',
MM: 'Myanmar',
NA: 'Namibia',
NR: 'Nauru',
NP: 'Nepal',
NL: 'Netherlands',
AN: 'Netherlands Antilles',
NC: 'New Caledonia',
NZ: 'New Zealand',
NI: 'Nicaragua',
NE: 'Niger',
NG: 'Nigeria',
NU: 'Niue',
NF: 'Norfolk Island',
MP: 'Northern Mariana Islands',
NO: 'Norway',
OM: 'Oman',
PK: 'Pakistan',
PW: 'Palau',
PS: 'Palestinian Territory, Occupied',
PA: 'Panama',
PG: 'Papua New Guinea',
PY: 'Paraguay',
PE: 'Peru',
PH: 'Philippines',
PN: 'Pitcairn',
PL: 'Poland',
PT: 'Portugal',
PR: 'Puerto Rico',
QA: 'Qatar',
RE: 'Reunion',
RO: 'Romania',
RU: 'Russian Federation',
RW: 'Rwanda',
BL: 'Saint Barthelemy',
SH: 'Saint Helena',
KN: 'Saint Kitts And Nevis',
LC: 'Saint Lucia',
MF: 'Saint Martin',
PM: 'Saint Pierre And Miquelon',
VC: 'Saint Vincent And Grenadines',
WS: 'Samoa',
SM: 'San Marino',
ST: 'Sao Tome And Principe',
SA: 'Saudi Arabia',
SN: 'Senegal',
RS: 'Serbia',
SC: 'Seychelles',
SL: 'Sierra Leone',
SG: 'Singapore',
SK: 'Slovakia',
SI: 'Slovenia',
SB: 'Solomon Islands',
SO: 'Somalia',
ZA: 'South Africa',
GS: 'South Georgia And Sandwich Isl.',
ES: 'Spain',
LK: 'Sri Lanka',
SD: 'Sudan',
SR: 'Suriname',
SJ: 'Svalbard And Jan Mayen',
SZ: 'Swaziland',
SE: 'Sweden',
CH: 'Switzerland',
SY: 'Syrian Arab Republic',
TW: 'Taiwan',
TJ: 'Tajikistan',
TZ: 'Tanzania',
TH: 'Thailand',
TL: 'Timor-Leste',
TG: 'Togo',
TK: 'Tokelau',
TO: 'Tonga',
TT: 'Trinidad And Tobago',
TN: 'Tunisia',
TR: 'Turkey',
TM: 'Turkmenistan',
TC: 'Turks And Caicos Islands',
TV: 'Tuvalu',
UG: 'Uganda',
UA: 'Ukraine',
AE: 'United Arab Emirates',
GB: 'United Kingdom',
US: 'United States',
UM: 'United States Outlying Islands',
UY: 'Uruguay',
UZ: 'Uzbekistan',
VU: 'Vanuatu',
VE: 'Venezuela',
VN: 'Viet Nam',
VG: 'Virgin Islands, British',
VI: 'Virgin Islands, U.S.',
WF: 'Wallis And Futuna',
EH: 'Western Sahara',
YE: 'Yemen',
ZM: 'Zambia',
ZW: 'Zimbabwe'
}

View File

@@ -11,7 +11,7 @@ export function formatTimeHHMM(minutes?: number) {
}
export function formatMeters(meters?: number) {
if (!meters) {
if (meters === undefined) {
return "-";
}
if (meters % 1 === 0) {

View File

@@ -1,17 +1,22 @@
<script lang="ts">
import { goto } from "$app/navigation";
import Search, {
type SearchItem,
} from "$lib/components/base/search.svelte";
import CategoryCard from "$lib/components/category_card.svelte";
import TrailCard from "$lib/components/trail/trail_card.svelte";
import { ms } from "$lib/meilisearch";
import type { Trail } from "$lib/models/trail";
import { categories } from "$lib/stores/category_store";
import { summit_logs_delete } from "$lib/stores/summit_log_store";
import {
trails,
trails_delete,
trails_index,
} from "$lib/stores/trail_store";
import { currentUser } from "$lib/stores/user_store";
import { waypoints_delete } from "$lib/stores/waypoint_store";
import { country_codes } from "$lib/util/country_code_util";
let searchDropdownItems: SearchItem[] = [];
async function handleDropdownClick(
currentTrail: Trail,
@@ -24,24 +29,61 @@
await trails_index();
}
}
async function search(q: string) {
const response = await ms.multiSearch({
queries: [
{
indexUid: "trails",
q: q,
limit: 5,
},
{
indexUid: "cities500",
q: q,
limit: 5,
},
],
});
const trailItems = response.results[0].hits.map((t) => ({
text: t.name,
description: `Trail | ${t.location}`,
value: t.id,
icon: "route",
}));
const cityItems = response.results[1].hits.map((t) => ({
text: t.name,
description: `City | ${
country_codes[t["country code"] as keyof typeof country_codes]
}`,
value: t.id,
icon: "city",
}));
searchDropdownItems = [...trailItems, ...cityItems];
}
function handleSearchClick(item: SearchItem) {
if (item.icon == "route") {
goto(`/trail/view/${item.value}`);
}
}
</script>
<section class="hero flex justify-center items-center" style="height: 50vh">
<div class="relative text-gray-600 mx-4">
<span class="absolute inset-y-0 left-0 flex items-center pl-4">
<i class="fa fa-search text-2xl"></i>
</span>
<input
type="search"
name="q"
class="w-80 sm:w-96 md:w-[32rem] py-4 rounded-2xl pl-14 focus:outline-none text-xl text-primary "
placeholder="Search trails..."
autocomplete="off"
/>
</div>
<Search
on:update={(e) => search(e.detail)}
on:click={(e) => handleSearchClick(e.detail)}
large={true}
placeholder="Search trails..."
items={searchDropdownItems}
></Search>
</section>
<section class="max-w-7xl mx-auto mt-8 px-8 xl:px-0">
<h2 class="text-5xl md:text-6xl font-bold text-primary">{$currentUser ? 'Your' : 'Explore'} trails</h2>
<h2 class="text-5xl md:text-6xl font-bold text-primary">
{$currentUser ? "Your" : "Explore"} trails
</h2>
<div
id="trails"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 justify-items-center gap-8 py-8"
@@ -50,6 +92,7 @@
<a href="/trail/view/{trail.id}">
<TrailCard
{trail}
mode="edit"
on:change={(e) => handleDropdownClick(trail, e.detail)}
></TrailCard></a
>

View File

@@ -194,11 +194,13 @@
reader.onload = async function (e) {
trail.set(new Trail(""));
await addGPXLayer(e.target?.result as string);
console.log(await
ms.index("cities500").search("", {
filter: [`_geoRadius(${$form.lat}, ${$form.lon}, 100000000000)`],
}),
);
const closestCity = (await ms.index("cities500").search("", {
filter: [`_geoRadius(${$form.lat}, ${$form.lon}, 10000)`],
sort: [`_geoPoint(${$form.lat}, ${$form.lon}):asc`],
limit: 1,
})).hits[0];
$form.location = closestCity.name;
};
}

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import DoubleSlider from "$lib/components/base/double_slider.svelte";
import RadioGroup, {
type RadioItem,
} from "$lib/components/base/radio_group.svelte";
import Search, {
type SearchItem,
} from "$lib/components/base/search.svelte";
import Slider from "$lib/components/base/slider.svelte";
import TextField from "$lib/components/base/text_field.svelte";
import TrailCard from "$lib/components/trail/trail_card.svelte";
import { ms } from "$lib/meilisearch";
import type { TrailFilter } from "$lib/models/trail";
import { categories } from "$lib/stores/category_store";
import { trails } from "$lib/stores/trail_store";
import { country_codes } from "$lib/util/country_code_util";
import { formatMeters } from "$lib/util/format_util";
$: maxDistance = Math.max(...$trails.map((t) => t.distance ?? 0));
$: maxElevationGain = Math.max(
...$trails.map((t) => t.elevation_gain ?? 0),
);
const filter: TrailFilter = {
q: "",
category: [],
near: {
distance: 2000,
},
distanceMin: 0,
distanceMax: maxDistance,
eleavationGainMin: 0,
elevationGainMax: maxElevationGain,
};
const radioGroupItems: RadioItem[] = [
{ text: "Completed", value: "completed" },
{ text: "Not completed", value: "not_completed" },
{ text: "No preference", value: "no_preference" },
];
let searchDropdownItems: SearchItem[] = [];
let citySearchQuery: string = "";
function setCompletedFilter(item: RadioItem) {
switch (item.value) {
case "no_preference":
filter.completed = undefined;
break;
case "completed":
filter.completed = true;
break;
case "not_completed":
filter.completed = false;
break;
default:
filter.completed = undefined;
break;
}
}
async function searchCities(q: string) {
if (q.length == 0) {
filter.near.lat = undefined;
filter.near.lon = undefined;
console.log(filter.near);
return;
}
const result = await ms.index("cities500").search(q, { limit: 5 });
searchDropdownItems = result.hits.map((h) => ({
text: h.name,
description:
country_codes[h["country code"] as keyof typeof country_codes],
value: h,
icon: "city",
}));
}
function handleSearchClick(item: SearchItem) {
citySearchQuery = item.text;
filter.near.lat = item.value.lat;
filter.near.lon = item.value.lon;
}
</script>
<main class="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-8 max-w-7xl mx-6 md:mx-auto">
<div class="trail-filters p-8 border rounded-xl">
<TextField placeholder="Search..."></TextField>
<hr class="my-4" />
<p class="text-sm font-medium pb-4">Category</p>
{#each $categories as category}
<div class="flex items-center mb-4">
<input
id="{category.name}-checkbox"
type="checkbox"
value=""
class="w-4 h-4 text-primary bg-gray-100 border-gray-300 focus:ring-gray-400 focus:ring-2"
/>
<label
for="{category.name}-checkbox"
class="ms-2 text-sm text-gray-900 dark:text-gray-300"
>{category.name}</label
>
</div>
{/each}
<hr class="my-4" />
<p class="text-sm font-medium pb-4">Near</p>
<div class="mb-8">
<Search
items={searchDropdownItems}
bind:value={citySearchQuery}
on:update={(e) => searchCities(e.detail)}
on:click={(e) => handleSearchClick(e.detail)}
></Search>
</div>
<Slider maxValue={10000} bind:currentValue={filter.near.distance}
></Slider>
<p>
<span class="text-gray-500 text-sm">Radius:</span>
{formatMeters(filter.near.distance)}
</p>
<hr class="my-4" />
<p class="text-sm font-medium pb-4">Distance</p>
<DoubleSlider
maxValue={maxDistance}
bind:currentMin={filter.distanceMin}
bind:currentMax={filter.distanceMax}
></DoubleSlider>
<div class="flex justify-between">
<span>{formatMeters(filter.distanceMin)}</span>
<span>{formatMeters(filter.distanceMax)}</span>
</div>
<hr class="my-4" />
<p class="text-sm font-medium pb-4">Elevation Gain</p>
<DoubleSlider
maxValue={maxElevationGain}
bind:currentMin={filter.eleavationGainMin}
bind:currentMax={filter.elevationGainMax}
></DoubleSlider>
<div class="flex justify-between">
<span>{formatMeters(filter.eleavationGainMin)}</span>
<span>{formatMeters(filter.elevationGainMax)}</span>
</div>
<hr class="my-4" />
<p class="text-sm font-medium pb-4">Completed</p>
<RadioGroup
name="completed"
items={radioGroupItems}
selected={2}
on:change={(e) => setCompletedFilter(e.detail)}
></RadioGroup>
</div>
<div id="trails" class="flex items-start flex-wrap gap-8 py-8">
{#each $trails as trail}
<a href="/trail/view/{trail.id}">
<TrailCard {trail}></TrailCard></a
>
{/each}
</div>
</main>

View File

@@ -0,0 +1,8 @@
import { categories_index } from "$lib/stores/category_store";
import { trails_index } from "$lib/stores/trail_store";
import type { ServerLoad } from "@sveltejs/kit";
export const load: ServerLoad = async ({ params, locals }) => {
await trails_index()
await categories_index()
};