diff --git a/src/components/explore/ExploreLanding.tsx b/src/components/explore/ExploreLanding.tsx index f7fbbc6..498549c 100644 --- a/src/components/explore/ExploreLanding.tsx +++ b/src/components/explore/ExploreLanding.tsx @@ -6,6 +6,7 @@ import { Telescope, User } from 'lucide-react'; import AppearIn from './AppearIn'; import SearchBox from './SearchBox'; import JetstreamFeed from './JetstreamFeed'; +import TrendingLexicons from './TrendingLexicons'; import CrossLinkCards from '@/components/landing/CrossLinkCards'; import { useAtprotoSession } from '@/components/AtprotoSessionProvider'; import { getProfile, type AppViewProfile } from '@/utils/atproto/appview'; @@ -141,6 +142,8 @@ export default function ExploreLanding() { + + diff --git a/src/components/explore/TrendingLexicons.tsx b/src/components/explore/TrendingLexicons.tsx new file mode 100644 index 0000000..612dcfe --- /dev/null +++ b/src/components/explore/TrendingLexicons.tsx @@ -0,0 +1,381 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { TrendingUp } from 'lucide-react'; +import AppearIn from './AppearIn'; + +const UFOS_API = 'https://ufos-api.microcosm.blue'; + +type Window = '1d' | '7d' | '30d'; + +type WindowConfig = { + label: string; + /** Hours covered, used to build `since` ISO. */ + hours: number; + /** Step in seconds for the timeseries — sized so each window renders + * ~10-30 sparkline buckets. */ + step: number; + /** Number of trailing buckets to keep on the chart. */ + bucketCount: number; +}; + +const WINDOWS: Record = { + '1d': { label: '1d', hours: 24, step: 60 * 60 * 2, bucketCount: 12 }, + '7d': { label: '7d', hours: 24 * 7, step: 60 * 60 * 12, bucketCount: 14 }, + '30d': { label: '30d', hours: 24 * 30, step: 60 * 60 * 24, bucketCount: 30 }, +}; + +type CollectionRow = { + nsid: string; + creates: number; + dids_estimate: number; + /** Bucketed creates for the sparkline. Length matches WINDOWS[window].bucketCount. */ + series: number[]; +}; + +/** + * Trending / top lexicons strip on /explore. Hits the UFOs API + * (ufos-api.microcosm.blue) to rank collections by `creates` in the + * chosen window, then fetches a per-collection timeseries to render + * a sparkline next to each row. + * + * One toggle row (1d / 7d / 30d) re-fetches both the ranking and the + * sparklines. Each fetch is independent; the previous window's data + * stays on-screen while the new one loads so the layout doesn't + * collapse. + */ +export default function TrendingLexicons() { + const [window, setWindow] = useState('7d'); + const [rows, setRows] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + (async () => { + try { + const next = await fetchTrending(window); + if (!cancelled) setRows(next); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [window]); + + return ( + +
+
+
+ {error ? ( +
+ Couldn’t reach the trending API: {error} +
+ ) : rows ? ( +
    + {rows.map((row, i) => ( + + ))} +
+ ) : loading ? ( +

+ Loading trending lexicons… +

+ ) : null} +
+
+
+ ); +} + +function Header({ + window, + setWindow, +}: { + window: Window; + setWindow: (w: Window) => void; +}) { + return ( +
+
+ + Trending lexicons + + via UFOs + +
+
+ {(Object.keys(WINDOWS) as Window[]).map((w) => { + const active = w === window; + return ( + + ); + })} +
+
+ ); +} + +function Row({ + row, + rank, + window, + isLast, +}: { + row: CollectionRow; + rank: number; + window: Window; + isLast: boolean; +}) { + return ( +
  • + { + e.currentTarget.style.background = 'var(--bg-tertiary)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'transparent'; + }} + > + + {rank.toString().padStart(2, '0')} + + + {row.nsid} + + + + + + {formatCount(row.creates)} + + +
  • + ); +} + +function Sparkline({ data, window }: { data: number[]; window: Window }) { + const width = 90; + const height = 24; + const path = useMemo(() => buildPath(data, width, height), [data]); + + return ( + + {path && ( + + )} + + ); +} + +function buildPath(data: number[], w: number, h: number): string | null { + if (data.length === 0) return null; + const min = Math.min(...data); + const max = Math.max(...data); + const span = max - min || 1; + const stepX = data.length > 1 ? w / (data.length - 1) : 0; + const pad = 2; + const useable = h - pad * 2; + return data + .map((v, i) => { + const x = i * stepX; + const y = pad + useable - ((v - min) / span) * useable; + return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`; + }) + .join(' '); +} + +function formatCount(n: number): string { + if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return n.toLocaleString(); +} + +// ─── Fetching ────────────────────────────────────────────────────────────── + +const TOP_N = 10; + +async function fetchTrending(window: Window): Promise { + const cfg = WINDOWS[window]; + const sinceIso = new Date(Date.now() - cfg.hours * 60 * 60 * 1000).toISOString(); + + // Step 1: rank collections by creates in the window. + const url = + `${UFOS_API}/collections` + + `?order=records-created&limit=${TOP_N}&since=${encodeURIComponent(sinceIso)}`; + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const data = (await res.json()) as { + collections?: { nsid: string; creates: number; dids_estimate: number }[]; + }; + const top = data.collections || []; + + // Step 2: timeseries per collection. Fetched in parallel; failures + // leave that row with an empty series rather than dropping it. + const series = await Promise.all( + top.map((c) => fetchSeries(c.nsid, sinceIso, cfg.step, cfg.bucketCount)), + ); + + return top.map((c, i) => ({ + nsid: c.nsid, + creates: c.creates, + dids_estimate: c.dids_estimate, + series: series[i], + })); +} + +async function fetchSeries( + collection: string, + sinceIso: string, + step: number, + bucketCount: number, +): Promise { + const url = + `${UFOS_API}/timeseries` + + `?collection=${encodeURIComponent(collection)}` + + `&since=${encodeURIComponent(sinceIso)}` + + `&step=${step}`; + try { + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) return []; + const data = (await res.json()) as { + series?: Record; + }; + const bucket = data.series?.[collection] || []; + const trimmed = bucket.slice(-bucketCount); + return trimmed.map((b) => b.creates ?? 0); + } catch { + return []; + } +}