4c3c8c6f87
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>
343 lines
14 KiB
TypeScript
343 lines
14 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||
import type { TrumpPost } from '@/types'
|
||
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'
|
||
import InfoTip from '@/components/ui/InfoTip'
|
||
import Pagination from '@/components/ui/Pagination'
|
||
|
||
const PAGE_SIZE = 30
|
||
|
||
const SENTIMENTS = ['all', 'bullish', 'bearish', 'neutral'] as const
|
||
type SentimentFilter = (typeof SENTIMENTS)[number]
|
||
type SignalFilter = 'all' | 'actionable' | 'buy' | 'short'
|
||
|
||
interface TrumpSignalPageProps {
|
||
initialData?: PostListResponse | 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(
|
||
key,
|
||
3 * 60_000,
|
||
() => getPostsPage(PAGE_SIZE, page, 'truth', filters),
|
||
fresh => {
|
||
setPosts(fresh.items)
|
||
setTotalPosts(fresh.total)
|
||
setCounts(fresh.counts)
|
||
hasRowsRef.current = fresh.items.length > 0
|
||
},
|
||
)
|
||
.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 filtered = useMemo(
|
||
() => serverPaging
|
||
? posts
|
||
: posts.filter(p => matchesBaseFilters(p, sentFilter, hideNoise) && matchesSignalFilter(p, sigFilter)),
|
||
[hideNoise, posts, sentFilter, serverPaging, sigFilter],
|
||
)
|
||
|
||
const noiseCount = counts.off_topic
|
||
const totalPages = Math.max(1, Math.ceil(totalPosts / PAGE_SIZE))
|
||
const safePage = Math.min(page, totalPages)
|
||
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',
|
||
}
|
||
const sentimentLabels: Record<SentimentFilter, string> = {
|
||
all: 'All', bullish: 'Bullish', bearish: 'Bearish', neutral: 'Neutral',
|
||
}
|
||
|
||
return (
|
||
<div className="page">
|
||
<div className="page-head">
|
||
<div>
|
||
<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>
|
||
</div>
|
||
|
||
<SystemControl system="trump" />
|
||
|
||
<div style={{
|
||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||
gap: 12, margin: '16px 0 12px', flexWrap: 'wrap',
|
||
}}>
|
||
{/* Signal filter tabs */}
|
||
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
|
||
{([
|
||
{ key: 'all', hint: 'Every post the scraper has captured.' },
|
||
{ key: 'actionable', hint: 'Only posts the AI flagged as BUY or SHORT.' },
|
||
{ key: 'buy', hint: 'Posts the AI scored as a long signal.' },
|
||
{ key: 'short', hint: 'Posts the AI scored as a short signal.' },
|
||
] as const).map(f => (
|
||
<button
|
||
key={f.key}
|
||
className={`nav-tab ${sigFilter === f.key ? 'active' : ''}`}
|
||
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)' }}>{counts[f.key]}</span>
|
||
</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 />}
|
||
|
||
{!loading && loadErr && (
|
||
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--down)' }}>
|
||
⚠️ {`Couldn't load signals — ${loadErr}`}
|
||
<div style={{ marginTop: 10 }}>
|
||
<button className="btn ghost" style={{ fontSize: 12, padding: '6px 14px' }}
|
||
onClick={() => location.reload()}>Retry</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!loading && !loadErr && totalPosts === 0 && (
|
||
<div className="card" style={{ padding: 48, textAlign: 'center', color: 'var(--ink-3)' }}>
|
||
No Trump signals match the current filter.
|
||
</div>
|
||
)}
|
||
|
||
{!loading && pageItems.length > 0 && (
|
||
<>
|
||
<div className="post-stream">
|
||
{pageItems.map(p => <PostRow key={p.id} post={p} />)}
|
||
</div>
|
||
|
||
<Pagination
|
||
page={safePage}
|
||
total={totalPages}
|
||
count={totalPosts}
|
||
pageSize={PAGE_SIZE}
|
||
onChange={setPage}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TrumpSkeleton() {
|
||
return (
|
||
<div className="post-stream" style={{ marginTop: 8 }}>
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<div key={i} className="skeleton-card" style={{ marginBottom: 8 }}>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<div className="skeleton sk-line sk-w-q" />
|
||
<div className="skeleton sk-line" style={{ width: 60 }} />
|
||
</div>
|
||
<div className="skeleton sk-title sk-w-3q" />
|
||
<div className="skeleton sk-line sk-w-full" />
|
||
<div className="skeleton sk-line sk-w-half" />
|
||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||
<div className="skeleton sk-line-sm" style={{ width: 56 }} />
|
||
<div className="skeleton sk-line-sm" style={{ width: 80 }} />
|
||
<div className="skeleton sk-line-sm" style={{ width: 64 }} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|