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:
+226
-171
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user