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
+209 -92
View File
@@ -1,10 +1,9 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useLocale } from 'next-intl'
import { useState, useEffect, useMemo, useRef } from 'react'
import type { TrumpPost } from '@/types'
import { getPosts } from '@/lib/api'
import { swrFetch } from '@/lib/cache'
import { getPosts, getPostsPage, type PostListResponse } from '@/lib/api'
import { hasCached, swrFetch } from '@/lib/cache'
import PostRow, { isAiScored } from '@/components/dashboard/PostCards'
import SystemControl from '@/components/signals/SystemControl'
import PageHint from '@/components/ui/PageHint'
@@ -18,62 +17,147 @@ type SentimentFilter = (typeof SENTIMENTS)[number]
type SignalFilter = 'all' | 'actionable' | 'buy' | 'short'
interface TrumpSignalPageProps {
initialPosts?: TrumpPost[] | null
initialData?: PostListResponse | null
}
export default function TrumpSignalPage({ initialPosts = null }: TrumpSignalPageProps) {
const locale = useLocale()
const isZh = false
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts ?? [])
const [loading, setLoading] = useState(initialPosts === null)
const EMPTY_COUNTS: PostListResponse['counts'] = {
all: 0,
actionable: 0,
buy: 0,
short: 0,
off_topic: 0,
}
function matchesBaseFilters(post: TrumpPost, sentFilter: SentimentFilter, hideNoise: boolean) {
if (hideNoise && !isAiScored(post)) return false
if (sentFilter !== 'all' && post.sentiment !== sentFilter) return false
return true
}
function matchesSignalFilter(post: TrumpPost, sigFilter: SignalFilter) {
if (sigFilter === 'actionable') return post.signal === 'buy' || post.signal === 'short'
if (sigFilter === 'buy') return post.signal === 'buy'
if (sigFilter === 'short') return post.signal === 'short'
return true
}
function buildLocalCounts(
posts: TrumpPost[],
sentFilter: SentimentFilter,
hideNoise: boolean,
): PostListResponse['counts'] {
const sentimentScoped = posts.filter(p => sentFilter === 'all' || p.sentiment === sentFilter)
const base = sentimentScoped.filter(p => matchesBaseFilters(p, sentFilter, hideNoise))
return {
all: base.length,
actionable: base.filter(p => p.signal === 'buy' || p.signal === 'short').length,
buy: base.filter(p => p.signal === 'buy').length,
short: base.filter(p => p.signal === 'short').length,
off_topic: sentimentScoped.filter(p => !isAiScored(p)).length,
}
}
export default function TrumpSignalPage({ initialData = null }: TrumpSignalPageProps) {
const [posts, setPosts] = useState<TrumpPost[]>(initialData?.items ?? [])
const [totalPosts, setTotalPosts] = useState(initialData?.total ?? 0)
const [counts, setCounts] = useState<PostListResponse['counts']>(initialData?.counts ?? EMPTY_COUNTS)
const [loading, setLoading] = useState(initialData === null)
const [loadErr, setLoadErr] = useState('')
const [sentFilter, setSentFilter] = useState<SentimentFilter>('all')
const [sigFilter, setSigFilter] = useState<SignalFilter>('all')
const [hideNoise, setHideNoise] = useState(false)
const [page, setPage] = useState(1)
const [serverPaging, setServerPaging] = useState(true)
// Tracks whether we already have any rows on screen, WITHOUT putting
// posts.length in the effect deps (which would re-run the effect on every
// setPosts and fire a redundant fetch). Updated after each load below.
const hasRowsRef = useRef((initialData?.items?.length ?? 0) > 0)
useEffect(() => {
const filters = {
// Don't apply the sentiment bias when a directional signal filter is
// active — Buy/Short/Actionable already imply direction, and combining
// them with a sentiment filter produces confusing empty results.
// sentFilter is intentionally NOT reset when the user switches tabs so
// their preference is preserved if they switch back to 'all'.
sentiment: sentFilter === 'all' || sigFilter !== 'all' ? undefined : sentFilter,
signal: sigFilter === 'all' ? undefined : sigFilter,
aiScoredOnly: hideNoise,
} as const
// sentFilter only participates in the key when sigFilter is 'all' — it's
// ignored by the query otherwise, so caching it in the key would create
// phantom cache entries that never match on the way back.
const effectiveSent = sigFilter === 'all' ? sentFilter : 'all'
const key = `posts-truth-page-${page}-sent-${effectiveSent}-sig-${sigFilter}-noise-${hideNoise ? '1' : '0'}`
const legacyKey = 'posts-truth-legacy-500'
setLoadErr('')
setLoading(!hasRowsRef.current && !hasCached(key) && !hasCached(legacyKey))
swrFetch(
'posts-500',
key,
3 * 60_000,
() => getPosts(500, 1),
fresh => setPosts(fresh),
() => getPostsPage(PAGE_SIZE, page, 'truth', filters),
fresh => {
setPosts(fresh.items)
setTotalPosts(fresh.total)
setCounts(fresh.counts)
hasRowsRef.current = fresh.items.length > 0
},
)
.then(p => { setPosts(p); setLoadErr('') })
.catch(e => setLoadErr(e instanceof Error ? e.message : 'Failed to load posts'))
.then(r => {
setPosts(r.items)
setTotalPosts(r.total)
setCounts(r.counts)
setServerPaging(true)
setLoadErr('')
hasRowsRef.current = r.items.length > 0
})
.catch(async e => {
const detail = e instanceof Error ? e.message : 'Failed to load posts'
if (!detail.includes('404')) {
setLoadErr(detail)
return
}
try {
// Legacy fallback (old backend without /posts-paged). Pull the
// largest window the /posts endpoint allows (le=500) so client-side
// pagination below has the full set to slice — 200 silently dropped
// older matching posts once the truth feed grew past one page.
const legacyPosts = await swrFetch(
legacyKey,
3 * 60_000,
() => getPosts(500, 1, 'truth'),
)
const localCounts = buildLocalCounts(legacyPosts, sentFilter, hideNoise)
setPosts(legacyPosts)
setTotalPosts(localCounts.all)
setCounts(localCounts)
setServerPaging(false)
setLoadErr('')
hasRowsRef.current = legacyPosts.length > 0
} catch (legacyErr) {
setLoadErr(legacyErr instanceof Error ? legacyErr.message : detail)
}
})
.finally(() => setLoading(false))
}, [])
}, [page, sentFilter, sigFilter, hideNoise])
const trumpPosts = useMemo(
() => posts.filter(p => (p.source || '') === 'truth'),
[posts],
const filtered = useMemo(
() => serverPaging
? posts
: posts.filter(p => matchesBaseFilters(p, sentFilter, hideNoise) && matchesSignalFilter(p, sigFilter)),
[hideNoise, posts, sentFilter, serverPaging, sigFilter],
)
const noiseCount = useMemo(
() => trumpPosts.filter(p => !isAiScored(p)).length,
[trumpPosts],
)
const filtered = useMemo(() => trumpPosts.filter(p => {
if (hideNoise && !isAiScored(p)) return false
if (sentFilter !== 'all' && p.sentiment !== sentFilter) return false
if (sigFilter === 'actionable' && p.signal !== 'buy' && p.signal !== 'short') return false
if (sigFilter === 'buy' && p.signal !== 'buy') return false
if (sigFilter === 'short' && p.signal !== 'short') return false
return true
}), [trumpPosts, sentFilter, sigFilter, hideNoise])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const noiseCount = counts.off_topic
const totalPages = Math.max(1, Math.ceil(totalPosts / PAGE_SIZE))
const safePage = Math.min(page, totalPages)
const pageItems = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE)
const sigCounts = useMemo(() => ({
all: trumpPosts.length,
actionable: trumpPosts.filter(p => p.signal === 'buy' || p.signal === 'short').length,
buy: trumpPosts.filter(p => p.signal === 'buy').length,
short: trumpPosts.filter(p => p.signal === 'short').length,
}), [trumpPosts])
const pageItems = serverPaging
? posts
: filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE)
const signalLabels: Record<SignalFilter, string> = {
all: 'All', actionable: 'Actionable', buy: 'Buy', short: 'Short',
@@ -86,10 +170,25 @@ export default function TrumpSignalPage({ initialPosts = null }: TrumpSignalPage
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title"> Trump Signal</h1>
<PageHint count={`${sigCounts.actionable} actionable / ${trumpPosts.length} posts`}>
Watches Trump's Truth Social posts in real time, AI-scores each one,
and only fires a trade when conviction is high enough to move the market.
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<h1 className="page-title" style={{ margin: 0 }}>Trump Signal</h1>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 6, fontSize: 11, fontWeight: 700,
background: 'color-mix(in oklab, var(--up) 12%, transparent)',
color: 'var(--up)', border: '1px solid color-mix(in oklab, var(--up) 25%, transparent)',
letterSpacing: '0.04em',
}}>
Auto-trade
</span>
</div>
<PageHint count={
sigFilter === 'buy' ? `${counts.buy} buy signals`
: sigFilter === 'short' ? `${counts.short} short signals`
: sigFilter === 'actionable' ? `${counts.actionable} actionable signals`
: `${counts.actionable} actionable / ${counts.all} posts`
}>
Every Truth Social post scored in &lt;3s. Trades only when conviction clears the threshold.
</PageHint>
</div>
<span className="chip"><span className="live-dot" />Live</span>
@@ -112,56 +211,74 @@ export default function TrumpSignalPage({ initialPosts = null }: TrumpSignalPage
<button
key={f.key}
className={`nav-tab ${sigFilter === f.key ? 'active' : ''}`}
onClick={() => { setSigFilter(f.key); setPage(1) }}
onClick={() => {
setSigFilter(f.key)
setPage(1)
// sentFilter is intentionally NOT reset here — the user's
// bias preference is preserved so it's still active when
// they switch back to 'all'. The query layer ignores
// sentFilter whenever sigFilter is directional.
}}
>
{f.key === 'actionable' ? '🔥 ' : ''}
{signalLabels[f.key]}
<span style={{ color: 'var(--ink-4)', marginLeft: 4 }}>{sigCounts[f.key]}</span>
{signalLabels[f.key]}{' '}
<span style={{ color: 'var(--ink-4)' }}>{counts[f.key]}</span>
</button>
))}
</div>
{/* Sentiment filter */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
{/* One-click collapse of off-topic (non-crypto, un-scored) posts. */}
{noiseCount > 0 && (
<button
onClick={() => { setHideNoise(v => !v); setPage(1) }}
title={`Show only crypto-relevant posts — hides ${noiseCount} off-topic Trump posts`}
style={{
padding: '4px 10px', borderRadius: 6,
border: '1px solid var(--line)',
background: hideNoise ? 'var(--ink)' : 'transparent',
color: hideNoise ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 11, cursor: 'pointer', fontWeight: 600,
marginRight: 4,
}}
>
{hideNoise ? 'Show all' : 'Signals only'}
</button>
)}
<span style={{ fontSize: 11, color: 'var(--ink-4)', marginRight: 4 }}>
Bias
<InfoTip
text="Filters by the post's directional bias (bullish / bearish / neutral). Bias trade signal a bearish post can still be a BUY if the market reads it as priced-in."
placement="left"
/>
</span>
{SENTIMENTS.map(f => (
<button
key={f}
onClick={() => { setSentFilter(f); setPage(1) }}
style={{
padding: '4px 10px', borderRadius: 6, border: '1px solid var(--line)',
background: sentFilter === f ? 'var(--ink)' : 'transparent',
color: sentFilter === f ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 11, cursor: 'pointer', fontWeight: sentFilter === f ? 600 : 400,
}}
>
{sentimentLabels[f]}
</button>
))}
</div>
{/* Sentiment + noise filters — hidden when a directional filter is
already active (actionable / buy / short), because:
- "buy" already implies bullish direction
- "short" already implies bearish direction
- "actionable" = buy short — sentiment adds no further info
Showing them would create confusing combinations (e.g. Buy + Bearish
returns 0 results with no explanation). */}
{sigFilter === 'all' && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
{/* One-click collapse of off-topic (non-crypto, un-scored) posts.
Hidden when a sentiment filter is active — Bullish/Bearish/Neutral
results are all AI-scored by definition, so there are no off-topic
posts to hide and the button would be meaningless. */}
{(noiseCount > 0 || hideNoise) && sentFilter === 'all' && (
<button
onClick={() => { setHideNoise(v => !v); setPage(1) }}
title={`Show only crypto-relevant posts — hides ${noiseCount} off-topic Trump posts`}
style={{
padding: '4px 10px', borderRadius: 6,
border: '1px solid var(--line)',
background: hideNoise ? 'var(--ink)' : 'transparent',
color: hideNoise ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 11, cursor: 'pointer', fontWeight: 600,
marginRight: 4,
}}
>
{hideNoise ? 'Show all' : 'Signals only'}
</button>
)}
<span style={{ fontSize: 11, color: 'var(--ink-4)', marginRight: 4 }}>
Bias
<InfoTip
text="Filters by the post's directional bias (bullish / bearish / neutral). Bias ≠ trade signal — a bearish post can still be a BUY if the market reads it as priced-in."
placement="left"
/>
</span>
{SENTIMENTS.map(f => (
<button
key={f}
onClick={() => { setSentFilter(f); setPage(1) }}
style={{
padding: '4px 10px', borderRadius: 6, border: '1px solid var(--line)',
background: sentFilter === f ? 'var(--ink)' : 'transparent',
color: sentFilter === f ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 11, cursor: 'pointer', fontWeight: sentFilter === f ? 600 : 400,
}}
>
{sentimentLabels[f]}
</button>
))}
</div>
)}
</div>
{loading && <TrumpSkeleton />}
@@ -176,7 +293,7 @@ export default function TrumpSignalPage({ initialPosts = null }: TrumpSignalPage
</div>
)}
{!loading && !loadErr && filtered.length === 0 && (
{!loading && !loadErr && totalPosts === 0 && (
<div className="card" style={{ padding: 48, textAlign: 'center', color: 'var(--ink-3)' }}>
No Trump signals match the current filter.
</div>
@@ -191,7 +308,7 @@ export default function TrumpSignalPage({ initialPosts = null }: TrumpSignalPage
<Pagination
page={safePage}
total={totalPages}
count={filtered.length}
count={totalPosts}
pageSize={PAGE_SIZE}
onChange={setPage}
/>