diff --git a/apps/web/src/lib/contrail.ts b/apps/web/src/lib/contrail.ts index 16be944..2900a88 100644 --- a/apps/web/src/lib/contrail.ts +++ b/apps/web/src/lib/contrail.ts @@ -49,7 +49,6 @@ type EventProfileEntry = RsvpAtmoEventListRecords.ProfileEntry; type EventGetOutput = RsvpAtmoEventGetRecord.$output; type EventGetProfileEntry = RsvpAtmoEventGetRecord.ProfileEntry; type RsvpListRecord = RsvpAtmoRsvpListRecords.Record; -type RsvpProfileEntry = RsvpAtmoRsvpListRecords.ProfileEntry; type HydratedEventRecord = RsvpAtmoRsvpListRecords.RefEventRecord; type FlattenableEventRecord = EventListRecord | EventGetOutput | HydratedEventRecord; type EventProfiles = EventProfileEntry[] | EventGetProfileEntry[] | undefined; diff --git a/apps/web/src/lib/search/near-me-input.test.ts b/apps/web/src/lib/search/near-me-input.test.ts new file mode 100644 index 0000000..e43510d --- /dev/null +++ b/apps/web/src/lib/search/near-me-input.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import * as v from 'valibot'; +import { nearMeInput } from './near-me-input'; + +describe('nearMeInput', () => { + it('accepts in-range coordinates and radius', () => { + const parsed = v.parse(nearMeInput, { lat: 38.25, lng: -85.76, radiusMeters: 25000 }); + expect(parsed).toEqual({ lat: 38.25, lng: -85.76, radiusMeters: 25000 }); + }); + + it('rejects out-of-range latitude/longitude', () => { + expect(() => v.parse(nearMeInput, { lat: 91, lng: 0, radiusMeters: 1000 })).toThrow(); + expect(() => v.parse(nearMeInput, { lat: 0, lng: -181, radiusMeters: 1000 })).toThrow(); + }); + + it('rejects radii beyond the 500 km cap (abuse bound, UI offers fixed choices)', () => { + expect(() => v.parse(nearMeInput, { lat: 0, lng: 0, radiusMeters: 1_000_000 })).toThrow(); + }); +}); diff --git a/apps/web/src/lib/search/near-me-input.ts b/apps/web/src/lib/search/near-me-input.ts new file mode 100644 index 0000000..071dc7c --- /dev/null +++ b/apps/web/src/lib/search/near-me-input.ts @@ -0,0 +1,14 @@ +// Input bounds for the near-me query. Pure valibot module (no $app/server) +// so the schema — the validation that actually runs in production — stays +// directly testable. +import * as v from 'valibot'; + +/** Server-side abuse bound; the UI offers fixed radii well under this. */ +export const MAX_NEAR_ME_RADIUS_METERS = 500_000; + +export const nearMeInput = v.object({ + lat: v.pipe(v.number(), v.minValue(-90), v.maxValue(90)), + lng: v.pipe(v.number(), v.minValue(-180), v.maxValue(180)), + radiusMeters: v.pipe(v.number(), v.minValue(1), v.maxValue(MAX_NEAR_ME_RADIUS_METERS)), + cursor: v.optional(v.string()) +}); diff --git a/apps/web/src/lib/search/near-me.remote.ts b/apps/web/src/lib/search/near-me.remote.ts new file mode 100644 index 0000000..08a5133 --- /dev/null +++ b/apps/web/src/lib/search/near-me.remote.ts @@ -0,0 +1,30 @@ +import { command, getRequestEvent } from '$app/server'; +import { error } from '@sveltejs/kit'; +import { getServerClient } from '$lib/contrail/index'; +import { runNearMePage, searchBackendFromEnv } from './server/query'; +import { nearMeInput } from './near-me-input'; + +/** Geo query function: events within a radius, nearest first. One of the two + * endpoints backed by Meilisearch (with `search`); requires the backend — + * there is no D1 fallback for geo. */ +export const loadNearMeEvents = command(nearMeInput, async (input) => { + const { platform } = getRequestEvent(); + + const backend = searchBackendFromEnv(platform?.env); + if (!backend) error(503, 'Search is not configured on this deployment'); + + const client = getServerClient(platform!.env.DB); + const page = await runNearMePage(backend, client, { + lat: input.lat, + lng: input.lng, + radiusMeters: input.radiusMeters, + cursor: input.cursor ?? null + }); + + return { + events: page.events, + handles: page.handles, + cursor: page.cursor, + distances: page.distances + }; +}); diff --git a/apps/web/src/lib/search/server/page.ts b/apps/web/src/lib/search/server/page.ts index 68e3e42..0a699af 100644 --- a/apps/web/src/lib/search/server/page.ts +++ b/apps/web/src/lib/search/server/page.ts @@ -14,7 +14,6 @@ export function assembleSearchPage( ): { items: { record: T; distanceMeters?: number }[]; consumed: number } { const byUri = new Map(records.map((r) => [r.uri, r])); const items: { record: T; distanceMeters?: number }[] = []; - let consumed = 0; for (const [index, hit] of hits.entries()) { const record = byUri.get(hit.uri); @@ -23,8 +22,7 @@ export function assembleSearchPage( record, ...(hit.distanceMeters !== undefined ? { distanceMeters: hit.distanceMeters } : {}) }); - consumed = index + 1; - if (items.length === pageSize) return { items, consumed }; + if (items.length === pageSize) return { items, consumed: index + 1 }; } // Hits exhausted before the page filled: everything was examined. return { items, consumed: hits.length }; diff --git a/apps/web/src/routes/(app)/near-me/+page.server.ts b/apps/web/src/routes/(app)/near-me/+page.server.ts new file mode 100644 index 0000000..25405e0 --- /dev/null +++ b/apps/web/src/routes/(app)/near-me/+page.server.ts @@ -0,0 +1,7 @@ +import { searchBackendFromEnv } from '$lib/search/server/query'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ platform }) => { + // Geo search has no D1 fallback; tell the page whether to offer it at all. + return { available: searchBackendFromEnv(platform?.env) !== null }; +}; diff --git a/apps/web/src/routes/(app)/near-me/+page.svelte b/apps/web/src/routes/(app)/near-me/+page.svelte new file mode 100644 index 0000000..6bcee5e --- /dev/null +++ b/apps/web/src/routes/(app)/near-me/+page.svelte @@ -0,0 +1,153 @@ + + + + Events Near Me + + +
+

