Expose search in TopNav and add Agora kind presets to the Kind picker

Add a Search icon button to the TopNav right cluster (visible on all
breakpoints) that links to /search, so users no longer have to dig into
the mobile drawer or profile menu to reach search.

Surface Agora's main content kinds in the Search filter Kind picker:

- Add Campaigns (33863) and Pledges (36639) to buildKindOptions(), since
  they are first-class Agora content but live outside EXTRA_KINDS (which
  drives the sidebar / feed-settings UI and shouldn't be polluted with
  kinds that don't have their own toggleable feed/sidebar items).

- Group the curated Agora set — Campaigns, Pledges, Communities, Posts,
  Articles, Events, Polls, Photos, Videos — under an 'Agora content'
  section at the top of the KindPicker dropdown, with the long-tail list
  of every other supported kind under an 'All kinds' section below. When
  the user types in the search box the partition collapses to a flat
  result list.
This commit is contained in:
Chad Curtis
2026-05-21 22:26:36 -05:00
parent 4e9c6b37d3
commit 1dbac90108
3 changed files with 98 additions and 4 deletions
+36 -2
View File
@@ -44,7 +44,7 @@ type KindOption = {
// ─── Kind options (built once) ───────────────────────────────────────────────
import { buildKindOptions } from '@/lib/feedFilterUtils';
import { buildKindOptions, AGORA_PRESET_KIND_VALUES } from '@/lib/feedFilterUtils';
// ─── useScrollCarets ─────────────────────────────────────────────────────────
@@ -152,6 +152,25 @@ export function KindPicker({ value, options, onChange }: {
);
}, [options, search]);
// Partition into Agora preset (top of picker) and the rest.
// When the user is searching, we skip the partition and show a flat list.
const presetSet = useMemo(() => new Set(AGORA_PRESET_KIND_VALUES), []);
const { presetOptions, otherOptions } = useMemo(() => {
if (search) return { presetOptions: [], otherOptions: filtered };
const preset: KindOption[] = [];
const other: KindOption[] = [];
// Preserve AGORA_PRESET_KIND_VALUES order for the preset section.
const byValue = new Map(filtered.map((o) => [o.value, o]));
for (const v of AGORA_PRESET_KIND_VALUES) {
const opt = byValue.get(v);
if (opt) preset.push(opt);
}
for (const o of filtered) {
if (!presetSet.has(o.value)) other.push(o);
}
return { presetOptions: preset, otherOptions: other };
}, [filtered, presetSet, search]);
const selected = value === 'all' || value === 'custom' ? null : options.find((o) => o.value === value);
const SelectedIcon = selected?.icon;
@@ -199,7 +218,22 @@ export function KindPicker({ value, options, onChange }: {
{canScrollUp && <KindScrollCaret direction="up" onMouseEnter={() => startScroll('up')} onMouseLeave={stopScroll} />}
<div ref={refCallback} className="overflow-y-auto flex-1 min-h-0" onScroll={onScroll}>
{!search && <KindPickerItem icon={null} label="All kinds" active={value === 'all'} onClick={() => handleSelect('all')} />}
{filtered.map((opt) => (
{!search && presetOptions.length > 0 && (
<>
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Agora content
</div>
{presetOptions.map((opt) => (
<KindPickerItem key={opt.value} icon={opt.icon ?? null} label={opt.label} active={value === opt.value} onClick={() => handleSelect(opt.value)} />
))}
{otherOptions.length > 0 && (
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
All kinds
</div>
)}
</>
)}
{otherOptions.map((opt) => (
<KindPickerItem key={opt.value} icon={opt.icon ?? null} label={opt.label} active={value === opt.value} onClick={() => handleSelect(opt.value)} />
))}
{(!search || 'custom'.includes(search.toLowerCase())) && (
+16 -1
View File
@@ -1,5 +1,5 @@
import { useState, type ComponentType } from 'react';
import { Link, NavLink } from 'react-router-dom';
import { Link, NavLink, useNavigate } from 'react-router-dom';
import {
Activity,
Bell,
@@ -54,6 +54,9 @@ export function TopNav() {
const { user } = useCurrentUser();
const { orderedItems } = useFeedSettings();
const [mobileOpen, setMobileOpen] = useState(false);
const navigate = useNavigate();
const goToSearch = () => navigate('/search');
return (
<header className="sticky top-0 z-40 w-full border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/70">
@@ -89,6 +92,18 @@ export function TopNav() {
{/* Right cluster */}
<div className="flex items-center gap-2 sm:gap-3">
{/* Search — navigates to the /search page. Visible on all
breakpoints so users can always reach search from the chrome. */}
<button
type="button"
onClick={goToSearch}
className="shrink-0 size-9 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Search"
title="Search"
>
<Search className="size-5" />
</button>
{/* LoginArea handles both logged-in (account avatar dropdown) and
logged-out (Log in / Sign up) states. We render it inline-flex
and let it style its own children. */}
+46 -1
View File
@@ -10,9 +10,54 @@ type KindOption = {
icon: React.ComponentType<{ className?: string }> | undefined;
};
/** Build the kind options from EXTRA_KINDS definitions. */
/**
* Agora-native kinds that are not modeled in EXTRA_KINDS (which drives the
* sidebar / feed-settings UI). We surface them in the search Kind picker
* because they are first-class searchable content on Agora.
*/
const AGORA_NATIVE_KIND_OPTIONS: KindOption[] = [
{
value: '33863',
label: 'Campaigns (33863)',
description: 'Fundraising campaigns',
parentId: 'campaigns',
icon: CONTENT_KIND_ICONS['campaigns'],
},
{
value: '36639',
label: 'Pledges (36639)',
description: 'Donor pledges for concrete actions',
parentId: 'actions',
icon: CONTENT_KIND_ICONS['actions'],
},
];
/**
* Agora's curated "main content" kinds, surfaced as a preset section at the
* top of the KindPicker. Order is the order they appear in the picker.
*
* Includes Agora-native kinds plus the existing-NIP kinds Agora foregrounds
* (campaigns, pledges, communities, posts, articles, events, polls, photos,
* videos). Excludes social-signal kinds (reactions, reposts, zaps) and
* stats snapshots, which users rarely filter on directly.
*/
export const AGORA_PRESET_KIND_VALUES: readonly string[] = [
'33863', // Campaigns
'36639', // Pledges
'34550', // Communities
'1', // Posts
'30023', // Articles
'31923', // Events (time)
'1068', // Polls
'20', // Photos
'21', // Videos
] as const;
/** Build the kind options from EXTRA_KINDS definitions plus Agora-native kinds. */
export function buildKindOptions(): KindOption[] {
const options: KindOption[] = [];
// Agora-native kinds appear first so they're easy to find.
for (const opt of AGORA_NATIVE_KIND_OPTIONS) options.push(opt);
for (const def of EXTRA_KINDS) {
if (def.subKinds) {
for (const sub of def.subKinds) {