From d647fd477c146b4627c338e8548bf1516fc4eba0 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 09:22:37 -0700 Subject: [PATCH] Add shared geographic filtering and scanner crime context --- README.md | 4 + apps/cli/src/index.js | 16 +- apps/web/src/app.js | 4 + apps/web/src/lib/mcp/tools.js | 31 +- apps/web/src/lib/scanner-context.js | 17 + apps/web/src/lib/serialize.js | 3 + apps/web/src/lib/service.js | 3 +- apps/web/src/routes/api.js | 35 +- apps/web/src/routes/pages.js | 48 ++- apps/web/src/views/admin.jsx | 18 +- apps/web/src/views/components.jsx | 18 +- apps/web/src/views/pages.jsx | 100 ++++- docs/geographic-queries.md | 173 +++++++++ packages/adapters/src/index.js | 2 + packages/adapters/src/scanners.js | 112 ++++++ packages/core/package.json | 3 +- packages/core/src/geo.js | 69 ++++ .../db/migrations/0023_geographic_queries.sql | 145 +++++++ packages/db/src/geo.js | 24 ++ packages/db/src/matchups.js | 21 +- packages/db/src/queries.js | 114 +++++- test/geo-surfaces.test.js | 116 ++++++ test/geo.test.js | 359 ++++++++++++++++++ 23 files changed, 1373 insertions(+), 62 deletions(-) create mode 100644 apps/web/src/lib/scanner-context.js create mode 100644 docs/geographic-queries.md create mode 100644 packages/adapters/src/scanners.js create mode 100644 packages/core/src/geo.js create mode 100644 packages/db/migrations/0023_geographic_queries.sql create mode 100644 packages/db/src/geo.js create mode 100644 test/geo-surfaces.test.js create mode 100644 test/geo.test.js diff --git a/README.md b/README.md index 0b1a253..481b0f6 100644 --- a/README.md +++ b/README.md @@ -295,3 +295,7 @@ The rules: ## License MIT + +### Geographic filtering and scanner context + +Use `?lat=41.88&long=-87.62&radius=10` across located collections, searches and feeds. [Geographic queries](docs/geographic-queries.md) documents the API, CLI/MCP flags, permissioned scanner catalogs, coverage semantics and migration. diff --git a/apps/cli/src/index.js b/apps/cli/src/index.js index fe3c150..655353f 100644 --- a/apps/cli/src/index.js +++ b/apps/cli/src/index.js @@ -16,6 +16,7 @@ import { join } from 'node:path'; import { createInterface } from 'node:readline'; export const VERSION = '0.26.0'; +const GEO_FLAGS = ['lat', 'long', 'radius', 'unit', 'bbox', 'sort', 'offset']; const DEFAULT_API = process.env.NICHEDB_API ?? 'https://nichedb.dev'; const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'nichedb'); const CONFIG_FILE = join(CONFIG_DIR, 'config.json'); @@ -94,7 +95,7 @@ export const COMMANDS = [ { name: 'recent', usage: - 'recent [--collection …] [--source …] [--kind …] [--tags a,b] [--from …] [--to …] [--since …] [--sort id|published|updated]', + 'recent [--collection …] [--source …] [--kind …] [--tags a,b] [--from …] [--to …] [--since …] [--sort id|published|updated|distance]', summary: 'Newest items across a collection or source, or a window of them, or what changed since.', options: [ @@ -104,8 +105,11 @@ export const COMMANDS = [ '--tags a,b (every one must be on the item)', '--from / --to (ISO, on published_at)', '--since (ISO, on updated_at)', - '--sort id|published|updated', + '--sort id|published|updated|distance', '--order asc|desc', + '--lat / --long / --radius / --unit km|mi', + '--bbox west,south,east,north', + '--offset (distance pagination)', '--limit', '--json', '--urls', @@ -597,6 +601,9 @@ export async function run( tags: csv(flags.tags), q: flags.q, upcoming: Boolean(flags.upcoming), + ...Object.fromEntries( + GEO_FLAGS.filter((k) => flags[k] !== undefined).map((k) => [k, flags[k]]), + ), public: !flags.private, }); out( @@ -618,6 +625,7 @@ export async function run( const [slug] = rest; if (!slug) throw new Error('items '); const qs = new URLSearchParams(); + for (const k of GEO_FLAGS) if (flags[k] !== undefined) qs.set(k, flags[k]); if (flags.limit) qs.set('limit', flags.limit); if (flags.before) qs.set('before', flags.before); const { items } = await client.get(`/api/v1/feeds/${slug}/items?${qs}`); @@ -626,6 +634,7 @@ export async function run( } case 'recent': { const qs = new URLSearchParams(); + for (const k of GEO_FLAGS) if (flags[k] !== undefined) qs.set(k, flags[k]); for (const k of [ 'collection', 'source', @@ -649,6 +658,7 @@ export async function run( const name = rest.join(' '); if (!name) throw new Error('match '); const qs = new URLSearchParams({ q: name }); + for (const k of GEO_FLAGS) if (flags[k] !== undefined) qs.set(k, flags[k]); for (const k of ['collection', 'kind', 'year', 'date', 'tags', 'limit']) if (flags[k]) qs.set(k, flags[k]); const answer = await client.get(`/api/v1/match?${qs}`); @@ -668,6 +678,7 @@ export async function run( } case 'upcoming': { const qs = new URLSearchParams(); + for (const k of GEO_FLAGS) if (flags[k] !== undefined) qs.set(k, flags[k]); for (const k of ['collection', 'days', 'limit']) if (flags[k]) qs.set(k, flags[k]); const { items } = await client.get(`/api/v1/items/upcoming?${qs}`); printItems(items, { json, urls: flags.urls }); @@ -677,6 +688,7 @@ export async function run( const term = rest.join(' '); if (!term) throw new Error('search '); const qs = new URLSearchParams({ q: term }); + for (const k of GEO_FLAGS) if (flags[k] !== undefined) qs.set(k, flags[k]); for (const k of ['collection', 'kind', 'limit']) if (flags[k]) qs.set(k, flags[k]); const { items } = await client.get(`/api/v1/search?${qs}`); printItems(items, { json, urls: flags.urls }); diff --git a/apps/web/src/app.js b/apps/web/src/app.js index 7e822a6..86554fd 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -104,6 +104,10 @@ app.use('*', async (c, next) => { app.onError((err, c) => { if (err.redirect) return c.redirect(err.redirect, 303); + if (err.status === 400) { + if (wantsJson(c)) return c.json({ error: err.message }, 400); + return c.text(err.message, 400); + } if (err instanceof Denied) { if (wantsJson(c)) return c.json({ error: err.message }, err.status); const back = new URL(c.req.header('referer') ?? '/', config.siteUrl); diff --git a/apps/web/src/lib/mcp/tools.js b/apps/web/src/lib/mcp/tools.js index 114db31..b33cb44 100644 --- a/apps/web/src/lib/mcp/tools.js +++ b/apps/web/src/lib/mcp/tools.js @@ -1,5 +1,6 @@ import { config } from '@nichedb/config'; import { describeAdapters, describeEnrichers } from '@nichedb/core'; +import { geoQueryFields, geoSchema } from '@nichedb/core/geo'; import { cleanChannelName, parseName } from '@nichedb/core/names'; import * as profiles from '@nichedb/db/profiles'; import * as q from '@nichedb/db/queries'; @@ -86,16 +87,20 @@ export const TOOLS = [ inputSchema: { type: 'object', properties: { + ...geoSchema, feed: str('Feed slug'), limit: int('Default 30, max 200'), - before_id: int('Keyset cursor'), + before_id: int('Keyset cursor; cannot combine with distance sorting'), + offset: int('Distance pagination offset'), }, required: ['feed'], }, - run: async ({ feed, limit, before_id }) => { + run: async ({ feed, limit, before_id, offset, ...location }) => { const f = await q.getFeed(String(feed)); if (!f) throw toolError(`No feed named ${feed}`); const items = await q.feedItems(f, { + ...geoQueryFields(location), + offset, limit: Math.min(Number(limit) || 30, 200), beforeId: before_id ?? null, }); @@ -111,19 +116,23 @@ export const TOOLS = [ inputSchema: { type: 'object', properties: { + ...geoSchema, collection: str('Collection slug'), source: str('Source slug'), kind: str('Item kind'), limit: int('Default 30, max 200'), - before_id: int('Keyset cursor'), + before_id: int('Keyset cursor; cannot combine with distance sorting'), + offset: int('Distance pagination offset'), }, }, - run: async ({ collection, source, kind, limit, before_id }) => { + run: async ({ collection, source, kind, limit, before_id, offset, ...location }) => { const col = collection ? await q.getCollection(collection) : null; if (collection && !col) throw toolError(`No collection named ${collection}`); const src = source ? await q.getSource(source) : null; if (source && !src) throw toolError(`No source named ${source}`); const items = await q.recentItems({ + offset, + ...geoQueryFields(location), collectionId: col?.id ?? null, sourceId: src?.id ?? null, kind: kind ?? null, @@ -139,15 +148,17 @@ export const TOOLS = [ inputSchema: { type: 'object', properties: { + ...geoSchema, collection: str('Collection slug'), days: int('Horizon, default 30'), limit: int('Default 50'), }, }, - run: async ({ collection, days, limit }) => { + run: async ({ collection, days, limit, ...location }) => { const col = collection ? await q.getCollection(collection) : null; if (collection && !col) throw toolError(`No collection named ${collection}`); const items = await q.upcomingItems({ + ...geoQueryFields(location), collectionId: col?.id ?? null, days: Number(days) || 30, limit: Math.min(Number(limit) || 50, 200), @@ -162,6 +173,7 @@ export const TOOLS = [ inputSchema: { type: 'object', properties: { + ...geoSchema, q: str('Query'), collection: str('Collection slug'), kind: str('Item kind'), @@ -169,10 +181,11 @@ export const TOOLS = [ }, required: ['q'], }, - run: async ({ q: term, collection, kind, limit }) => { + run: async ({ q: term, collection, kind, limit, ...location }) => { const col = collection ? await q.getCollection(collection) : null; if (collection && !col) throw toolError(`No collection named ${collection}`); const items = await q.searchItems(String(term), { + ...geoQueryFields(location), collectionId: col?.id ?? null, kind: kind ?? null, limit: Math.min(Number(limit) || 20, 100), @@ -187,6 +200,7 @@ export const TOOLS = [ inputSchema: { type: 'object', properties: { + ...geoSchema, q: str('The name as written'), collection: str('Collection slug: screen, channels or sports'), kind: str('Item kind: title, channel, fixture'), @@ -196,12 +210,13 @@ export const TOOLS = [ }, required: ['q'], }, - run: async ({ q: term, collection, kind, year, date, limit }) => { + run: async ({ q: term, collection, kind, year, date, limit, ...location }) => { const col = collection ? await q.getCollection(collection) : null; if (collection && !col) throw toolError(`No collection named ${collection}`); const parsed = parseName(String(term)); const sports = !col || col.slug === 'sports' || kind === 'fixture'; const items = await q.matchItems(parsed.name, { + ...geoQueryFields(location), collectionId: col?.id ?? null, kind: kind ?? null, year: Number(year) || parsed.year || null, @@ -234,6 +249,7 @@ export const TOOLS = [ inputSchema: { type: 'object', properties: { + ...geoSchema, collection: str('Collection slug'), name: str('Feed name'), description: str('Optional'), @@ -254,6 +270,7 @@ export const TOOLS = [ name: args.name, description: args.description, query: { + ...geoQueryFields(args), sources: args.sources, kinds: args.kinds, tags: args.tags, diff --git a/apps/web/src/lib/scanner-context.js b/apps/web/src/lib/scanner-context.js new file mode 100644 index 0000000..2b73820 --- /dev/null +++ b/apps/web/src/lib/scanner-context.js @@ -0,0 +1,17 @@ +import { GeoQueryError, geoQueryFields } from '@nichedb/core/geo'; + +export function scannerContextOptions(raw = {}) { + const date = (key) => { + const value = raw[key]; + if (value === undefined || value === null || value === '') return null; + const d = new Date(value); + if (Number.isNaN(d.getTime())) throw new GeoQueryError(`${key} must be an ISO date`); + return d.toISOString(); + }; + const from = date('from'); + const to = date('to'); + if (from && to && from >= to) throw new GeoQueryError('from must precede to'); + return { ...geoQueryFields(raw), from, to }; +} +export const SCANNER_CONTEXT_NOTE = + 'Reported incidents in the coverage area; not verified links to scanner transmissions. An empty result does not imply no crime.'; diff --git a/apps/web/src/lib/serialize.js b/apps/web/src/lib/serialize.js index b49d5cb..f3c87b5 100644 --- a/apps/web/src/lib/serialize.js +++ b/apps/web/src/lib/serialize.js @@ -44,6 +44,9 @@ export function itemOut(i, siteUrl, { enrichers = null } = {}) { precision: i.precision, tags: i.tags, data: i.data, + ...(i.distance_m !== null && i.distance_m !== undefined + ? { distance_m: Number(i.distance_m) } + : {}), first_seen_at: i.first_seen_at, enrichment: enrichmentOut( i, diff --git a/apps/web/src/lib/service.js b/apps/web/src/lib/service.js index 6e87c95..abd86d5 100644 --- a/apps/web/src/lib/service.js +++ b/apps/web/src/lib/service.js @@ -1,5 +1,6 @@ import { config } from '@nichedb/config'; import { adapterByName, slugify } from '@nichedb/core'; +import { geoQueryFields } from '@nichedb/core/geo'; import * as knowledge from '@nichedb/db/knowledge'; import * as premiumDb from '@nichedb/db/premium'; import * as q from '@nichedb/db/queries'; @@ -169,7 +170,7 @@ export function normaliseQuery(raw = {}) { .map((s) => String(s).trim()) .filter(Boolean) .slice(0, 20); - const out = {}; + const out = { ...geoQueryFields(raw) }; const sources = arr(raw.sources); const kinds = arr(raw.kinds); const tags = arr(raw.tags).map((t) => t.toLowerCase()); diff --git a/apps/web/src/routes/api.js b/apps/web/src/routes/api.js index 4fa79e9..7889e54 100644 --- a/apps/web/src/routes/api.js +++ b/apps/web/src/routes/api.js @@ -1,5 +1,6 @@ import { config } from '@nichedb/config'; import { describeAdapters, describeEnrichers } from '@nichedb/core'; +import { geoQueryFields } from '@nichedb/core/geo'; import { cleanChannelName, parseName } from '@nichedb/core/names'; import * as q from '@nichedb/db/queries'; import { enqueueRun } from '@nichedb/queue'; @@ -8,6 +9,7 @@ import { mintPass } from '@profullstack/x402-gateway'; import { callerAddress } from '../lib/auth-throttle.js'; import { isProUser, render, requireUser } from '../lib/http.js'; import { apiLimitFor, planOf } from '../lib/premium.js'; +import { SCANNER_CONTEXT_NOTE, scannerContextOptions } from '../lib/scanner-context.js'; import { allowedEnrichers, collectionOut, feedOut, itemOut, sourceOut } from '../lib/serialize.js'; import { addSource, @@ -232,8 +234,10 @@ export function registerApi(app) { if (!f || (!f.public && f.owner_id !== c.get('user')?.id)) return c.json({ error: 'not found' }, 404); const items = await q.feedItems(f, { + ...geoQueryFields(c.req.query()), limit: lim(c.req.query('limit'), 50, 200), beforeId: Number(c.req.query('before')) || null, + offset: Math.max(0, Math.floor(Number(c.req.query('offset')) || 0)), }); c.header('cache-control', 'public, max-age=60'); return c.json({ @@ -249,7 +253,7 @@ export function registerApi(app) { collection: body.collection, name: body.name, description: body.description, - query: body, + query: body.query ?? body, isPublic: body.public, }); return c.json({ feed: feedOut(await q.getFeed(feed.slug), site()) }, 201); @@ -268,7 +272,8 @@ export function registerApi(app) { body.kinds || body.tags || body.q !== undefined || - body.upcoming !== undefined + body.upcoming !== undefined || + ['lat', 'long', 'radius', 'unit', 'bbox', 'sort'].some((key) => body[key] !== undefined) ? body : undefined), isPublic: body.public, @@ -323,10 +328,11 @@ export function registerApi(app) { const d = new Date(v); return Number.isNaN(d.getTime()) ? null : d.toISOString(); }; - const sort = ['id', 'published', 'updated'].includes(c.req.query('sort')) + const sort = ['id', 'published', 'updated', 'distance'].includes(c.req.query('sort')) ? c.req.query('sort') : 'id'; const items = await q.recentItems({ + ...geoQueryFields(c.req.query()), collectionId: col?.id ?? null, sourceId: src?.id ?? null, kind: c.req.query('kind') ?? null, @@ -340,6 +346,7 @@ export function registerApi(app) { order: c.req.query('order') === 'asc' ? 'asc' : 'desc', limit: lim(c.req.query('limit'), 50, 200), beforeId: Number(c.req.query('before')) || null, + offset: Math.max(0, Math.floor(Number(c.req.query('offset')) || 0)), afterId: Number(c.req.query('after')) || null, }); // A mirror asking "what changed since" must not be handed a stale page. @@ -361,6 +368,7 @@ export function registerApi(app) { const date = /^\d{4}-\d{2}-\d{2}$/.test(c.req.query('date') ?? '') ? c.req.query('date') : null; const sports = col === null || col.slug === 'sports' || kind === 'fixture'; const items = await q.matchItems(parsed.name, { + ...geoQueryFields(c.req.query()), collectionId: col?.id ?? null, kind, tags: (c.req.query('tags') ?? '').split(',').filter(Boolean), @@ -384,6 +392,7 @@ export function registerApi(app) { app.get('/api/v1/items/upcoming', async (c) => { const col = await collectionOrNull(c.req.query('collection')); const items = await q.upcomingItems({ + ...geoQueryFields(c.req.query()), collectionId: col?.id ?? null, days: lim(c.req.query('days'), 30, 365), limit: lim(c.req.query('limit'), 100, 200), @@ -391,6 +400,25 @@ export function registerApi(app) { c.header('cache-control', 'public, max-age=300'); return c.json({ count: items.length, items: items.map((i) => itemOut(i, site())) }); }); + app.get('/api/v1/items/:id/nearby-crime', async (c) => { + const scanner = await q.getItem(Number(c.req.param('id'))); + if (scanner?.kind !== 'scanner-stream') return c.json({ error: 'scanner not found' }, 404); + const options = scannerContextOptions(c.req.query()); + const items = await q.nearbyCrime(scanner, { + ...options, + limit: lim(c.req.query('limit'), 20, 100), + }); + return c.json({ + scanner_id: Number(scanner.id), + relationship: 'within-coverage', + note: SCANNER_CONTEXT_NOTE, + coverage_basis: scanner.data?.coverage_basis ?? 'unknown', + from: options.from, + to: options.to, + count: items.length, + items: items.map((i) => itemOut(i, site())), + }); + }); app.get('/api/v1/items/:id', async (c) => { const item = await q.getItem(Number(c.req.param('id'))); if (!item) return c.json({ error: 'not found' }, 404); @@ -402,6 +430,7 @@ export function registerApi(app) { if (!term) return c.json({ error: 'q is required' }, 400); const col = await collectionOrNull(c.req.query('collection')); const items = await q.searchItems(term, { + ...geoQueryFields(c.req.query()), collectionId: col?.id ?? null, kind: c.req.query('kind') ?? null, limit: lim(c.req.query('limit'), 30, 100), diff --git a/apps/web/src/routes/pages.js b/apps/web/src/routes/pages.js index 00d00e3..1f34d81 100644 --- a/apps/web/src/routes/pages.js +++ b/apps/web/src/routes/pages.js @@ -1,4 +1,5 @@ import { config } from '@nichedb/config'; +import { geoQueryFields } from '@nichedb/core/geo'; import { profilePath } from '@nichedb/core/profiles'; import * as premiumDb from '@nichedb/db/premium'; import * as profiles from '@nichedb/db/profiles'; @@ -9,6 +10,7 @@ import { cached, isProUser, render, requireUser, wantsJson } from '../lib/http.j import { currentModules } from '../lib/modules.js'; import { entitlementsOf, planOf } from '../lib/premium.js'; import { buildJsonFeed, buildRss } from '../lib/rss.js'; +import { scannerContextOptions } from '../lib/scanner-context.js'; import { allowedEnrichers } from '../lib/serialize.js'; import { canEditFeed } from '../lib/service.js'; import { @@ -69,6 +71,12 @@ export function registerPages(app) { // The address people say out loud for the webrings directory. The collection // itself lives under /c like every other one, so this only ever forwards. + app.get('/crimes', (c) => c.redirect(`/c/crime${new URL(c.req.url).search}`, 302)); + app.get('/scanners', (c) => { + const params = new URL(c.req.url).searchParams; + params.set('kind', 'scanner-stream'); + return c.redirect(`/c/crime?${params}`, 302); + }); app.get('/rings', (c) => c.redirect('/c/webrings', 301)); app.get('/c/:slug', async (c) => { @@ -78,21 +86,23 @@ export function registerPages(app) { // readable by members and by nobody else until it is opened. if (collection.early_access && entitlementsOf(c).plan === 'free') return earlyAccessWall(c, collection); + const geo = geoQueryFields(c.req.query()); + const offset = Math.min(10000, Math.max(0, Math.floor(Number(c.req.query('offset')) || 0))); const tag = c.req.query('tag') ?? null; const kind = c.req.query('kind') ?? null; const before = Number(c.req.query('before')) || null; - const key = `c:${collection.slug}:${tag ?? ''}:${kind ?? ''}:${before ?? ''}`; + const key = `c:${collection.slug}:${tag ?? ''}:${kind ?? ''}:${before ?? ''}:${JSON.stringify(geo)}:${offset}`; return cached(c, key, async () => { const pseudo = { collection_id: collection.id, - query: { tags: tag ? [tag] : [], kinds: kind ? [kind] : [] }, + query: { ...geo, tags: tag ? [tag] : [], kinds: kind ? [kind] : [] }, }; const [stats, sources, feeds, latest, upcoming, kinds, tags] = await Promise.all([ q.collectionStats(collection.id), q.listSources({ collectionId: collection.id }), q.listFeeds({ collectionId: collection.id }), - q.feedItems(pseudo, { limit: 50, beforeId: before }), - before || tag || kind + q.feedItems(pseudo, { limit: 50, beforeId: before, offset }), + before || tag || kind || Object.keys(geo).length ? [] : q.upcomingItems({ collectionId: collection.id, days: 14, limit: 8 }), q.kindsForCollection(collection.id), @@ -109,6 +119,8 @@ export function registerPages(app) { upcoming={upcoming} kinds={kinds} tags={tags} + geo={geo} + offset={offset} tag={tag} kind={kind} />, @@ -136,11 +148,13 @@ export function registerPages(app) { if (!feed) return c.notFound(); const user = c.get('user'); if (!feed.public && !canEditFeed(user, feed)) return c.notFound(); + const geo = geoQueryFields(c.req.query()); + const offset = Math.min(10000, Math.max(0, Math.floor(Number(c.req.query('offset')) || 0))); const before = Number(c.req.query('before')) || null; const items = feed.id === 0 - ? await q.recentItems({ limit: 100, beforeId: before }) - : await q.feedItems(feed, { limit: m ? 100 : 50, beforeId: before }); + ? await q.recentItems({ ...geo, limit: 100, beforeId: before, offset }) + : await q.feedItems(feed, { ...geo, limit: m ? 100 : 50, beforeId: before, offset }); if (m) { // A free feed carries one sponsored item at the top; Pro and paid @@ -150,7 +164,7 @@ export function registerPages(app) { title: feed.name, link: `${config.siteUrl}/f/${feed.slug}`, description: feed.description, - selfUrl: `${config.siteUrl}/f/${feed.slug}.${m[2]}`, + selfUrl: `${config.siteUrl}/f/${feed.slug}.${m[2]}${new URL(c.req.url).search}`, items: ad ? [ad, ...items] : items, siteUrl: config.siteUrl, }; @@ -164,7 +178,7 @@ export function registerPages(app) { } if (feed.id === 0) return c.redirect('/', 303); - const key = `f:${feed.slug}:${before ?? ''}`; + const key = `f:${feed.slug}:${before ?? ''}:${JSON.stringify(geo)}:${offset}`; return cached(c, key, async () => { const following = user ? await q.isFollowing({ userId: user.id, feedId: feed.id }) : false; const follow = following ? await q.getFollow({ userId: user.id, feedId: feed.id }) : null; @@ -172,6 +186,8 @@ export function registerPages(app) { []), user ? premiumDb.creditBalance(user.id).catch(() => 0) : Promise.resolve(0), ]); return cached( c, - `i:${id}`, + `i:${id}:${JSON.stringify(contextOptions)}`, () => render( x.slug === collectionSlug) : null; const results = term - ? await q.searchItems(term, { collectionId: col?.id ?? null, limit: 50 }) + ? await q.searchItems(term, { + ...geoQueryFields(c.req.query()), + collectionId: col?.id ?? null, + limit: 50, + }) : []; return c.html( await render( diff --git a/apps/web/src/views/admin.jsx b/apps/web/src/views/admin.jsx index 21b485c..87d4a89 100644 --- a/apps/web/src/views/admin.jsx +++ b/apps/web/src/views/admin.jsx @@ -499,6 +499,16 @@ export const FeedForm = ({ ) : null} + {['lat', 'long', 'radius', 'unit', 'bbox', 'sort'] + .filter((key) => values[key] !== undefined && values[key] !== null) + .map((key) => ( + + ))}