Files
trumpsignal-frontend/app/[locale]/DashboardClient.tsx
T
k 4c3c8c6f87 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>
2026-06-09 22:55:27 +08:00

771 lines
38 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { useLocale } from 'next-intl'
import { useAccount } from 'wagmi'
import type { TrumpPost, BotPerformance, Candle } from '@/types'
import { useDashboardStore } from '@/store/dashboard'
import { usePriceSocket } from '@/lib/useRealtimeData'
import { getMacroSnapshot, getPerformance, getPrices, getUserPublic, getKolDivergence, getKolDigest, type MacroSnapshot } from '@/lib/api'
import type { KolDivergence, KolDigest } from '@/types'
import { getCachedViewEnvelope } from '@/lib/signedRequest'
import { swrFetch, invalidate as invalidateCache } from '@/lib/cache'
import PostRow, { SignalPill, SourceIcon, SOURCE_DISPLAY, fmtPct, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards'
import OpenPositions from '@/components/positions/OpenPositions'
import PageHint from '@/components/ui/PageHint'
import AnimatedNumber from '@/components/ui/AnimatedNumber'
// Heavy components — lazy-loaded so they don't bloat the initial JS bundle.
// ChartPanel pulls in lightweight-charts (~200KB gz); split it out.
const ChartPanel = dynamic(() => import('@/components/dashboard/ChartPanel'), {
ssr: false,
loading: () => <div style={{ height: 320, background: 'var(--bg-sunk)', borderRadius: 12, marginBottom: 16 }} />,
})
interface Props {
initialPosts: TrumpPost[]
}
// ── Inline post detail panel shown in the right rail ──────────────────────────
function PostDetail({ post, onClose }: { post: TrumpPost; onClose: () => void }) {
const impact = post.price_impact
function fmtImpactPct(v: number | null | undefined) {
if (v == null || isNaN(v)) return '—'
const s = Math.abs(v).toFixed(2) + '%'
return v >= 0 ? '+' + s : '-' + s
}
return (
<div className="card" style={{ padding: 20 }}>
{/* header */}
<div className="row between" style={{ marginBottom: 14 }}>
<span className="tiny">Signal detail</span>
<button
className="icon-btn"
onClick={onClose}
style={{ width: 26, height: 26, borderRadius: '50%' }}
title="Close"
>
<svg width="10" height="10" viewBox="0 0 24 24">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
</svg>
</button>
</div>
{/* source + time */}
<div className="row gap-s" style={{ marginBottom: 12 }}>
<SourceIcon source={post.source} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, fontFamily: 'var(--mono)' }}>
{SOURCE_DISPLAY[post.source?.toLowerCase?.()]?.label ?? post.source ?? 'Signal'}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)' }}><TimeAgo iso={post.published_at} suffix=" ago" /> · <LocalDateTime iso={post.published_at} /></div>
</div>
</div>
{/* post text */}
<p style={{ fontSize: 14, lineHeight: 1.55, margin: '0 0 16px', color: 'var(--ink)' }}>{post.text}</p>
{/* signal + sentiment */}
<div className="row gap-s" style={{ marginBottom: 16 }}>
<SignalPill signal={post.signal} />
<span className={`chip ${post.sentiment === 'bullish' ? 'up' : post.sentiment === 'bearish' ? 'down' : 'neutral'}`}>
{post.sentiment}
</span>
</div>
{/* AI confidence */}
<div className="ai-metric-head">
<span>AI confidence</span>
<strong>{post.ai_confidence}%</strong>
</div>
<div className="confidence-bar" style={{ marginBottom: 16 }}>
<div style={{ width: post.ai_confidence + '%' }} />
</div>
{/* AI reasoning */}
{post.ai_reasoning && (
<>
<div className="ai-reasoning-label">
{post.signal === 'buy' || post.signal === 'short' ? 'Why this signal fired' : 'Why this was filtered out'}
</div>
<div className="ai-reasoning-card scroll" style={{ marginBottom: 16 }}>
{post.ai_reasoning}
</div>
</>
)}
{/* Price impact grid */}
{impact && (
<>
<div className="tiny" style={{ marginBottom: 10 }}>Price impact · {impact.asset}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
{([['m5', '5m'], ['m15', '15m'], ['m1h', '1h']] as const).map(([key, label]) => {
const v = impact[key]
const correct = impact[`correct_${key}` as 'correct_m5' | 'correct_m15' | 'correct_m1h']
return (
<div key={key} style={{
padding: 10,
background: 'var(--bg-sunk)',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--line)',
textAlign: 'center',
}}>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 4 }}>{label}</div>
<div className={`delta ${(v ?? 0) >= 0 ? 'up' : 'down'}`} style={{ fontSize: 14, fontWeight: 600 }}>
{fmtImpactPct(v)}
</div>
{correct != null && (
<div style={{ fontSize: 10, marginTop: 4, color: correct ? 'var(--up)' : 'var(--down)' }}>
{correct ? '✓' : '✗'}
</div>
)}
</div>
)
})}
</div>
</>
)}
</div>
)
}
// ── Empty state for when nothing is selected ──────────────────────────────────
function SelectHint() {
return (
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--ink-3)' }}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" style={{ margin: '0 auto 12px', display: 'block', opacity: 0.4 }}>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2"/>
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
<p style={{ fontSize: 13, margin: 0, lineHeight: 1.5 }}>
Click any marker on the chart or a post below to see details
</p>
</div>
)
}
// ── Main dashboard ─────────────────────────────────────────────────────────────
export default function DashboardClient({ initialPosts }: Props) {
const intlLocale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const { asset, setAsset, timeframe, setTimeframe: _setTimeframe, setLivePrice, setSubscribed, setBotReadiness, setHlApiKeySet, setPaperMode, isSubscribed, hlApiKeySet, botReadiness, livePrices } = useDashboardStore()
function setTimeframe(tf: string) { _setTimeframe(tf as '5m' | '15m' | '1H' | '4H' | '1D' | '1W') }
const { address, isConnected } = useAccount()
const params = useParams()
const locale = (typeof params?.locale === 'string' ? params.locale : 'en')
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts)
const [performance, setPerformance] = useState<BotPerformance | undefined>(undefined)
const [candles, setCandles] = useState<Candle[]>([])
const [chartErr, setChartErr] = useState('')
const [chartReload, setChartReload] = useState(0)
const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
const [macro, setMacro] = useState<MacroSnapshot | null>(null)
const [kolDivergences, setKolDivergences] = useState<KolDivergence[]>([])
const [kolDigest, setKolDigest] = useState<KolDigest | null>(null)
// For 1D: show all posts from selected day
const [selectedDayPosts, setSelectedDayPosts] = useState<TrumpPost[] | null>(null)
useEffect(() => {
// B42: cancel the in-flight getUserPublic if the wallet switches again
// before it resolves — prevents old wallet's data polluting new wallet's state.
let cancelled = false
if (!isConnected || !address) {
setSubscribed(false)
setHlApiKeySet(false)
setBotReadiness('unknown')
setPerformance(undefined)
return () => { cancelled = true }
}
// Clear account-scoped bot state immediately (setWallet already resets these
// via store, but DashboardClient may be rendered without a wallet switch —
// keep explicit resets here for clarity and safety).
setSubscribed(false)
setHlApiKeySet(false)
setBotReadiness('unknown')
// `snapAddr !== address` would be a stale-closure trap: both are the same
// closed-over value. Use `cancelled` (set by effect cleanup) as the sole guard.
getUserPublic(address.toLowerCase())
.then((user) => {
if (cancelled) return // effect cleaned up = wallet changed or unmounted
setSubscribed(user.active)
setHlApiKeySet(user.hl_api_key_set)
setBotReadiness(user.hl_api_key_set ? 'saved' : 'unknown')
setPaperMode(!!user.paper_mode)
})
.catch(() => {})
return () => { cancelled = true }
}, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness])
useEffect(() => {
if (!isConnected || !address) {
setPerformance(undefined)
return
}
let cancelled = false
;(async () => {
try {
const env = getCachedViewEnvelope('view_performance', address)
?? getCachedViewEnvelope('view_user', address)
if (!env) {
if (!cancelled) setPerformance(undefined)
return
}
const data = await getPerformance(address, env)
if (!cancelled) setPerformance(data)
} catch {
if (!cancelled) setPerformance(undefined)
}
})()
return () => { cancelled = true }
}, [address, isConnected])
const [freshPostId, setFreshPostId] = useState<number | null>(null)
usePriceSocket({
onPrice: (a, price) => setLivePrice(a, price),
onNewPost: (post) => {
const p = post as TrumpPost
// Dedup: WS may resend a post already in initialPosts or a prior push.
setPosts((prev) => prev.some(x => x.id === p.id) ? prev : [p, ...prev].slice(0, 500))
setFreshPostId(p.id)
// Keep a ref to the timer so we can cancel it if the component unmounts
// before it fires (avoids setState-after-unmount warning).
const timer = setTimeout(() => setFreshPostId((id) => (id === p.id ? null : id)), 1400)
return () => clearTimeout(timer) // returned but not used by usePriceSocket — see L1 note
},
})
useEffect(() => {
let cancelled = false
setCandles([])
setChartErr('')
const priceKey = `prices-${asset}-${timeframe}`
if (chartReload > 0) invalidateCache(priceKey)
swrFetch(
priceKey,
90_000,
() => getPrices(asset, timeframe),
fresh => { if (!cancelled) { setCandles(fresh); setChartErr('') } },
)
.then(c => { if (!cancelled) { setCandles(c); setChartErr('') } })
.catch(e => { if (!cancelled) setChartErr(e instanceof Error ? e.message : 'Failed to load price data') })
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [asset, timeframe, chartReload])
useEffect(() => {
let alive = true
function load() {
swrFetch(
'macro-snapshot',
10 * 60_000,
() => getMacroSnapshot(),
fresh => { if (alive) setMacro(fresh) },
)
.then((snap) => { if (alive) setMacro(snap) })
.catch(() => {})
}
load()
const id = setInterval(load, 10 * 60_000)
return () => { alive = false; clearInterval(id) }
}, [])
// KOL divergence + digest — loaded once, 30 min cache (changes daily)
useEffect(() => {
let alive = true
// KOL ingestion is daily and sparse — a 7d window is frequently empty
// (e.g. 7d=0 while 30d=196), which hid the whole sidebar card. Use a 30d
// window so the Overview reflects the data that actually exists.
swrFetch('kol-divergence-30d', 30 * 60_000, () => getKolDivergence({ days: 30 }), f => { if (alive) setKolDivergences(f.items ?? []) })
.then(r => { if (alive) setKolDivergences(r.items ?? []) })
.catch(() => {})
swrFetch('kol-digest-30d', 30 * 60_000, () => getKolDigest(30), f => { if (alive) setKolDigest(f) })
.then(r => { if (alive) setKolDigest(r) })
.catch(() => {})
return () => { alive = false }
}, [])
const selectedPost = posts.find(p => p.id === selectedPostId) ?? null
// Show actionable signals first (buy/short), then most recent hold/neutral.
// Cap at 8 total so the list doesn't get too long.
const actionable = posts.filter(p => p.signal === 'buy' || p.signal === 'short').slice(0, 4)
const recentOthers = posts.filter(p => p.signal !== 'buy' && p.signal !== 'short').slice(0, 4)
const recentPosts = [...actionable, ...recentOthers].slice(0, 8)
const lastCandle = candles[candles.length - 1]
// Live price beats the most recent candle close — the candle is only
// refreshed when the user changes asset/timeframe, so without WS the number
// is frozen at page load.
const livePrice = livePrices[asset]
const displayPrice = livePrice ?? lastCandle?.close ?? null
// 24-hour change. Picking by candle index is wrong (200×4H = 33 days).
// Find the candle whose `time` is closest to (now 24h) and use its open.
const now = lastCandle ? lastCandle.time : Math.floor(Date.now() / 1000)
const target24h = now - 24 * 3600
let baseline24h: typeof lastCandle | undefined
if (candles.length) {
baseline24h = candles[0]
for (const c of candles) {
if (c.time <= target24h) baseline24h = c
else break
}
}
const priceChange =
displayPrice != null && baseline24h
? ((displayPrice - baseline24h.open) / baseline24h.open) * 100
: 0
const totalPosts = posts.length
const todayKey = new Date().toISOString().slice(0, 10)
const signalsToday = posts.filter(p => p.published_at?.slice(0, 10) === todayKey).length
const actionablePosts = posts.filter(p => p.signal === 'buy' || p.signal === 'short').length
const trumpActionable = posts.filter(p => (p.source || '') === 'truth' && (p.signal === 'buy' || p.signal === 'short')).length
const macroActionable = posts.filter(p => ((p.source || '') === 'btc_bottom_reversal' || (p.source || '') === 'funding_reversal') && (p.signal === 'buy' || p.signal === 'short')).length
// Use actual KOL divergence count (divergence-flagged items from the API),
// not a source==='kol' post count (which never matches — source is 'substack'/'blog'/etc.).
const kolMentions = kolDivergences.filter(d => d.signal_type === 'divergence').length
const winRate = performance?.win_rate ?? 0
const netPnl = performance?.net_pnl_usd ?? 0
const hasPriceData = candles.length > 0
const hasPerformanceData = Boolean(performance)
const macroScore = macro?.composite_score ?? null
const macroRegime = macro?.regime_label ?? null
const macroPct = macroScore == null ? 50 : Math.max(0, Math.min(100, (macroScore + 100) / 2))
// Derive tone from the backend's regime_label, NOT a re-thresholded score.
// The backend uses ±20 for BULLISH/BEARISH; re-deriving with ±15 here made
// a score of e.g. 15.2 read "Supportive" in the card while the same card's
// regime label said NEUTRAL. Trusting regime_label keeps them consistent.
const macroTone =
macroRegime == null ? (macroScore == null ? 'neutral' : 'neutral')
: macroRegime === 'BULL' || macroRegime === 'BULLISH' ? 'bull'
: macroRegime === 'BEAR' || macroRegime === 'BEARISH' ? 'bear'
: 'neutral'
const macroSummary =
macroScore == null ? 'Daily macro composite not loaded yet.'
: macroTone === 'bull' ? 'Supportive backdrop — trend setups have room.'
: macroTone === 'bear' ? 'Defensive backdrop — preserve size, downside moves are cleaner.'
: 'Mixed backdrop — context only, not a directional trigger.'
return (
<div className="page wide">
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '信号总览' : 'Signal monitor'}</h1>
<PageHint count={actionablePosts > 0 ? `${actionablePosts} actionable · ${totalPosts} tracked` : undefined}>
Trump · Macro · KOL divergence live.
</PageHint>
</div>
<span className="chip"><span className="live-dot" />Live</span>
</div>
{/* Open positions — what's on the book right now. Renders only when
a subscribed wallet is connected, so guests see the normal feed. */}
<OpenPositions />
<div className="overview-shell">
<div className="overview-main">
<section className="overview-market-card">
<div className="overview-card-head">
<div>
<div className="overview-kicker">Market and macro</div>
<div className="overview-headline-row">
<AnimatedNumber
className="hero-value mono"
style={{ fontSize: 40 }}
value={displayPrice}
display={displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
/>
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h</span>
</div>
<div className="overview-market-subtitle">{asset} · live signal context</div>
</div>
<div className="overview-controls">
<div className="asset-switch">
<button className={asset === 'BTC' ? 'on' : ''} onClick={() => setAsset('BTC')}>
<span className="asset-dot btc" /> BTC
</button>
<button className={asset === 'ETH' ? 'on' : ''} onClick={() => setAsset('ETH')}>
<span className="asset-dot eth" /> ETH
</button>
</div>
<div className="tf-bar">
{(['5m', '15m', '1H', '4H', '1D'] as const).map(t => (
<button key={t} className={timeframe === t ? 'on' : ''} onClick={() => setTimeframe(t)}>{t}</button>
))}
</div>
</div>
</div>
<div className={`overview-macro-card ${macroTone}`}>
<div className="overview-macro-band">
<div className="overview-macro-copy">
<div className="overview-macro-title-row">
<div className="overview-macro-title">Macro composite</div>
<div className="overview-macro-stamp">today · 8 indicators</div>
</div>
<div className="overview-macro-text">{macroSummary}</div>
</div>
<div className="overview-macro-score">
<div className={`overview-score-value ${macroTone}`}>
{macroScore == null ? '—' : `${macroScore > 0 ? '+' : ''}${macroScore.toFixed(1)}`}
</div>
<div className={`overview-score-pill ${macroTone}`}>{macroRegime ?? 'Waiting'}</div>
</div>
</div>
<div className="overview-score-track">
<div className={`overview-score-needle ${macroTone}`} style={{ left: `calc(${macroPct}% - 11px)` }} />
</div>
<div className="overview-score-scale">
<span>100 bear</span>
<span>0 neutral</span>
<span>+100 bull</span>
</div>
</div>
</section>
{/* ── Unified stats row ─────────────────────────────────────────── */}
<div className="overview-stats-bar">
{([
{ label: 'Trump signals', value: trumpActionable, href: `/${locale}/trump` },
{ label: 'Macro signals', value: macroActionable, href: `/${locale}/macro` },
{ label: 'KOL divergence', value: kolMentions, href: `/${locale}/kol` },
] as const).map(({ label, value, href }) => {
const empty = value === 0
const inner = (
<>
<span className="stats-bar-label">{label}</span>
<span className="stats-bar-value" style={{ color: empty ? 'var(--ink-4)' : 'var(--ink)' }}>
{empty ? '—' : value}
</span>
</>
)
return <Link key={label} href={href} className="stats-bar-item">{inner}</Link>
})}
<div className="stats-bar-item passive" style={{ borderRight: 'none' }}>
<span className="stats-bar-label">My P&amp;L · 30d</span>
<span className="stats-bar-value" style={{
color: hasPerformanceData ? (netPnl >= 0 ? 'var(--up)' : 'var(--down)') : 'var(--ink-4)',
}}>
{hasPerformanceData
? `${netPnl >= 0 ? '+' : ''}$${Math.abs(netPnl).toLocaleString('en-US', { maximumFractionDigits: 0 })}`
: '—'}
</span>
</div>
</div>
</div>
<aside className="overview-side">
{isConnected ? (
<section className="overview-side-card">
<div className="overview-kicker">Account</div>
<div className="overview-account-list">
<div className="overview-account-item">
<span>Wallet</span>
<strong className="mono">{address ? `${address.slice(0, 6)}${address.slice(-4)}` : '—'}</strong>
</div>
<div className="overview-account-item">
<span>Settings</span>
<strong>{hasPerformanceData ? 'Loaded' : 'Not loaded'}</strong>
</div>
{hasPerformanceData && (
<div className="overview-account-item">
<span>Win rate</span>
<strong>{`${(winRate * 100).toFixed(1)}%`}</strong>
</div>
)}
</div>
</section>
) : (
<section className="overview-side-card" style={{ padding: '14px 16px' }}>
<div className="overview-kicker" style={{ marginBottom: 12 }}>Get started</div>
{([
{ icon: '📡', head: 'Free signals', sub: 'Trump · Macro · KOL — no account needed' },
{ icon: '🔔', head: 'Telegram alerts', sub: 'Signal fires → your phone in seconds' },
{ icon: '⚡', head: 'Auto-trade', sub: 'Trade-only key · no withdrawal access' },
] as const).map(({ icon, head, sub }) => (
<div key={head} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 12 }}>
<span style={{ fontSize: 16, flexShrink: 0, marginTop: 1 }}>{icon}</span>
<div>
<div style={{ fontSize: 12, fontWeight: 700, lineHeight: 1.3 }}>{head}</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', lineHeight: 1.4, marginTop: 1 }}>{sub}</div>
</div>
</div>
))}
</section>
)}
{/* KOL divergence hook — highest-conviction signal type */}
{(kolDivergences.length > 0 || (kolDigest && kolDigest.tickers.length > 0)) && (
<section className="overview-side-card" style={{ padding: '14px 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div className="overview-kicker">KOL intel · 30d</div>
<a href={`/${locale}/kol`} style={{ fontSize: 10, color: 'var(--amber-ink)', textDecoration: 'none', fontWeight: 700 }}>
Full feed
</a>
</div>
{/* Latest divergence — the money signal */}
{kolDivergences.filter(d => d.signal_type === 'divergence').slice(0, 1).map(d => (
<div key={d.id} style={{
padding: '9px 11px', borderRadius: 7, marginBottom: 8,
background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.25)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span style={{ fontSize: 10, fontWeight: 800, color: '#f59e0b', letterSpacing: '0.05em' }}> DIVERGENCE</span>
<span style={{ fontSize: 10, color: 'var(--ink-4)' }}>
{/* post_at = when the KOL actually published. created_at
is the DB write time, which a backfill/late scan can
set to "now", making an old event look fresh. */}
{Math.round((Date.now() - new Date(d.post_at).getTime()) / 864e5)}d ago
</span>
</div>
<div style={{ fontSize: 12, fontWeight: 600 }}>
@{d.handle} said <span style={{ color: d.post_action === 'bullish' || d.post_action === 'buy' ? 'var(--up)' : 'var(--down)' }}>{d.post_action}</span>
{' '}on <strong>{d.ticker}</strong>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
Then {d.onchain_action.replace('_', ' ')}{d.usd_after != null ? `$${(d.usd_after / 1000).toFixed(0)}k on-chain` : ''}
</div>
</div>
))}
{/* Top digest tickers */}
{kolDigest && kolDigest.tickers.slice(0, 2).map(t => (
<div key={t.ticker} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 0', borderBottom: '1px solid var(--line)',
}}>
<div>
<span style={{ fontSize: 12, fontWeight: 700 }}>{t.ticker}</span>
<span style={{ fontSize: 10, color: 'var(--ink-4)', marginLeft: 6 }}>{t.kol_count} KOLs</span>
</div>
<span style={{
fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 4,
background: t.side === 'long' ? 'var(--up-soft)' : t.side === 'short' ? 'rgba(220,38,38,.12)' : 'var(--bg-sunk)',
color: t.side === 'long' ? 'var(--up)' : t.side === 'short' ? 'var(--down)' : 'var(--ink-3)',
}}>
{t.dominant_action.toUpperCase()}
</span>
</div>
))}
</section>
)}
</aside>
</div>
<div className="dash-grid">
{/* Left: Chart + signal stream */}
<div className="stack gap-l">
<div className="card" style={{ padding: 24 }}>
<div className="row between" style={{ marginBottom: 18, alignItems: 'flex-start' }}>
<div>
<div className="tiny">Price · {asset}</div>
<div className="row gap-m" style={{ marginTop: 6 }}>
<AnimatedNumber
className="hero-value mono"
style={{ fontSize: 32 }}
value={displayPrice}
display={displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
/>
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h</span>
</div>
</div>
<div className="tiny" style={{ color: 'var(--ink-3)' }}>Live chart with signal markers</div>
</div>
{chartErr && (
<div style={{ padding: '8px 12px', margin: '0 0 8px', fontSize: 12,
color: 'var(--down)', background: 'var(--down-soft, rgba(220,38,38,.08))',
borderRadius: 8 }}>
{asset} {timeframe} chart failed to load {chartErr}{' '}
<button className="btn ghost" style={{ fontSize: 11, padding: '3px 10px', marginLeft: 6 }}
onClick={() => setChartReload(n => n + 1)}>Retry</button>
</div>
)}
<div className="chart-wrap" style={{ position: 'relative' }}>
{/* Loading overlay — shown while candles are fetching (candles=[] and no error).
Prevents the user from seeing a blank lightweight-charts canvas. */}
{!hasPriceData && !chartErr && (
<div style={{
position: 'absolute', inset: 0, zIndex: 4,
background: 'var(--bg-sunk)', borderRadius: 'var(--r-sm)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
gap: 10, color: 'var(--ink-3)', fontSize: 13,
}}>
<span className="live-dot" style={{ background: 'var(--amber)' }} />
Loading chart
</div>
)}
<ChartPanel
posts={posts}
candles={candles}
externalSelectedId={selectedPostId}
onSelectPost={(id) => {
setSelectedDayPosts(null)
setSelectedPostId(id)
}}
onSelectDayPosts={(dayPosts) => {
setSelectedDayPosts(dayPosts)
setSelectedPostId(null)
}}
/>
</div>
<div className="chart-footnote">
<div className="item"><span className="legend-dot" style={{ background: '#26a69a' }} /> Buy signal</div>
<div className="item"><span className="legend-dot" style={{ background: '#ef5350' }} /> Short signal</div>
<div className="item"><span className="legend-dot" style={{ background: '#aaaaaa' }} /> Hold / filtered</div>
{asset === 'BTC' && <div className="item"><span className="legend-dot macro" /> Macro reversal highlight</div>}
<div style={{ marginLeft: 'auto' }}>Binance candles via backend API · click marker to inspect</div>
</div>
</div>
{/* Recent signals list */}
<div>
<div className="section-title">
<h2>Recent signals</h2>
<span className="hint">
{actionable.length > 0 ? `${actionable.length} actionable · ` : ''}
{`${totalPosts} tracked`}
</span>
</div>
<div className="post-stream">
{recentPosts.map(p => (
<div key={p.id} className={p.id === freshPostId ? 'signal-enter' : undefined}>
<PostRow post={p} selected={selectedPostId === p.id} onClick={() => {
// Clear the day-list view first: the right panel renders
// selectedDayPosts with higher priority than selectedPost,
// so without this the detail never appears while a candle's
// multi-post list is open.
setSelectedDayPosts(null)
setSelectedPostId(selectedPostId === p.id ? null : p.id)
}} />
</div>
))}
</div>
</div>
</div>
{/* Right rail */}
<div className="rail">
{/* Day-view: all posts from clicked day */}
{selectedDayPosts ? (
<div className="card" style={{ padding: 20 }}>
<div className="row between" style={{ marginBottom: 14 }}>
<div>
<div className="tiny">{selectedDayPosts.length} posts · this candle</div>
<div style={{ fontSize: 15, fontWeight: 600, marginTop: 2 }}>
<LocalDateTime iso={selectedDayPosts[0].published_at} opts={{ month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }} />
</div>
</div>
<button className="icon-btn" onClick={() => { setSelectedDayPosts(null); setSelectedPostId(null) }}
style={{ width:26, height:26, borderRadius:'50%' }}>
<svg width="10" height="10" viewBox="0 0 24 24">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
</svg>
</button>
</div>
<div style={{ display:'flex', flexDirection:'column', gap:10 }}>
{selectedDayPosts.map(p => {
const expanded = selectedPostId === p.id
const impact = p.price_impact
function fmtImpactPct(v: number | null | undefined) {
if (v == null || isNaN(v)) return '—'
const s = Math.abs(v).toFixed(2) + '%'
return v >= 0 ? '+' + s : '-' + s
}
return (
<div key={p.id}
style={{ border:`1px solid ${expanded?'var(--amber)':'var(--line)'}`,
borderRadius:'var(--r-sm)', overflow:'hidden', transition:'border-color 0.15s' }}>
{/* collapsed row — always visible */}
<div onClick={() => setSelectedPostId(expanded ? null : p.id)}
style={{ padding:12, cursor:'pointer',
background: expanded ? 'var(--bg-sunk)' : 'transparent' }}>
<div className="row between" style={{ marginBottom:6 }}>
<div className="row gap-s">
<SignalPill signal={p.signal} />
<span className={`chip ${p.sentiment === 'bullish' ? 'up' : p.sentiment === 'bearish' ? 'down' : 'neutral'}`}
style={{ fontSize:10 }}>{p.sentiment}</span>
</div>
<span style={{ fontSize:11, color:'var(--ink-3)' }}><TimeAgo iso={p.published_at} /></span>
</div>
<p style={{ fontSize:12, lineHeight:1.5, margin:'0 0 8px', color:'var(--ink-2)',
...(expanded ? {} : {
display:'-webkit-box', WebkitLineClamp: 3,
WebkitBoxOrient:'vertical', overflow:'hidden'
}) }}>
{p.text}
</p>
<div style={{ display:'flex', gap:6, alignItems:'center' }}>
<div style={{ flex:1, height:3, background:'var(--line)', borderRadius:2, overflow:'hidden' }}>
<div style={{ width:p.ai_confidence+'%', height:'100%', background:'var(--amber)', borderRadius:2 }} />
</div>
<span style={{ fontSize:10, color:'var(--ink-3)', whiteSpace:'nowrap' }}>{p.ai_confidence}% conf</span>
</div>
</div>
{/* expanded detail */}
{expanded && (
<div style={{ padding:'0 12px 12px', borderTop:'1px solid var(--line)' }}>
{p.ai_reasoning && (
<>
<div className="ai-reasoning-label" style={{ margin:'10px 0 8px' }}>AI reasoning</div>
<div className="ai-reasoning-card" style={{ padding: '14px 15px', fontSize: 14, lineHeight: 1.7 }}>
{p.ai_reasoning}
</div>
</>
)}
{impact && (
<>
<div className="tiny" style={{ margin:'10px 0 6px' }}>Price impact · {impact.asset}</div>
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:6 }}>
{(['m5','m15','m1h'] as const).map((key) => {
const label = key === 'm5' ? '5m' : key === 'm15' ? '15m' : '1h'
const v = impact[key]
const correct = impact[`correct_${key}` as 'correct_m5'|'correct_m15'|'correct_m1h']
return (
<div key={key} style={{ padding:8, background:'var(--bg-sunk)',
borderRadius:'var(--r-sm)', border:'1px solid var(--line)', textAlign:'center' }}>
<div style={{ fontSize:10, color:'var(--ink-3)', marginBottom:3 }}>{label}</div>
<div className={`delta ${(v??0)>=0?'up':'down'}`} style={{ fontSize:13, fontWeight:600 }}>
{fmtImpactPct(v)}
</div>
{correct != null && (
<div style={{ fontSize:10, marginTop:3, color: correct ? 'var(--up)' : 'var(--down)' }}>
{correct ? '✓' : '✗'}
</div>
)}
</div>
)
})}
</div>
</>
)}
</div>
)}
</div>
)
})}
</div>
</div>
) : selectedPost ? (
<PostDetail post={selectedPost} onClose={() => setSelectedPostId(null)} />
) : (
<SelectHint />
)}
</div>
</div>
</div>
)
}