Files
trumpsignal-frontend/app/[locale]/kol/KolPageClient.tsx
T

1060 lines
39 KiB
TypeScript

'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useLocale } from 'next-intl'
import type {
KolPostSummary, KolPostDetail, KolTicker,
KolDigest, KolDigestTicker, KolHoldingChange, KolDivergence,
} from '@/types'
import { getKolPosts, getKolPost, getKolDigest, getKolChanges, getKolDivergence } from '@/lib/api'
import { swrFetch } from '@/lib/cache'
import PageHint from '@/components/ui/PageHint'
import Pagination from '@/components/ui/Pagination'
/**
* KOL feed — independent module. Lists analyzed posts from tracked KOLs
* (Substack today, Twitter later). Click a row to open the full post body
* + structured ticker calls inline.
*/
const ACTION_COLOR: Record<KolTicker['action'], string> = {
buy: '#16a34a',
bullish: '#16a34a',
sell: '#dc2626',
bearish: '#dc2626',
reduce: '#f59e0b',
mention: '#9ca3af',
}
const WINDOW_OPTIONS = [1, 7, 30, 90] as const
function actionLabel(action: KolTicker['action'], isZh: boolean) {
const labels: Record<KolTicker['action'], string> = {
buy: 'BUY',
sell: 'SELL',
reduce: 'Reduce',
bullish: 'Bullish',
bearish: 'Bearish',
mention: 'Mention',
}
return labels[action]
}
function windowLabel(days: number, isZh: boolean) {
if (days === 1) return 'Today'
return `Last ${days}d`
}
function changeLabel(changeType: KolHoldingChange['change_type'], isZh: boolean) {
const labels: Record<KolHoldingChange['change_type'], string> = {
new_position: 'New',
increased: 'Added',
decreased: 'Reduced',
closed: 'Closed',
}
return labels[changeType]
}
function divergenceMeta(signalType: KolDivergence['signal_type'], isZh: boolean) {
return signalType === 'divergence'
? {
label: '⚠️ Divergence',
color: '#f59e0b',
bg: '#f59e0b22',
tip: 'Public stance and wallet action disagree. Follow the wallet, not the words.',
}
: {
label: '✅ Alignment',
color: '#22c55e',
bg: '#22c55e22',
tip: 'Public stance and wallet action align, which increases confidence.',
}
}
function directionLabel(direction: KolDivergence['direction'], isZh: boolean) {
if (direction === 'long') return 'Long ▲'
return 'Short ▼'
}
function postActionLabel(action: string, isZh: boolean) {
const labels: Record<string, string> = {
buy: 'BUY',
sell: 'SELL',
reduce: 'Reducing',
bullish: 'Bullish post',
bearish: 'Bearish post',
}
return labels[action] ?? action
}
function chainActionLabel(action: string, isZh: boolean) {
const labels: Record<string, string> = {
new_position: 'Opened on-chain',
increased: 'Added on-chain',
decreased: 'Reduced on-chain',
closed: 'Closed on-chain',
}
return labels[action] ?? action
}
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
}
function formatShortUsd(value: number) {
if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`
return `$${(value / 1e3).toFixed(0)}K`
}
function digestSideCopy(side: KolDigestTicker['side']) {
if (side === 'long') return 'Bullish flow'
if (side === 'short') return 'Bearish flow'
return 'Mention flow'
}
function compactChangeCopy(change: KolHoldingChange) {
const verb = change.change_type === 'new_position'
? 'opened'
: change.change_type === 'increased'
? 'added'
: change.change_type === 'decreased'
? 'reduced'
: 'closed'
const size = change.usd_after != null && change.usd_after > 0
? ` ${formatShortUsd(change.usd_after)}`
: ''
const pct = change.pct_change != null
? ` (${change.pct_change > 0 ? '+' : ''}${change.pct_change.toFixed(0)}%)`
: ''
return `@${change.handle} ${verb}${size}${pct}`
}
function DigestWidget({
onTickerClick,
activeTicker,
isZh,
initialDigest = null,
days,
}: {
onTickerClick: (ticker: string) => void
activeTicker?: string | null
isZh: boolean
initialDigest?: KolDigest | null
days: number
}) {
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 ?? 30)) {
setLoading(true)
}
swrFetch(
`kol-digest-${days}`,
15 * 60_000,
() => getKolDigest(days),
fresh => setData(fresh),
)
.then(d => { setData(d); setErr('') })
.catch(e => setErr(e instanceof Error ? e.message : ('Failed to load digest')))
.finally(() => setLoading(false))
}, [days, initialDigest, isZh])
return (
<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>
)}
{loading && (
<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: '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(170px, 1fr))',
gap: 8,
}}>
{data.tickers.map(t => <DigestTickerChip
key={t.ticker}
t={t}
isZh={isZh}
active={activeTicker === t.ticker}
hasActive={!!activeTicker}
onClick={() => onTickerClick(t.ticker)}
/>)}
</div>
)}
</div>
)
}
function DigestTickerChip({
t, onClick, isZh, active, hasActive,
}: { t: KolDigestTicker; onClick: () => void; isZh: boolean; active: boolean; hasActive: boolean }) {
const sideColor = t.side === 'long' ? '#16a34a'
: t.side === 'short' ? '#dc2626' : '#9ca3af'
const sideLabel = t.side === 'long'
? 'Long'
: t.side === 'short'
? 'Short'
: 'Mention'
const actionLabel = t.dominant_action === 'buy' ? 'BUY'
: t.dominant_action === 'sell' ? 'SELL'
: sideLabel
return (
<button
onClick={onClick}
title={`${t.kols.map(k => '@' + k).join(', ')} · ${t.post_count} signals`}
style={{
display: 'inline-flex', flexDirection: 'column',
alignItems: 'flex-start', gap: 4,
padding: '12px 14px', borderRadius: 12,
background: active ? `${sideColor}12` : hasActive ? 'color-mix(in oklab, var(--bg-sunk) 92%, transparent)' : 'var(--bg-sunk)',
border: active ? `2px solid ${sideColor}` : `1px solid ${sideColor}55`,
boxShadow: active ? `0 10px 24px ${sideColor}22` : 'none',
cursor: 'pointer', textAlign: 'left',
// No fixed minHeight — content is 3 short lines; 108px left the bottom
// ~40% of every chip empty (worst on phones, 2 chips per row).
width: '100%',
transform: active ? 'translateY(-2px)' : 'none',
opacity: hasActive && !active ? 0.6 : 1,
transition: 'background 120ms, border-color 120ms, box-shadow 120ms, transform 120ms, opacity 120ms',
}}
aria-pressed={active}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<strong style={{ fontSize: 18, color: 'var(--ink)' }}>
{t.ticker}
</strong>
{active && (
<span style={{
width: 18,
height: 18,
borderRadius: 999,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: sideColor,
color: '#fff',
fontSize: 11,
fontWeight: 800,
}}>
</span>
)}
<span style={{
fontSize: 10, fontWeight: 700, color: sideColor,
padding: '2px 6px', borderRadius: 999,
background: `${sideColor}22`,
}}>
{actionLabel}
</span>
</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)}%
</div>
<div style={{
fontSize: 11, color: 'var(--ink-3)',
maxWidth: '100%', whiteSpace: 'nowrap',
overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{t.kols.map(k => '@' + k).join(', ')}
</div>
{active && (
<div style={{ fontSize: 11, fontWeight: 700, color: sideColor, marginTop: 2 }}>
Filtering the page by {t.ticker}
</div>
)}
</button>
)
}
const CHANGE_COLOR: Record<KolHoldingChange['change_type'], string> = {
new_position: '#16a34a',
increased: '#22c55e',
decreased: '#f59e0b',
closed: '#dc2626',
}
function WalletCheckWidget({
isZh,
dateLocale,
initialDigest = null,
initialChanges = null,
initialItems = null,
tickerFilter = null,
days,
}: {
isZh: boolean
dateLocale: string
days: number
initialDigest?: KolDigest | null
initialChanges?: KolHoldingChange[] | null
initialItems?: KolDivergence[] | null
tickerFilter?: string | null
}) {
const [digest, setDigest] = useState<KolDigest | null>(initialDigest)
const [changes, setChanges] = useState<KolHoldingChange[]>(initialChanges ?? [])
const [items, setItems] = useState<KolDivergence[]>(initialItems ?? [])
const [loading, setLoading] = useState(
initialDigest === null || initialChanges === null || initialItems === null,
)
// `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 => { if (gen === walletCheckGenRef.current) setDigest(fresh) },
),
swrFetch(
`kol-changes-${days}`,
30 * 60_000,
() => getKolChanges({ days }),
fresh => { if (gen === walletCheckGenRef.current) setChanges(fresh.changes) },
),
swrFetch(
`kol-divergence-all-${days}`,
30 * 60_000,
() => getKolDivergence({ days }),
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(() => { if (gen === walletCheckGenRef.current) setLoading(false) })
}, [days])
const rows = useMemo(() => {
const sourceTickers = digest?.tickers ?? []
const filteredTickers = tickerFilter
? sourceTickers.filter(t => t.ticker === tickerFilter)
: sourceTickers
return filteredTickers.map(t => {
const relatedChanges = changes
.filter(c => c.ticker === t.ticker)
.sort((a, b) => +new Date(b.detected_at) - +new Date(a.detected_at))
const relatedItems = items
.filter(i => i.ticker === t.ticker)
.sort((a, b) => +new Date(b.onchain_at) - +new Date(a.onchain_at))
const divergenceCount = relatedItems.filter(i => i.signal_type === 'divergence').length
const alignmentCount = relatedItems.filter(i => i.signal_type === 'alignment').length
const latestChange = relatedChanges[0] ?? null
const latestMatch = relatedItems[0] ?? null
const verdict =
divergenceCount > 0 && alignmentCount === 0
? {
label: 'Mismatch',
color: '#f59e0b',
bg: '#f59e0b22',
note: 'Wallet action disagrees with the public pitch.',
}
: alignmentCount > 0 && divergenceCount === 0
? {
label: 'Aligned',
color: '#22c55e',
bg: '#22c55e22',
note: 'Wallet action supports the public pitch.',
}
: alignmentCount > 0 && divergenceCount > 0
? {
label: 'Mixed',
color: '#a855f7',
bg: '#a855f722',
note: 'Different KOLs or time windows point in both directions.',
}
: latestChange
? {
label: 'Watch',
color: CHANGE_COLOR[latestChange.change_type],
bg: `${CHANGE_COLOR[latestChange.change_type]}22`,
note: 'Wallet movement exists, but no clean talk-vs-wallet match yet.',
}
: {
label: 'No wallet proof',
color: '#9ca3af',
bg: '#9ca3af22',
note: 'No tracked wallet move linked to this asset in the selected window.',
}
return {
ticker: t.ticker,
talkLine: `${t.kol_count} KOLs pushing ${t.side === 'short' ? 'bearish' : t.side === 'long' ? 'bullish' : 'mixed'} flow`,
speakers: t.kols.map(k => '@' + k).join(', '),
conviction: `${(t.max_conviction * 100).toFixed(0)}% max conviction`,
verdict,
divergenceCount,
alignmentCount,
latestChange,
latestMatch,
}
})
}, [changes, digest, items, tickerFilter])
const totalMismatch = rows.filter(r => r.verdict.label === 'Mismatch').length
const totalAligned = rows.filter(r => r.verdict.label === 'Aligned').length
return (
<div style={{
marginBottom: 16, padding: 'clamp(12px, 3vw, 18px)',
borderRadius: 12, background: 'var(--surface)',
border: '1px solid var(--line)',
}}>
{/* 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>
)}
{!loading && rows.length === 0 && (
<div style={{
padding: '12px 16px', borderRadius: 8, background: 'var(--bg-sunk)',
fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.6,
}}>
No wallet overlap for this window try a wider range.
</div>
)}
{!loading && rows.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{rows.map(row => (
<div key={row.ticker} style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1.4fr) minmax(0, 1.4fr) auto',
gap: 12,
alignItems: 'start',
padding: '14px 16px',
borderRadius: 12,
background: 'var(--bg-sunk)',
border: '1px solid var(--line)',
}}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6, flexWrap: 'wrap' }}>
<strong style={{ fontSize: 18 }}>{row.ticker}</strong>
<span style={{
fontSize: 10, fontWeight: 700, color: row.verdict.color,
padding: '3px 8px', borderRadius: 999, background: row.verdict.bg,
}}>
{row.verdict.label}
</span>
</div>
{/* Speakers + conviction dropped — the digest chip for the same
ticker directly above already shows the handles and max
conviction %. This column keeps only the one-line talk summary. */}
<div style={{ fontSize: 13, color: 'var(--ink)', fontWeight: 600 }}>
{row.talkLine}
</div>
</div>
<div style={{ minWidth: 0 }}>
{/* 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
? compactChangeCopy(row.latestChange)
: 'No tracked move yet'}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.5 }}>
{/* 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 }}>
{row.alignmentCount > 0 && (
<span style={{ fontSize: 11, color: '#22c55e' }}>
{row.alignmentCount} alignment
</span>
)}
{row.divergenceCount > 0 && (
<span style={{ fontSize: 11, color: '#f59e0b' }}>
{row.divergenceCount} divergence
</span>
)}
</div>
)}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', textAlign: 'right', whiteSpace: 'nowrap' }}>
{row.latestMatch
? new Date(row.latestMatch.onchain_at).toLocaleDateString(dateLocale)
: row.latestChange
? new Date(row.latestChange.detected_at).toLocaleDateString(dateLocale)
: 'No date'}
</div>
</div>
))}
</div>
)}
</div>
)
}
function TickerChips({ tickers, isZh }: { tickers: KolTicker[]; isZh: boolean }) {
// No tickers → render nothing. A lone "—" dangled under every card whose
// post had no extracted assets (most of the feed).
if (!tickers.length) return null
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{tickers.map((t, i) => (
<span
key={`${t.ticker}-${i}`}
title={t.quote}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 999,
fontSize: 11, fontWeight: 600,
background: 'var(--bg-sunk)',
border: `1px solid ${ACTION_COLOR[t.action]}33`,
color: ACTION_COLOR[t.action],
}}
>
{t.ticker}
<span style={{ opacity: 0.7, fontWeight: 500 }}>
{actionLabel(t.action, isZh)}
</span>
<span style={{ opacity: 0.55, fontWeight: 500 }}>
{(t.conviction * 100).toFixed(0)}
</span>
</span>
))}
</div>
)
}
function PostDetail({
post, onClose, isZh, dateLocale,
}: { post: KolPostDetail; onClose: () => void; isZh: boolean; dateLocale: string }) {
// Lock body scroll while open
useEffect(() => {
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
}, [])
const postSourceLabel = sourceLabel(post.source, isZh)
const node = (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.82)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
zIndex: 9999, display: 'flex', alignItems: 'flex-start',
justifyContent: 'center',
padding: 'clamp(8px, 4vw, 40px) clamp(8px, 3vw, 20px)',
overflowY: 'auto',
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
background: 'var(--bg)', borderRadius: 14,
maxWidth: 820, width: '100%',
padding: 'clamp(16px, 4vw, 28px)',
border: '1px solid var(--line)',
boxShadow: '0 24px 80px rgba(0,0,0,0.6)',
}}
>
{/* ── Header: 标题 + 关闭 + 查看原帖 ── */}
<div style={{
display: 'flex', justifyContent: 'space-between',
alignItems: 'flex-start', gap: 12, marginBottom: 12,
}}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginBottom: 6 }}>
@{post.kol_handle} · {postSourceLabel} · {new Date(post.published_at).toLocaleString(dateLocale)}
</div>
<h2 style={{
margin: 0, fontSize: 'clamp(16px, 4.5vw, 22px)',
lineHeight: 1.3, wordBreak: 'break-word',
}}>
{post.title || post.summary || (post.source === 'twitter' ? '(Tweet)' : '(Untitled)')}
</h2>
</div>
{/* 右上角按钮组 */}
<div style={{ display: 'flex', gap: 8, flexShrink: 0, alignItems: 'center' }}>
{post.url && (
<a
href={post.url}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '5px 12px', borderRadius: 6,
background: 'var(--amber)', color: '#000',
fontSize: 12, fontWeight: 700, textDecoration: 'none',
whiteSpace: 'nowrap',
}}
>
{`Open original ${postSourceLabel}`}
</a>
)}
<button
onClick={onClose}
aria-label="Close"
style={{
background: 'var(--bg-sunk)', border: '1px solid var(--line)',
borderRadius: 6, padding: '5px 10px', cursor: 'pointer',
color: 'var(--ink-2)', fontSize: 14, lineHeight: 1,
}}
></button>
</div>
</div>
{/* ── AI 摘要 ── */}
{post.summary && (
<div style={{
margin: '12px 0', padding: 12, borderRadius: 8,
background: 'var(--bg-sunk)', borderLeft: '3px solid var(--amber)',
fontSize: 14, lineHeight: 1.6,
}}>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 4,
letterSpacing: 1, textTransform: 'uppercase' }}>
{'AI summary'}
</div>
{post.summary}
</div>
)}
{/* ── 喊单标的 ── */}
{post.tickers.length > 0 && (
<div style={{ margin: '12px 0' }}>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 6,
letterSpacing: 1, textTransform: 'uppercase' }}>
{'Extracted assets'}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{post.tickers.map((t, i) => (
<div key={i} style={{
padding: 10, borderRadius: 8,
background: 'var(--bg-sunk)',
borderLeft: `3px solid ${ACTION_COLOR[t.action]}`,
}}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
<strong style={{ fontSize: 14 }}>{t.ticker}</strong>
<span style={{ color: ACTION_COLOR[t.action], fontSize: 12, fontWeight: 600 }}>
{actionLabel(t.action, isZh)}
</span>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
{'Conviction'} {(t.conviction * 100).toFixed(0)}%
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--ink-2)',
fontStyle: 'italic', lineHeight: 1.5 }}>
&ldquo;{t.quote}&rdquo;
</div>
</div>
))}
</div>
</div>
)}
{/* ── 原文 ── */}
<div style={{
marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--line)',
}}>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 8,
letterSpacing: 1, textTransform: 'uppercase' }}>
{'Source excerpt'}
</div>
<div style={{
whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7,
color: 'var(--ink-2)', maxHeight: '40vh', overflowY: 'auto',
padding: 12, borderRadius: 8, background: 'var(--bg-sunk)',
wordBreak: 'break-word',
}}>
{post.raw_text}
</div>
</div>
</div>
</div>
)
if (typeof document === 'undefined') return null
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
}
export default function KolPage({
initialPosts = null,
initialTotal = 0,
initialDigest = null,
initialChanges = null,
initialDivergence = null,
}: KolPageProps) {
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(
cacheKey,
15 * 60_000, // 15 min TTL — KOL feed is ingested daily
() => 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 => {
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])
// posts are already filtered server-side; no client-side re-filter needed
const filtered = posts
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 : ('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">{'KOL Signals'}</h1>
{/* No count slot — the digest meta line ("X posts · Y assets") and
the feed pagination already show the totals. */}
<PageHint>
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)
setServerPage(1)
}}
/>
<WalletCheckWidget
isZh={isZh}
dateLocale={dateLocale}
initialDigest={initialDigest}
initialChanges={initialChanges}
initialItems={initialDivergence}
tickerFilter={tickerFilter}
days={kolDays}
/>
{tickerFilter && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
marginBottom: 12, fontSize: 12,
}}>
<span style={{ color: 'var(--ink-3)' }}>{'Filtered asset:'}</span>
<strong>{tickerFilter}</strong>
<button
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)',
}}
>{'Clear ✕'}</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' }}>{`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)' }}>
{'No data yet. Wait for the next feed ingestion run (daily 01:15 UTC).'}
</div>
)}
{filtered.map(p => (
<div
key={p.id}
onClick={() => openDetail(p.id)}
style={{
padding: 14, borderRadius: 10,
background: 'var(--surface)',
border: '1px solid var(--line)',
cursor: 'pointer',
transition: 'border-color 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--amber)')}
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--line)')}
>
{/* 顶部:作者 + 右侧操作区 */}
<div style={{ display: 'flex', justifyContent: 'space-between',
alignItems: 'center', gap: 8, marginBottom: 6,
flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: 'var(--ink-3)' }}>
@{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
? ('✓ Analyzed')
: ('⏳ Pending')}
</span>
{p.url && (
<a
href={p.url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
style={{
fontSize: 11, fontWeight: 600,
color: 'var(--amber)', textDecoration: 'none',
padding: '2px 8px', borderRadius: 4,
border: '1px solid color-mix(in srgb, var(--amber) 35%, transparent)',
whiteSpace: 'nowrap',
}}
>
{'Source ↗'}
</a>
)}
</div>
</div>
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 6,
wordBreak: 'break-word' }}>
{p.title || p.summary || (p.source === 'twitter' ? '(Tweet)' : '(Untitled)')}
</div>
{/* 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}
</div>
)}
<TickerChips tickers={p.tickers} isZh={isZh} />
</div>
))}
<Pagination
page={serverPage}
total={totalServerPages}
count={serverTotal}
pageSize={KOL_SERVER_PAGE_SIZE}
onChange={setServerPage}
scrollTop={false}
/>
</div>
)}
{openPost && <PostDetail post={openPost} onClose={() => setOpenPost(null)} isZh={isZh} dateLocale={dateLocale} />}
</div>
)
}
function KolPostsSkeleton() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 8 }}>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="skeleton-card">
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div className="skeleton sk-line-sm" style={{ width: 160 }} />
<div className="skeleton sk-line-sm" style={{ width: 56 }} />
</div>
<div className="skeleton sk-title sk-w-3q" />
<div className="skeleton sk-line sk-w-full" />
<div className="skeleton sk-line" style={{ width: '85%' }} />
<div style={{ display: 'flex', gap: 6, marginTop: 2 }}>
<div className="skeleton sk-line-sm" style={{ width: 48 }} />
<div className="skeleton sk-line-sm" style={{ width: 48 }} />
</div>
</div>
))}
</div>
)
}