KOL count: 29 → 25 across marketing/SEO copy

Backend KOL_FEEDS trimmed from 29 to 25 (dead feeds removed).
Sync all hardcoded count mentions:
- layout.tsx JSON-LD, page.tsx (metric + comparison + copy)
- kol/page.tsx, KolPageClient.tsx ("and 26 more" → "and 22 more")
- glossary/page.tsx, opengraph-image.tsx
- public/llms.txt, llms-full.txt
- drop removed KOLs (Dragonfly Capital, Nic Carter) from named lists

Bundles other in-flight frontend work already in the working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
k
2026-06-09 22:55:27 +08:00
parent 9e0f6554cb
commit 4c3c8c6f87
57 changed files with 3464 additions and 1855 deletions
+226 -171
View File
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useLocale } from 'next-intl'
import type {
@@ -42,8 +42,8 @@ function actionLabel(action: KolTicker['action'], isZh: boolean) {
}
function windowLabel(days: number, isZh: boolean) {
if (days === 1) return isZh ? '今日' : 'Today'
return isZh ? `${days}` : `Last ${days}d`
if (days === 1) return 'Today'
return `Last ${days}d`
}
function changeLabel(changeType: KolHoldingChange['change_type'], isZh: boolean) {
@@ -100,6 +100,7 @@ function chainActionLabel(action: string, isZh: boolean) {
function sourceLabel(source: string, isZh: boolean) {
if (source === 'substack') return 'Substack'
if (source === 'blog') return 'Blog'
if (source === 'podcast') return 'Podcast'
if (source === 'twitter') return 'X'
return source
@@ -138,19 +139,20 @@ function DigestWidget({
activeTicker,
isZh,
initialDigest = null,
days,
}: {
onTickerClick: (ticker: string) => void
activeTicker?: string | null
isZh: boolean
initialDigest?: KolDigest | null
days: number
}) {
const [days, setDays] = useState<number>(7)
const [data, setData] = useState<KolDigest | null>(initialDigest)
const [loading, setLoading] = useState(initialDigest === null)
const [err, setErr] = useState('')
useEffect(() => {
if (initialDigest === null || days !== (initialDigest?.window_days ?? 7)) {
if (initialDigest === null || days !== (initialDigest?.window_days ?? 30)) {
setLoading(true)
}
swrFetch(
@@ -160,64 +162,34 @@ function DigestWidget({
fresh => setData(fresh),
)
.then(d => { setData(d); setErr('') })
.catch(e => setErr(e instanceof Error ? e.message : (isZh ? '加载失败' : 'Failed to load digest')))
.catch(e => setErr(e instanceof Error ? e.message : ('Failed to load digest')))
.finally(() => setLoading(false))
}, [days, initialDigest, isZh])
return (
<div style={{
marginBottom: 16, padding: 'clamp(12px, 3vw, 18px)',
borderRadius: 12, background: 'var(--surface)',
border: '1px solid var(--line)',
}}>
<div style={{
display: 'flex', justifyContent: 'space-between',
alignItems: 'center', flexWrap: 'wrap', gap: 8, marginBottom: 12,
}}>
<div>
<div style={{ fontSize: 10, color: 'var(--ink-3)',
letterSpacing: 1, textTransform: 'uppercase',
marginBottom: 2 }}>
What KOLs are pushing now
</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>
{data
? `${data.post_count} posts analysed · ${data.ticker_count} assets with repeat mentions`
: 'Loading…'}
</div>
<div style={{ marginBottom: 10 }}>
{/* Compact meta line — no header label needed, context is obvious */}
{data && !loading && (
<div style={{ fontSize: 12, color: 'var(--ink-4)', marginBottom: 8 }}>
{data.post_count} posts · {data.ticker_count} assets
</div>
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
{WINDOW_OPTIONS.map(daysOption => (
<button
key={daysOption}
onClick={() => setDays(daysOption)}
className={`nav-tab ${days === daysOption ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer', fontSize: 12 }}
>
{windowLabel(daysOption, isZh)}
</button>
))}
</div>
</div>
)}
{loading && (
<div style={{ padding: 16, textAlign: 'center', color: 'var(--ink-3)',
fontSize: 12 }}>Loading</div>
<div style={{ padding: '8px 0', color: 'var(--ink-3)', fontSize: 12 }}>Loading</div>
)}
{err && (
<div style={{ padding: 12, color: '#dc2626', fontSize: 12 }}>{err}</div>
)}
{!loading && !err && data && data.tickers.length === 0 && (
<div style={{ padding: 16, textAlign: 'center', color: 'var(--ink-3)',
fontSize: 13 }}>
No actionable calls in this window. Try a longer range.
<div style={{ padding: '8px 0', color: 'var(--ink-3)', fontSize: 13 }}>
No repeat mentions in this window.
</div>
)}
{!loading && !err && data && data.tickers.length > 0 && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))',
gap: 10,
gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 1fr))',
gap: 8,
}}>
{data.tickers.map(t => <DigestTickerChip
key={t.ticker}
@@ -267,7 +239,7 @@ function DigestTickerChip({
aria-pressed={active}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<strong style={{ fontSize: 18, color: 'var(--ink-1)' }}>
<strong style={{ fontSize: 18, color: 'var(--ink)' }}>
{t.ticker}
</strong>
{active && (
@@ -294,11 +266,10 @@ function DigestTickerChip({
{actionLabel}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--ink-2)', fontWeight: 600 }}>
{digestSideCopy(t.side)}
</div>
{/* Compact: KOL count + handles in two lines, no "Bullish flow" copy
(Long/Short label already conveys direction). */}
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>
{t.kol_count} KOL{t.kol_count !== 1 ? 's' : ''} · {(t.max_conviction * 100).toFixed(0)}% max conviction
{t.kol_count} KOL{t.kol_count !== 1 ? 's' : ''} · {(t.max_conviction * 100).toFixed(0)}%
</div>
<div style={{
fontSize: 11, color: 'var(--ink-3)',
@@ -330,9 +301,11 @@ function WalletCheckWidget({
initialChanges = null,
initialItems = null,
tickerFilter = null,
days,
}: {
isZh: boolean
dateLocale: string
days: number
initialDigest?: KolDigest | null
initialChanges?: KolHoldingChange[] | null
initialItems?: KolDivergence[] | null
@@ -344,41 +317,48 @@ function WalletCheckWidget({
const [loading, setLoading] = useState(
initialDigest === null || initialChanges === null || initialItems === null,
)
const [days, setDays] = useState(7)
// `days` now comes from the parent — no local state needed.
// genRef lets in-flight fetches detect they were superseded by a newer
// days/filter change before writing back to state.
const walletCheckGenRef = useRef(0)
useEffect(() => {
const gen = ++walletCheckGenRef.current
setLoading(true)
Promise.all([
swrFetch(
`kol-digest-${days}`,
15 * 60_000,
() => getKolDigest(days),
fresh => setDigest(fresh),
fresh => { if (gen === walletCheckGenRef.current) setDigest(fresh) },
),
swrFetch(
`kol-changes-${days}`,
30 * 60_000,
() => getKolChanges({ days }),
fresh => setChanges(fresh.changes),
fresh => { if (gen === walletCheckGenRef.current) setChanges(fresh.changes) },
),
swrFetch(
`kol-divergence-all-${days}`,
30 * 60_000,
() => getKolDivergence({ days }),
fresh => setItems(fresh.items),
fresh => { if (gen === walletCheckGenRef.current) setItems(fresh.items) },
),
])
.then(([nextDigest, nextChanges, nextItems]) => {
if (gen !== walletCheckGenRef.current) return
setDigest(nextDigest)
setChanges(nextChanges.changes)
setItems(nextItems.items)
})
.catch(() => {
if (gen !== walletCheckGenRef.current) return
setDigest(null)
setChanges([])
setItems([])
})
.finally(() => setLoading(false))
.finally(() => { if (gen === walletCheckGenRef.current) setLoading(false) })
}, [days])
const rows = useMemo(() => {
@@ -458,43 +438,21 @@ function WalletCheckWidget({
borderRadius: 12, background: 'var(--surface)',
border: '1px solid var(--line)',
}}>
<div style={{
display: 'flex', justifyContent: 'space-between',
alignItems: 'flex-start', flexWrap: 'wrap', gap: 8, marginBottom: 12,
}}>
<div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: 1,
textTransform: 'uppercase', marginBottom: 2 }}>
Talks vs wallets
</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>
{loading
? 'Loading…'
: rows.length === 0
? 'No overlapping talk and wallet evidence in this window.'
: `${totalAligned} aligned · ${totalMismatch} mismatched across the assets KOLs are pushing`}
</div>
{/* Compact summary — no uppercase header, no redundant label */}
{!loading && rows.length > 0 && (
<div style={{ fontSize: 12, color: 'var(--ink-4)', marginBottom: 10 }}>
{totalAligned > 0 && <span style={{ color: '#22c55e', marginRight: 8 }}> {totalAligned} aligned</span>}
{totalMismatch > 0 && <span style={{ color: '#f59e0b' }}> {totalMismatch} mismatch</span>}
{totalAligned === 0 && totalMismatch === 0 && 'Wallet evidence pending'}
</div>
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
{([1, 7, 30] as const).map(d => (
<button
key={d}
onClick={() => setDays(d)}
className={`nav-tab ${days === d ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer', fontSize: 12 }}
>
{windowLabel(d, isZh)}
</button>
))}
</div>
</div>
)}
{!loading && rows.length === 0 && (
<div style={{
padding: '12px 16px', borderRadius: 8, background: 'var(--bg-sunk)',
fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.6,
}}>
When KOLs call an asset, do tracked wallets back it up? No overlap yet in this window widen the range or check back after the next feed run.
No wallet overlap for this window try a wider range.
</div>
)}
@@ -521,7 +479,7 @@ function WalletCheckWidget({
{row.verdict.label}
</span>
</div>
<div style={{ fontSize: 13, color: 'var(--ink-1)', fontWeight: 600, marginBottom: 4 }}>
<div style={{ fontSize: 13, color: 'var(--ink)', fontWeight: 600, marginBottom: 4 }}>
{row.talkLine}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
@@ -533,10 +491,8 @@ function WalletCheckWidget({
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 11, color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 5 }}>
Wallet evidence
</div>
<div style={{ fontSize: 13, color: 'var(--ink-1)', fontWeight: 600, marginBottom: 4 }}>
{/* No "WALLET EVIDENCE" label — context is clear from layout */}
<div style={{ fontSize: 13, color: 'var(--ink)', fontWeight: 600, marginBottom: 4 }}>
{row.latestMatch
? `@${row.latestMatch.handle} ${chainActionLabel(row.latestMatch.onchain_action, isZh).toLowerCase()}`
: row.latestChange
@@ -544,9 +500,13 @@ function WalletCheckWidget({
: 'No tracked move yet'}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.5 }}>
{row.latestMatch
? `${row.verdict.note} ${row.latestMatch.usd_after ? `Size ${formatShortUsd(row.latestMatch.usd_after)}.` : ''}`
: row.verdict.note}
{/* Drop boilerplate "Wallet action supports/disagrees the public pitch." —
the verdict badge already says Aligned / Mismatch. Just show the size. */}
{row.latestMatch?.usd_after
? `Size ${formatShortUsd(row.latestMatch.usd_after)}`
: row.verdict.label === 'No wallet proof'
? 'No tracked wallet move in this window'
: ''}
</div>
{(row.divergenceCount > 0 || row.alignmentCount > 0) && (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 6 }}>
@@ -660,7 +620,7 @@ function PostDetail({
margin: 0, fontSize: 'clamp(16px, 4.5vw, 22px)',
lineHeight: 1.3, wordBreak: 'break-word',
}}>
{post.title || (isZh ? '(无标题)' : '(Untitled)')}
{post.title || post.summary || (post.source === 'twitter' ? '(Tweet)' : '(Untitled)')}
</h2>
</div>
@@ -679,7 +639,7 @@ function PostDetail({
whiteSpace: 'nowrap',
}}
>
{isZh ? `查看原帖 ${postSourceLabel}` : `Open original ${postSourceLabel}`}
{`Open original ${postSourceLabel}`}
</a>
)}
<button
@@ -703,7 +663,7 @@ function PostDetail({
}}>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 4,
letterSpacing: 1, textTransform: 'uppercase' }}>
{isZh ? 'AI 摘要' : 'AI summary'}
{'AI summary'}
</div>
{post.summary}
</div>
@@ -714,7 +674,7 @@ function PostDetail({
<div style={{ margin: '12px 0' }}>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 6,
letterSpacing: 1, textTransform: 'uppercase' }}>
{isZh ? '提取标的' : 'Extracted assets'}
{'Extracted assets'}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{post.tickers.map((t, i) => (
@@ -729,7 +689,7 @@ function PostDetail({
{actionLabel(t.action, isZh)}
</span>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
{isZh ? '信心分' : 'Conviction'} {(t.conviction * 100).toFixed(0)}%
{'Conviction'} {(t.conviction * 100).toFixed(0)}%
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--ink-2)',
@@ -748,7 +708,7 @@ function PostDetail({
}}>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 8,
letterSpacing: 1, textTransform: 'uppercase' }}>
{isZh ? '原文摘录' : 'Source excerpt'}
{'Source excerpt'}
</div>
<div style={{
whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7,
@@ -767,8 +727,12 @@ function PostDetail({
return createPortal(node, document.body)
}
type SourceFilter = 'all' | 'substack' | 'blog' | 'podcast' | 'twitter'
const KOL_SERVER_PAGE_SIZE = 50
interface KolPageProps {
initialPosts?: KolPostSummary[] | null
initialTotal?: number
initialDigest?: KolDigest | null
initialChanges?: KolHoldingChange[] | null
initialDivergence?: KolDivergence[] | null
@@ -776,78 +740,142 @@ interface KolPageProps {
export default function KolPage({
initialPosts = null,
initialTotal = 0,
initialDigest = null,
initialChanges = null,
initialDivergence = null,
}: KolPageProps) {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const dateLocale = isZh ? 'zh-CN' : 'en-US'
const KOL_PAGE_SIZE = 20
const [posts, setPosts] = useState<KolPostSummary[]>(initialPosts ?? [])
const [loading, setLoading] = useState(initialPosts === null)
const [err, setErr] = useState('')
const [openPost, setOpenPost] = useState<KolPostDetail | null>(null)
const [handleFilter, setHandleFilter] = useState<string>('all')
const [tickerFilter, setTickerFilter] = useState<string | null>(null)
const [kolPage, setKolPage] = useState(1)
const dateLocale = 'en-US'
const isZh = false // i18n shelved — passed to helper fns that still take the param
const [posts, setPosts] = useState<KolPostSummary[]>(initialPosts ?? [])
const [serverTotal, setServerTotal] = useState(initialTotal)
const [loading, setLoading] = useState(initialPosts === null)
const [err, setErr] = useState('')
const [openPost, setOpenPost] = useState<KolPostDetail | null>(null)
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
const [signalsOnly, setSignalsOnly] = useState(false)
const [tickerFilter, setTickerFilter] = useState<string | null>(null)
const [serverPage, setServerPage] = useState(1)
// Unified time window — controls both DigestWidget and WalletCheckWidget.
// Default 30d, not 7d: KOL ingestion is daily and sparse, so a 7d window is
// frequently empty (e.g. 7d=0 while 30d=196) and the first paint showed
// "No data yet" for modules that actually have data.
const [kolDays, setKolDays] = useState(30)
const postsGenRef = useRef(0)
useEffect(() => {
const gen = ++postsGenRef.current
setLoading(true)
const src = sourceFilter === 'all' ? undefined : sourceFilter
const cacheKey = `kol-posts-${sourceFilter}-${signalsOnly}-${serverPage}-${tickerFilter ?? ''}-${kolDays}`
swrFetch(
'kol-posts-100',
cacheKey,
15 * 60_000, // 15 min TTL — KOL feed is ingested daily
() => getKolPosts({ limit: 100 }),
fresh => setPosts(fresh.items),
() => getKolPosts({
limit: KOL_SERVER_PAGE_SIZE, page: serverPage, source: src, signalsOnly,
ticker: tickerFilter ?? undefined, days: kolDays,
}),
fresh => { if (gen === postsGenRef.current) { setPosts(fresh.items); setServerTotal(fresh.total ?? 0) } },
)
.then(r => { setPosts(r.items); setErr('') })
.catch(e => setErr(e instanceof Error ? e.message : (isZh ? '加载失败' : 'Failed to load posts')))
.finally(() => setLoading(false))
}, [isZh])
.then(r => {
if (gen !== postsGenRef.current) return
setPosts(r.items); setServerTotal(r.total ?? 0); setErr('')
})
.catch(e => { if (gen === postsGenRef.current) setErr(e instanceof Error ? e.message : ('Failed to load posts')) })
.finally(() => { if (gen === postsGenRef.current) setLoading(false) })
}, [sourceFilter, signalsOnly, serverPage, tickerFilter, kolDays])
const handles = useMemo(() => {
const set = new Set(posts.map(p => p.kol_handle))
return ['all', ...Array.from(set)]
}, [posts])
// posts are already filtered server-side; no client-side re-filter needed
const filtered = posts
const filtered = useMemo(() => {
let out = handleFilter === 'all' ? posts : posts.filter(p => p.kol_handle === handleFilter)
if (tickerFilter) {
out = out.filter(p => p.tickers.some(t => t.ticker === tickerFilter))
}
return out
}, [posts, handleFilter, tickerFilter])
const kolTotalPages = Math.max(1, Math.ceil(filtered.length / KOL_PAGE_SIZE))
const kolSafePage = Math.min(kolPage, kolTotalPages)
const kolPageItems = filtered.slice((kolSafePage - 1) * KOL_PAGE_SIZE, kolSafePage * KOL_PAGE_SIZE)
const totalServerPages = Math.max(1, Math.ceil(serverTotal / KOL_SERVER_PAGE_SIZE))
async function openDetail(id: number) {
try {
const detail = await getKolPost(id)
setOpenPost(detail)
} catch (e) {
setErr(e instanceof Error ? e.message : (isZh ? '详情加载失败' : 'Failed to load detail'))
setErr(e instanceof Error ? e.message : ('Failed to load detail'))
}
}
function changeSource(src: SourceFilter) {
setSourceFilter(src)
setServerPage(1)
setTickerFilter(null)
}
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? 'KOL 信号' : 'KOL Signals'}</h1>
<PageHint count={`${posts.length} posts`}>
Which assets KOLs are pushing right now and whether tracked wallets back it up or call their bluff.
<h1 className="page-title">{'KOL Signals'}</h1>
<PageHint count={`${serverTotal} posts`}>
Arthur Hayes, Delphi, Bankless, and 22 more their public calls vs what their wallets actually do.
</PageHint>
</div>
{/* Single time filter controlling both widgets */}
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)', alignSelf: 'flex-start' }}>
{WINDOW_OPTIONS.map(d => (
<button
key={d}
onClick={() => { setKolDays(d); setServerPage(1) }}
className={`nav-tab ${kolDays === d ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer', fontSize: 12 }}
>
{windowLabel(d, isZh)}
</button>
))}
</div>
</div>
{/* Source filter — All / Substack / X + Signals only toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12, flexWrap: 'wrap' }}>
{(['all', 'substack', 'blog', 'podcast', 'twitter'] as SourceFilter[]).map(src => {
const label = src === 'all' ? 'All' : src === 'substack' ? 'Substack' : src === 'blog' ? 'Blog' : src === 'podcast' ? 'Podcast' : 'X (Twitter)'
const active = sourceFilter === src
return (
<button
key={src}
onClick={() => changeSource(src)}
style={{
padding: '4px 12px', borderRadius: 6, fontSize: 12, fontWeight: 600,
cursor: 'pointer', border: '1px solid var(--line)',
background: active ? 'var(--amber)' : 'var(--surface)',
color: active ? '#000' : 'var(--ink-2)',
transition: 'background 0.15s, color 0.15s',
}}
>
{label}
</button>
)
})}
<button
onClick={() => { setSignalsOnly(v => !v); setServerPage(1) }}
title="Exclude noise posts (short tweets, gm, RT). Long-form and directional posts are kept."
style={{
marginLeft: 8,
padding: '4px 12px', borderRadius: 6, fontSize: 12, fontWeight: 600,
cursor: 'pointer',
border: `1px solid ${signalsOnly ? '#16a34a88' : 'var(--line)'}`,
background: signalsOnly ? '#16a34a22' : 'var(--surface)',
color: signalsOnly ? '#16a34a' : 'var(--ink-3)',
transition: 'background 0.15s, color 0.15s, border-color 0.15s',
}}
>
{signalsOnly ? '✓ Signals only' : 'Signals only'}
</button>
</div>
<DigestWidget
isZh={isZh}
initialDigest={initialDigest}
activeTicker={tickerFilter}
days={kolDays}
onTickerClick={(sym) => {
setTickerFilter(prev => prev === sym ? null : sym)
setKolPage(1)
setServerPage(1)
}}
/>
@@ -858,6 +886,7 @@ export default function KolPage({
initialChanges={initialChanges}
initialItems={initialDivergence}
tickerFilter={tickerFilter}
days={kolDays}
/>
{tickerFilter && (
@@ -865,47 +894,38 @@ export default function KolPage({
display: 'flex', alignItems: 'center', gap: 8,
marginBottom: 12, fontSize: 12,
}}>
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '当前筛选标的:' : 'Filtered asset:'}</span>
<span style={{ color: 'var(--ink-3)' }}>{'Filtered asset:'}</span>
<strong>{tickerFilter}</strong>
<button
onClick={() => { setTickerFilter(null); setKolPage(1) }}
onClick={() => { setTickerFilter(null); setServerPage(1) }}
style={{
background: 'var(--bg-sunk)', border: '1px solid var(--line)',
borderRadius: 4, padding: '2px 8px',
cursor: 'pointer', fontSize: 11, color: 'var(--ink-2)',
}}
>{isZh ? '清除 ✕' : 'Clear ✕'}</button>
>{'Clear ✕'}</button>
</div>
)}
{handles.length > 2 && (
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)', marginBottom: 12 }}>
{handles.map(h => (
<button
key={h}
onClick={() => { setHandleFilter(h); setKolPage(1) }}
className={`nav-tab ${handleFilter === h ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer' }}
>
{h === 'all' ? (isZh ? `全部 (${posts.length})` : `All (${posts.length})`) : `@${h}`}
</button>
))}
</div>
)}
{/* KOL handle filter removed — 25 handles as a horizontal tab bar
overflows on every screen size and most users filter by asset (ticker),
not by person. Handle is visible on each post card. */}
<div style={{ margin: '12px 0 4px', fontSize: 11, fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--ink-4)' }}>
All posts · paginated feed
</div>
{loading && <KolPostsSkeleton />}
{err && <div style={{ padding: 20, color: '#dc2626' }}>{isZh ? `错误:${err}` : `Error: ${err}`}</div>}
{err && <div style={{ padding: 20, color: '#dc2626' }}>{`Error: ${err}`}</div>}
{!loading && !err && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.length === 0 && (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--ink-3)' }}>
{isZh
? '当前没有数据,等待下一轮抓取任务(每天 01:15 UTC)。'
: 'No data yet. Wait for the next feed ingestion run (daily 01:15 UTC).'}
{'No data yet. Wait for the next feed ingestion run (daily 01:15 UTC).'}
</div>
)}
{kolPageItems.map(p => (
{filtered.map(p => (
<div
key={p.id}
onClick={() => openDetail(p.id)}
@@ -927,10 +947,44 @@ export default function KolPage({
@{p.kol_handle} · {sourceLabel(p.source, isZh)} · {new Date(p.published_at).toLocaleDateString(dateLocale)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
{p.tier === 'trade_signal' && (
<span style={{
fontSize: 10, fontWeight: 700, letterSpacing: '0.04em',
padding: '2px 6px', borderRadius: 4,
background: '#16a34a22', color: '#16a34a',
border: '1px solid #16a34a44',
whiteSpace: 'nowrap',
}}>
SIGNAL
</span>
)}
{p.tier === 'directional' && (
<span style={{
fontSize: 10, fontWeight: 700, letterSpacing: '0.04em',
padding: '2px 6px', borderRadius: 4,
background: '#f59e0b22', color: '#f59e0b',
border: '1px solid #f59e0b44',
whiteSpace: 'nowrap',
}}>
VIEW
</span>
)}
{p.talks_vs_trades_flag && (
<span title="Public stance contradicts revealed position in this post"
style={{
fontSize: 10, fontWeight: 700,
padding: '2px 6px', borderRadius: 4,
background: '#ef444422', color: '#ef4444',
border: '1px solid #ef444444',
whiteSpace: 'nowrap',
}}>
FLIP
</span>
)}
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
{p.analyzed_at
? (isZh ? '✓ 已分析' : '✓ Analyzed')
: (isZh ? '⏳ 待分析' : '⏳ Pending')}
? ('✓ Analyzed')
: ('⏳ Pending')}
</span>
{p.url && (
<a
@@ -946,16 +1000,17 @@ export default function KolPage({
whiteSpace: 'nowrap',
}}
>
{isZh ? '原帖 ↗' : 'Source ↗'}
{'Source ↗'}
</a>
)}
</div>
</div>
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 6,
wordBreak: 'break-word' }}>
{p.title || (isZh ? '(无标题)' : '(Untitled)')}
{p.title || p.summary || (p.source === 'twitter' ? '(Tweet)' : '(Untitled)')}
</div>
{p.summary && (
{/* Only show summary as a separate line when there's a real title above it */}
{p.title && p.summary && (
<div style={{ fontSize: 13, color: 'var(--ink-2)', lineHeight: 1.5,
marginBottom: 8 }}>
{p.summary}
@@ -966,11 +1021,11 @@ export default function KolPage({
))}
<Pagination
page={kolSafePage}
total={kolTotalPages}
count={filtered.length}
pageSize={KOL_PAGE_SIZE}
onChange={setKolPage}
page={serverPage}
total={totalServerPages}
count={serverTotal}
pageSize={KOL_SERVER_PAGE_SIZE}
onChange={setServerPage}
scrollTop={false}
/>
</div>