Events Near Me

+ + {#if !data.available} +

+ Location search isn't available on this deployment. +

+ {:else} +
+ + +
+ + {#if status === 'denied'} +

+ Location permission was denied. Allow location access and try again. +

+ {:else if status === 'error'} +

Something went wrong — try again.

+ {:else if status === 'loading'} +

Finding events…

+ {:else if status === 'ready'} + {#if events.length === 0} +

No events within {radiusKm} km.

+ {:else} +
+ {#each events as event (event.uri)} +
+ + {#if formatDistance(distances[event.uri])} +

+ {formatDistance(distances[event.uri])} away +

+ {/if} +
+ {/each} +
+ {#if cursor} +
+ +
+ {/if} + {/if} + {/if} + {/if} +
diff --git a/apps/web/src/routes/(app)/search/+page.svelte b/apps/web/src/routes/(app)/search/+page.svelte index f79f2a6..014a820 100644 --- a/apps/web/src/routes/(app)/search/+page.svelte +++ b/apps/web/src/routes/(app)/search/+page.svelte @@ -2,6 +2,7 @@ import EventList from '$lib/components/EventList.svelte'; import { Input, Button } from '@foxui/core'; import { goto } from '$app/navigation'; + import { resolve } from '$app/paths'; let { data } = $props(); @@ -11,7 +12,7 @@ e.preventDefault(); const q = query.trim(); if (q) { - goto(`/search?q=${encodeURIComponent(q)}`); + goto(`${resolve('/search')}?q=${encodeURIComponent(q)}`); } } @@ -23,7 +24,7 @@

Search Events

-
+ Search
+

+ + Events near me → + +

+ {#if data.query} {#if data.events.length === 0}

No events found for "{data.query}".