d72323b1c6
Big-picture changes since 01be8e7:
New routes — /trump /btc /kol /trades /analytics /archive plus four
SEO landing pages (/methodology /glossary /case-studies /contact),
all served under /[locale]. Each has dedicated metadata + JSON-LD.
KOL page (new) — DigestWidget + OnchainWidget + TalkVsTradesWidget,
filter by handle/ticker, click-through to per-post detail with the
original AI-extracted ticker/conviction/quote.
BTC page (new) — tabbed: macro-bottom (AHR999 + 200WMA + Pi Cycle)
and funding-rate reversal, with live sparkline + threshold bands.
Telegram card on Settings — wallet-link code generation, status,
disconnect. Preferences moved into the bot itself (/trump /btc etc.)
so the card stays minimal.
SignalMonitor (new) — ETH/LINK Bollinger breakout monitor in its own
component, shares the singleton WsProvider so no second WS opens.
WS singleton refactor (lib/wsContext) — shared WsProvider + useWsSubscribe
hook. Cleanup now actively closes the socket on unmount; previously the
local `ws` couldn't be reached from cleanup and leaked one connection
per StrictMode remount.
OpenPositions polling no longer pops MetaMask in the background —
splits into load('first') for user-initiated and load('poll') that
uses getCachedViewEnvelope without signing.
i18n shelved — proxy.ts (Next 16 middleware rename) wires next-intl
but only Navbar + layout footer have translations. Rest of UI used
isZh ternary scattered across 28 files. All `const isZh = locale === 'zh'`
flipped to `const isZh = false` so every site renders English; Chinese
branches kept as dead code so revival is one regex away.
LanguageSwitch hidden but file kept. zh-CN hreflang removed from
metadata + sitemap to avoid duplicate-content penalties.
Wallet error handling — lib/walletError.ts: isUserRejection walks EIP-1193
code 4001 + .cause chain; previous string-match for "reject"/"denied"
broke for users running MetaMask in non-English UIs. 12 call sites
migrated across 5 components.
PWA icons — app/icon.tsx + app/apple-icon.tsx render the brand "α" via
Next's ImageResponse so no static PNG asset is required. manifest.ts
references /icon and /apple-icon dynamic routes.
OG image + sitemap + robots — dynamic 1200×630 OG card; robots blocks
both /en/settings and /zh/settings; sitemap only emits /en routes;
JSON-LD covers SoftwareApplication + Organization + 10-Q FAQPage.
Landing page polish — Launch Dashboard button stripped of magnetic
hover + shimmer + lift + glow expansion (multiple users found it busy);
hero scramble alphabet swapped to alphanumeric (was block characters);
"15 KOL feeds" copy updated to 19 in 5 places.
PostCards source icons — new entries for btc_bottom_reversal,
funding_reversal, kol_divergence so they no longer fall through to
the generic "first letter" fallback.
Archive page filter — excludes funding_reversal + kol_divergence
(previously only excluded truth + btc_bottom_reversal so new live
signals leaked into the legacy archive).
Cache + skeleton loading — lib/cache.ts SWR module with per-key TTL,
applied across the major pages so navigation feels instant.
SignConfirmSheet — 4 hardcoded Chinese strings translated to English
(English users were seeing zh-only "需要钱包签名" etc).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
271 lines
8.9 KiB
TypeScript
271 lines
8.9 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef } from 'react'
|
|
import type { TrumpPost, Candle } from '@/types'
|
|
import { useDashboardStore } from '@/store/dashboard'
|
|
|
|
interface ChartPanelProps {
|
|
posts?: TrumpPost[]
|
|
candles?: Candle[]
|
|
externalSelectedId?: number | null
|
|
onSelectPost?: (id: number | null) => void
|
|
onSelectDayPosts?: (posts: TrumpPost[]) => void
|
|
}
|
|
|
|
export default function ChartPanel({ posts = [], candles = [], externalSelectedId, onSelectPost, onSelectDayPosts }: ChartPanelProps) {
|
|
const { timeframe, asset, livePrices } = useDashboardStore()
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const chartRef = useRef<unknown>(null)
|
|
const seriesRef = useRef<unknown>(null)
|
|
const fittedRef = useRef(false)
|
|
const postsRef = useRef(posts)
|
|
postsRef.current = posts
|
|
const selectedPostIdRef = useRef(externalSelectedId)
|
|
selectedPostIdRef.current = externalSelectedId
|
|
const timeframeRef = useRef(timeframe)
|
|
timeframeRef.current = timeframe
|
|
const onSelectRef = useRef(onSelectPost)
|
|
onSelectRef.current = onSelectPost
|
|
const onSelectDayPostsRef = useRef(onSelectDayPosts)
|
|
onSelectDayPostsRef.current = onSelectDayPosts
|
|
|
|
// Detect current theme for chart colors
|
|
function getChartColors() {
|
|
const isDark = document.documentElement.dataset.theme === 'dark'
|
|
return {
|
|
background: isDark ? '#121212' : '#ffffff',
|
|
textColor: isDark ? '#666666' : '#888888',
|
|
gridColor: isDark ? '#1e1e1e' : '#f0ede8',
|
|
borderColor: isDark ? '#2a2a2a' : '#e8e4de',
|
|
}
|
|
}
|
|
|
|
// Create chart once on mount
|
|
useEffect(() => {
|
|
if (!containerRef.current || typeof window === 'undefined') return
|
|
let destroyed = false
|
|
let ro: ResizeObserver | null = null
|
|
let themeObserver: MutationObserver | null = null
|
|
|
|
import('lightweight-charts').then(({ createChart, CrosshairMode }) => {
|
|
if (destroyed || !containerRef.current) return
|
|
const colors = getChartColors()
|
|
|
|
const chart = createChart(containerRef.current, {
|
|
width: containerRef.current.clientWidth,
|
|
height: 360,
|
|
layout: {
|
|
background: { color: colors.background },
|
|
textColor: colors.textColor,
|
|
},
|
|
grid: {
|
|
vertLines: { color: colors.gridColor },
|
|
horzLines: { color: colors.gridColor },
|
|
},
|
|
crosshair: { mode: CrosshairMode.Normal },
|
|
rightPriceScale: { borderColor: colors.borderColor },
|
|
timeScale: {
|
|
borderColor: colors.borderColor,
|
|
timeVisible: true,
|
|
rightOffset: 5,
|
|
barSpacing: 10,
|
|
},
|
|
handleScroll: true,
|
|
handleScale: true,
|
|
})
|
|
|
|
chartRef.current = chart
|
|
|
|
const series = chart.addCandlestickSeries({
|
|
upColor: '#26a69a',
|
|
downColor: '#ef5350',
|
|
borderUpColor: '#26a69a',
|
|
borderDownColor: '#ef5350',
|
|
wickUpColor: '#26a69a',
|
|
wickDownColor: '#ef5350',
|
|
})
|
|
|
|
seriesRef.current = series
|
|
|
|
chart.subscribeClick((param: any) => {
|
|
if (!param.time) return
|
|
const clickTime = typeof param.time === 'number' ? param.time : 0
|
|
if (!clickTime) return
|
|
|
|
const bucketByTf: Record<string, number> = {
|
|
'5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800,
|
|
}
|
|
const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? 3600
|
|
const clickBucket = Math.floor(clickTime / bucketSecs) * bucketSecs
|
|
|
|
const inBucket = postsRef.current
|
|
.filter((p) => {
|
|
if (!p.published_at) return false
|
|
const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
|
|
return Math.floor(pt / bucketSecs) * bucketSecs === clickBucket
|
|
})
|
|
|
|
if (inBucket.length === 0) {
|
|
onSelectRef.current?.(null)
|
|
return
|
|
}
|
|
|
|
// Multiple posts in this candle bucket → show all of them in the right rail
|
|
if (inBucket.length > 1 && onSelectDayPostsRef.current) {
|
|
const sorted = [...inBucket].sort(
|
|
(a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime()
|
|
)
|
|
onSelectDayPostsRef.current(sorted)
|
|
return
|
|
}
|
|
|
|
// Single post → show detail directly
|
|
onSelectRef.current?.(inBucket[0].id)
|
|
})
|
|
|
|
ro = new ResizeObserver(() => {
|
|
if (containerRef.current && !destroyed) {
|
|
chart.applyOptions({ width: containerRef.current.clientWidth })
|
|
}
|
|
})
|
|
ro.observe(containerRef.current)
|
|
|
|
themeObserver = new MutationObserver(() => {
|
|
const colors = getChartColors()
|
|
chart.applyOptions({
|
|
layout: {
|
|
background: { color: colors.background },
|
|
textColor: colors.textColor,
|
|
},
|
|
grid: {
|
|
vertLines: { color: colors.gridColor },
|
|
horzLines: { color: colors.gridColor },
|
|
},
|
|
rightPriceScale: { borderColor: colors.borderColor },
|
|
timeScale: { borderColor: colors.borderColor },
|
|
})
|
|
})
|
|
themeObserver.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ['data-theme'],
|
|
})
|
|
})
|
|
|
|
return () => {
|
|
destroyed = true
|
|
ro?.disconnect()
|
|
themeObserver?.disconnect()
|
|
fittedRef.current = false
|
|
if (chartRef.current) {
|
|
// @ts-expect-error lightweight-charts type
|
|
chartRef.current.remove()
|
|
chartRef.current = null
|
|
seriesRef.current = null
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
// Update candles + markers
|
|
useEffect(() => {
|
|
const series = seriesRef.current
|
|
const chart = chartRef.current
|
|
if (!series || !chart || candles.length === 0) return
|
|
|
|
const sorted = [...candles].sort((a, b) => a.time - b.time)
|
|
|
|
// @ts-expect-error lightweight-charts type
|
|
series.setData(sorted.map((c) => ({
|
|
time: c.time as number,
|
|
open: c.open,
|
|
high: c.high,
|
|
low: c.low,
|
|
close: c.close,
|
|
})))
|
|
|
|
const minTime = sorted[0].time
|
|
const maxTime = sorted[sorted.length - 1].time
|
|
|
|
const visible = posts.filter((p) => {
|
|
if (!p.published_at) return false
|
|
const t = Math.floor(new Date(p.published_at).getTime() / 1000)
|
|
return t >= minTime && t <= maxTime
|
|
})
|
|
|
|
const bucketByTf: Record<string, number> = {
|
|
'5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800,
|
|
}
|
|
const candleSpacing = sorted.length > 1 ? sorted[1].time - sorted[0].time : 300
|
|
const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? candleSpacing
|
|
|
|
const bucketMap = new Map<number, typeof visible>()
|
|
for (const p of visible) {
|
|
const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
|
|
const bucket = Math.floor(pt / bucketSecs) * bucketSecs
|
|
if (!bucketMap.has(bucket)) bucketMap.set(bucket, [])
|
|
bucketMap.get(bucket)!.push(p)
|
|
}
|
|
bucketMap.forEach((ps) => ps.sort((a, b) => (b.ai_confidence ?? 0) - (a.ai_confidence ?? 0)))
|
|
|
|
const markers = Array.from(bucketMap.entries())
|
|
.sort(([a], [b]) => a - b)
|
|
.map(([bucketTime, bPosts]) => {
|
|
const isSelected = bPosts.some((p) => p.id === externalSelectedId)
|
|
const best = bPosts[0]
|
|
const count = bPosts.length
|
|
const signalColor = isSelected
|
|
? '#f59e0b'
|
|
: best.signal === 'short' || best.signal === 'sell'
|
|
? '#ef5350'
|
|
: best.signal === 'buy'
|
|
? '#26a69a'
|
|
: '#aaaaaa'
|
|
return {
|
|
time: bucketTime as number,
|
|
position: 'aboveBar' as const,
|
|
color: signalColor,
|
|
shape: 'circle' as const,
|
|
text: count > 1 ? String(count) : '',
|
|
size: isSelected ? 2 : count > 1 ? 1.5 : 1,
|
|
}
|
|
})
|
|
|
|
// @ts-expect-error lightweight-charts type
|
|
series.setMarkers(markers)
|
|
|
|
if (!fittedRef.current) {
|
|
// @ts-expect-error lightweight-charts type
|
|
chart.timeScale().fitContent()
|
|
fittedRef.current = true
|
|
}
|
|
}, [candles, posts, externalSelectedId])
|
|
|
|
useEffect(() => { fittedRef.current = false }, [timeframe])
|
|
|
|
// Live-tick the rightmost candle so the chart feels alive between REST polls.
|
|
// lightweight-charts' `series.update()` either appends a new bar (newer time)
|
|
// or in-place mutates the bar at that timestamp. We always keep `time` ==
|
|
// the last candle's bucket so the bar grows in place; high/low expand if
|
|
// the live tick exceeds them.
|
|
useEffect(() => {
|
|
const series = seriesRef.current as any
|
|
if (!series) return
|
|
const live = livePrices[asset]
|
|
if (live == null || !candles.length) return
|
|
const last = candles[candles.length - 1]
|
|
series.update({
|
|
time: last.time as number,
|
|
open: last.open,
|
|
high: Math.max(last.high, live),
|
|
low: Math.min(last.low, live),
|
|
close: live,
|
|
})
|
|
}, [livePrices, asset, candles])
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
style={{ width: '100%', height: 360, borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair' }}
|
|
/>
|
|
)
|
|
}
|