SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
9.1 KB · 153 lines tsx
Raw Blame History
1import Link from "next/link";2import type { Metadata } from "next";3import { EventRow } from "@/components/event-row";4import { Badge, Chip, Empty, Flag, Kbd, PageHeader, Panel, Score, StateBadge, TierBadge } from "@/components/ui";5import { api } from "@/lib/api";6import { relTime, typeLabel, utcDateTime } from "@/lib/format";78export const dynamic = "force-dynamic";9export const metadata: Metadata = { title: "Search", robots: { index: false } };1011const SYNTAX: { key: string; example: string; help: string }[] = [12  { key: "entity", example: "entity:openai", help: "events linked to an entity" },13  { key: "type", example: "type:pricing_change", help: "event type" },14  { key: "after", example: "after:7d", help: "detected after (7d, 24h, 2026-09-01)" },15  { key: "silent", example: "silent:true", help: "silent changes only" },16  { key: "importance", example: "importance:>70", help: "minimum importance" },17  { key: "country", example: "country:CA", help: "source country" },18  { key: "first_party", example: "first_party:true", help: "first-party evidence only" },19];2021function fmtFilter(k: string, v: unknown): string {22  if (v === null || v === undefined) return "";23  if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}T/.test(v)) return utcDateTime(v);24  if (Array.isArray(v)) return v.join(", ");25  return String(v);26}2728/** Search (spec §30): full-text + structured filters, results across events, clusters, entities, sources and URLs. */29export default async function SearchPage({ searchParams }: { searchParams: Promise<{ q?: string }> }) {30  const { q = "" } = await searchParams;31  const query = q.trim();32  const r = query.length >= 2 ? await api.search(query) : null;33  const filters = Object.entries(r?.parsed?.filters ?? {}).filter(([, v]) => v !== undefined && v !== null && v !== "");34  const text = r?.parsed?.text?.trim() ?? "";35  const clusters = r?.clusters ?? [];36  const total = r ? r.events.length + clusters.length + r.entities.length + r.sources.length + r.urls.length : 0;37  return (38    <>39      <PageHeader compact kicker="Search everything" title={query ? <span>Results for “{query}”</span> : "Search"} description="Events, clusters, entities, sources and URLs. Full-text over titles, summaries and keywords; structured filters narrow the event set." />40      <form action="/search" className="mb-2 flex gap-2">41        <input name="q" defaultValue={q} autoFocus placeholder="OpenAI, CVE-2026, pricing, status.…" aria-label="Search" className="h-9 w-full min-w-0 max-w-2xl rounded-md border border-line bg-panel px-3 font-mono text-[13px] placeholder:text-fg-subtle" />42        <button type="submit" className="h-9 rounded-md border border-line bg-panel-2 px-3 text-[13px] hover:border-line-strong">Search</button>43      </form>44      <div className="mb-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-fg-subtle">45        <span>Syntax</span>46        {SYNTAX.map((s) => (47          <Link key={s.key} href={`/search?q=${encodeURIComponent(`${query ? query + " " : ""}${s.example}`)}`} title={s.help} className="rounded-sm border border-line bg-panel-2 px-1.5 py-px font-mono text-[10.5px] text-fg-muted hover:text-fg">{s.example}</Link>48        ))}49        <span className="hidden items-center gap-1 sm:inline-flex"><Kbd>⌘K</Kbd> anywhere</span>50      </div>5152      {r && (53        <>54          <div className="mb-4 flex flex-wrap items-center gap-1.5 text-[12px]">55            <span className="text-fg-subtle">{total} result{total === 1 ? "" : "s"}</span>56            {text && <Chip tone="default" title="Full-text term">“{text}”</Chip>}57            {filters.map(([k, v]) => (58              <Chip key={k} tone="info" className="font-mono" title={`Filter ${k}`}>{k}: {fmtFilter(k, v)}</Chip>59            ))}60            {filters.length > 0 && <Link href={`/search?q=${encodeURIComponent(text)}`} className="text-[11px] text-fg-subtle hover:text-fg">clear filters</Link>}61          </div>62          <div className="grid gap-4 xl:grid-cols-[1fr_360px]">63            <div className="flex min-w-0 flex-col gap-4">64              <Panel title={`Events · ${r.events.length}`} dense action={r.events.length ? <Link href={`/live?q=${encodeURIComponent(text || query)}`} className="text-[11px] text-fg-subtle hover:text-fg">live →</Link> : undefined}>65                {r.events.length ? r.events.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No event matches{filters.length ? " these filters" : ""}. Try a broader term, remove a filter, or extend the window with <span className="font-mono">after:30d</span>.</Empty>}66              </Panel>67              <Panel title={`Clusters · ${clusters.length}`} dense>68                {clusters.length ? (69                  <ul className="divide-y divide-line">70                    {clusters.map((c) => (71                      <li key={c.id} className="grid grid-cols-[auto_1fr] items-start gap-x-3 px-3 py-2 text-[13px]">72                        <Score value={c.max_importance} size="sm" />73                        <div className="min-w-0">74                          <div className="flex flex-wrap items-center gap-x-2 text-[11px] text-fg-subtle">75                            <StateBadge state={c.state} />76                            <span>{c.event_count} signal{c.event_count === 1 ? "" : "s"} · {c.source_count} source{c.source_count === 1 ? "" : "s"}</span>77                            <span className="ml-auto font-mono tabular">{relTime(c.last_at)}</span>78                          </div>79                          <Link href={`/cluster/${c.slug ?? c.id}`} className="mt-0.5 block font-medium leading-snug hover:underline">{c.title}</Link>80                        </div>81                      </li>82                    ))}83                  </ul>84                ) : (85                  <Empty>No cluster matches. Clusters group related signals across sources.</Empty>86                )}87              </Panel>88            </div>89            <aside className="flex min-w-0 flex-col gap-4">90              <Panel title={`Entities · ${r.entities.length}`} dense>91                {r.entities.length ? (92                  <ul className="divide-y divide-line">93                    {r.entities.map((e) => (94                      <li key={e.id} className="flex items-center gap-2 px-3 py-1.5 text-[13px]">95                        <div className="min-w-0 flex-1">96                          <Link href={`/entity/${e.id}`} className="block truncate font-medium hover:underline">{e.name}</Link>97                          <div className="truncate font-mono text-[10.5px] text-fg-subtle">{e.domain ?? e.id}{e.event_count ? ` · ${e.event_count} ev` : ""}</div>98                        </div>99                        <Chip>{e.type.replace(/_/g, " ")}</Chip>100                      </li>101                    ))}102                  </ul>103                ) : (104                  <Empty>No entity matches.</Empty>105                )}106              </Panel>107              <Panel title={`Sources · ${r.sources.length}`} dense>108                {r.sources.length ? (109                  <ul className="divide-y divide-line">110                    {r.sources.map((s) => (111                      <li key={s.id} className="flex items-center gap-2 px-3 py-1.5 text-[13px]">112                        <TierBadge tier={s.tier} />113                        <div className="min-w-0 flex-1">114                          <Link href={`/source/${s.id}`} className="block truncate font-medium hover:underline">{s.name}</Link>115                          <Link href={`/domain/${s.domain}`} className="block truncate font-mono text-[10.5px] text-fg-subtle hover:underline">{s.domain}</Link>116                        </div>117                        {s.country && <Flag code={s.country} className="text-[12px]" />}118                        {s.first_party === false ? <Badge kind="external" compact /> : <Badge kind="first-party" compact />}119                      </li>120                    ))}121                  </ul>122                ) : (123                  <Empty>No source matches.</Empty>124                )}125              </Panel>126              <Panel title={`URLs · ${r.urls.length}`} dense>127                {r.urls.length ? (128                  <ul className="divide-y divide-line">129                    {r.urls.map((u) => (130                      <li key={u.url} className="px-3 py-1.5 text-[12px]">131                        <Link href={`/url?u=${encodeURIComponent(u.url)}`} className="block truncate font-mono hover:underline" title={u.url}>{u.url}</Link>132                        <span className="flex items-center gap-2 text-[11px] text-fg-subtle">133                          <span>{u.change_count} change{u.change_count === 1 ? "" : "s"}</span>134                          <span>·</span>135                          <span>{relTime(u.last_seen_at)}</span>136                          {u.status && u.status !== "active" && <Chip tone="warn" className="font-mono">{typeLabel(u.status)}</Chip>}137                        </span>138                      </li>139                    ))}140                  </ul>141                ) : (142                  <Empty>No monitored URL matches.</Empty>143                )}144              </Panel>145            </aside>146          </div>147        </>148      )}149      {!r && query.length > 0 && query.length < 2 && <Empty>Type at least two characters.</Empty>}150    </>151  );152}153