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:
@@ -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 <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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user