done
This commit is contained in:
+220
-100
@@ -1,124 +1,244 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState } from 'react'
|
||||
import { useAccount } from 'wagmi'
|
||||
import { useConnectModal } from '@rainbow-me/rainbowkit'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import type { BotPerformance } from '@/types'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import { formatPct, formatHold } from '@/lib/utils'
|
||||
import { setHlApiKey, subscribe } from '@/lib/api'
|
||||
import { signRequest } from '@/lib/signedRequest'
|
||||
|
||||
const MOCK_PERFORMANCE: BotPerformance = {
|
||||
period_days: 30,
|
||||
total_trades: 89,
|
||||
win_rate: 0.73,
|
||||
net_pnl_usd: 12840,
|
||||
avg_hold_seconds: 14 * 60,
|
||||
max_drawdown_pct: 8.2,
|
||||
// Action names must match backend/app/api/{user,subscribe}.py
|
||||
const ACTION_SET_API_KEY = 'set_hl_api_key'
|
||||
const ACTION_SUBSCRIBE = 'subscribe'
|
||||
|
||||
interface Props {
|
||||
performance?: BotPerformance | null
|
||||
}
|
||||
|
||||
interface BotPanelProps {
|
||||
performance?: BotPerformance
|
||||
type SaveState = 'idle' | 'signing' | 'saving' | 'success' | 'error'
|
||||
|
||||
function fmtHold(s: number) {
|
||||
if (s < 60) return s + 's'
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return m + 'm'
|
||||
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
|
||||
}
|
||||
|
||||
export default function BotPanel({ performance = MOCK_PERFORMANCE }: BotPanelProps) {
|
||||
const t = useTranslations('bot')
|
||||
const { isSubscribed, setSubscribed } = useDashboardStore()
|
||||
export default function BotPanel({ performance }: Props) {
|
||||
const { isSubscribed, hlApiKeySet, hlApiKeyMasked, setHlApiKeySet, setSubscribed } = useDashboardStore()
|
||||
const { address, isConnected } = useAccount()
|
||||
const { openConnectModal } = useConnectModal()
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const { signMessageAsync } = useSignMessage()
|
||||
|
||||
const stats = [
|
||||
{ label: t('winRate'), value: formatPct(performance.win_rate * 100 - 100 + performance.win_rate * 100), display: `${Math.round(performance.win_rate * 100)}%` },
|
||||
{ label: t('netPnl'), value: `+$${performance.net_pnl_usd.toLocaleString()}`, positive: true },
|
||||
{ label: t('totalTrades'), value: String(performance.total_trades) },
|
||||
{ label: t('avgHold'), value: formatHold(performance.avg_hold_seconds) },
|
||||
]
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [subState, setSubState] = useState<'idle' | 'signing' | 'saving' | 'error'>('idle')
|
||||
const [subError, setSubError] = useState('')
|
||||
|
||||
async function handleSubscribe() {
|
||||
if (!address) return
|
||||
setSubError('')
|
||||
try {
|
||||
setSubState('signing')
|
||||
const env = await signRequest({
|
||||
action: ACTION_SUBSCRIBE,
|
||||
wallet: address,
|
||||
body: null,
|
||||
signMessageAsync,
|
||||
})
|
||||
setSubState('saving')
|
||||
await subscribe(env)
|
||||
setSubscribed(true)
|
||||
setSubState('idle')
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||
setSubError(msg.includes('User rejected') || msg.includes('denied') ? 'Signature cancelled' : msg.slice(0, 120))
|
||||
setSubState('error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveKey() {
|
||||
if (!address || !apiKey.trim()) return
|
||||
if (!apiKey.trim().startsWith('0x') || apiKey.trim().length !== 66) {
|
||||
setErrorMsg('Key must start with 0x and be 66 characters')
|
||||
setSaveState('error')
|
||||
return
|
||||
}
|
||||
setErrorMsg('')
|
||||
try {
|
||||
setSaveState('signing')
|
||||
const trimmed = apiKey.trim()
|
||||
const env = await signRequest({
|
||||
action: ACTION_SET_API_KEY,
|
||||
wallet: address,
|
||||
body: { api_key: trimmed },
|
||||
signMessageAsync,
|
||||
})
|
||||
setSaveState('saving')
|
||||
const res = await setHlApiKey(env, trimmed)
|
||||
setHlApiKeySet(true, res.masked_key)
|
||||
setApiKey('')
|
||||
setSaveState('success')
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||
if (msg.includes('User rejected') || msg.includes('denied')) {
|
||||
setErrorMsg('Signature cancelled')
|
||||
} else {
|
||||
setErrorMsg(msg.slice(0, 120))
|
||||
}
|
||||
setSaveState('error')
|
||||
}
|
||||
}
|
||||
|
||||
const saveLabel =
|
||||
saveState === 'signing' ? 'Waiting for signature…'
|
||||
: saveState === 'saving' ? 'Saving…'
|
||||
: saveState === 'success' ? '✓ Saved'
|
||||
: 'Save key'
|
||||
|
||||
return (
|
||||
<div className="w-[300px] shrink-0 flex flex-col gap-3">
|
||||
{/* Performance card */}
|
||||
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-[#555555] mb-4">
|
||||
{t('title')} · {t('period')}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label}>
|
||||
<p className="text-[10px] text-[#555555] mb-0.5">{stat.label}</p>
|
||||
<p className={`text-[16px] font-medium ${stat.positive ? 'text-[#4ade80]' : 'text-white'}`}>
|
||||
{stat.display ?? stat.value}
|
||||
</p>
|
||||
<>
|
||||
{/* Bot status card — dark design */}
|
||||
<div className="bot-status">
|
||||
<div className="bot-head">
|
||||
<h3>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 999, background: isSubscribed ? 'var(--amber)' : 'oklch(70% 0.01 85)', display: 'inline-block' }} />
|
||||
Auto-trader
|
||||
</h3>
|
||||
<span style={{ fontSize: 11, opacity: 0.7, textTransform: 'uppercase', letterSpacing: '0.08em' }}>30 days</span>
|
||||
</div>
|
||||
|
||||
<div className="bot-stats">
|
||||
<div className="bot-stat">
|
||||
<div className="k">Net P&L</div>
|
||||
<div className="v amber">
|
||||
{performance ? (performance.net_pnl_usd >= 0 ? '+$' : '-$') + Math.abs(performance.net_pnl_usd).toLocaleString(undefined, { maximumFractionDigits: 0 }) : '—'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="bot-stat">
|
||||
<div className="k">Win rate</div>
|
||||
<div className="v up">
|
||||
{performance ? (performance.win_rate * 100).toFixed(1) + '%' : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bot-stat">
|
||||
<div className="k">Trades</div>
|
||||
<div className="v">{performance?.total_trades ?? '—'}</div>
|
||||
</div>
|
||||
<div className="bot-stat">
|
||||
<div className="k">Avg hold</div>
|
||||
<div className="v">{performance ? fmtHold(performance.avg_hold_seconds) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bot-cta">
|
||||
{!isConnected && (
|
||||
<button className="btn amber" style={{ width: '100%' }}
|
||||
onClick={() => document.querySelector<HTMLButtonElement>('.connect-btn')?.click()}>
|
||||
Connect wallet
|
||||
</button>
|
||||
)}
|
||||
{isConnected && !isSubscribed && (
|
||||
<div style={{ width: '100%' }}>
|
||||
<button
|
||||
className="btn amber"
|
||||
style={{ width: '100%' }}
|
||||
onClick={handleSubscribe}
|
||||
disabled={subState === 'signing' || subState === 'saving'}
|
||||
>
|
||||
{subState === 'signing' ? 'Waiting for signature…' : subState === 'saving' ? 'Activating…' : 'Start trading'}
|
||||
</button>
|
||||
{subState === 'error' && subError && (
|
||||
<p style={{ fontSize: 11, color: 'var(--down)', marginTop: 6, textAlign: 'center' }}>{subError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isConnected && isSubscribed && !hlApiKeySet && (
|
||||
<div style={{ width: '100%', textAlign: 'center', fontSize: 12, color: 'oklch(75% 0.01 85)', padding: '6px 0' }}>
|
||||
↓ Paste your Hyperliquid API key below to activate
|
||||
</div>
|
||||
)}
|
||||
{isConnected && isSubscribed && hlApiKeySet && (
|
||||
<div style={{ width: '100%', textAlign: 'center', fontSize: 12, color: 'var(--amber)', padding: '6px 0', fontWeight: 500 }}>
|
||||
✓ Bot active · waiting for next signal
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-[#141414]" />
|
||||
|
||||
{/* Conditional bottom section */}
|
||||
{!isConnected && (
|
||||
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5 flex flex-col gap-3">
|
||||
<p className="text-[14px] font-medium text-white">{t('connectCta')}</p>
|
||||
<p className="text-[12px] text-[#555555] leading-relaxed">{t('connectDesc')}</p>
|
||||
<button
|
||||
onClick={openConnectModal}
|
||||
className="w-full bg-[#f97316] text-black font-medium text-[13px] rounded-lg py-2.5 hover:bg-[#fb923c] transition-colors"
|
||||
>
|
||||
{t('connectWalletFree')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && !isSubscribed && (
|
||||
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[13px] font-medium text-[#333333]">{t('settingsTitle')}</p>
|
||||
<span className="text-[10px] text-[#333333] border border-[#1e1e1e] rounded-full px-2 py-0.5">
|
||||
Locked
|
||||
</span>
|
||||
</div>
|
||||
{/* Disabled API key input */}
|
||||
<div className="opacity-40">
|
||||
<label className="text-[11px] text-[#555555] block mb-1">{t('apiKeyLabel')}</label>
|
||||
<input
|
||||
disabled
|
||||
placeholder={t('apiKeyPlaceholder')}
|
||||
className="w-full bg-[#060606] border border-[#141414] rounded-lg px-3 py-2 text-[12px] text-[#333333] placeholder-[#222222] cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSubscribed(true)}
|
||||
className="w-full bg-[#f97316] text-black font-medium text-[13px] rounded-lg py-2.5 hover:bg-[#fb923c] transition-colors"
|
||||
>
|
||||
{t('subscribeBtn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HL API Key card — only when subscribed */}
|
||||
{isConnected && isSubscribed && (
|
||||
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[13px] font-medium text-white">{t('settingsTitle')}</p>
|
||||
<span className="text-[10px] text-[#4ade80] border border-[#1a4a2a] bg-[#0d2e1a] rounded-full px-2 py-0.5">
|
||||
{t('activeStatus')}
|
||||
</span>
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<div className="section-title">
|
||||
<h2 style={{ fontSize: 14 }}>Hyperliquid API key</h2>
|
||||
{hlApiKeySet && <span style={{ fontSize: 11, color: 'var(--up)', fontWeight: 500 }}>✓ Connected</span>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-[#555555] block mb-1">{t('apiKeyLabel')}</label>
|
||||
<input
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={t('apiKeyPlaceholder')}
|
||||
className="w-full bg-[#060606] border border-[#141414] rounded-lg px-3 py-2 text-[12px] text-white placeholder-[#333333] focus:outline-none focus:border-[#222222]"
|
||||
/>
|
||||
</div>
|
||||
<button className="w-full bg-[#141414] text-white border border-[#1a1a1a] text-[13px] rounded-lg py-2 hover:bg-[#0d0d0d] transition-colors">
|
||||
{t('saveKey')}
|
||||
</button>
|
||||
|
||||
{hlApiKeySet && !apiKey && (
|
||||
<div className="row between" style={{ padding: '10px 12px', background: 'var(--bg-sunk)', borderRadius: 'var(--r-sm)', border: '1px solid var(--line)', marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--up)', fontFamily: 'var(--mono)' }}>
|
||||
{hlApiKeyMasked ?? '···'}
|
||||
</span>
|
||||
<button
|
||||
style={{ fontSize: 11, color: 'var(--ink-3)' }}
|
||||
onClick={() => { setSaveState('idle'); setApiKey(' ') }}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!hlApiKeySet || apiKey) && (
|
||||
<>
|
||||
<div className="field" style={{ marginBottom: 12 }}>
|
||||
<label>API wallet private key</label>
|
||||
<input
|
||||
value={apiKey.trim() === '' && hlApiKeySet ? '' : apiKey}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value)
|
||||
if (saveState === 'error' || saveState === 'success') setSaveState('idle')
|
||||
}}
|
||||
placeholder="0x…"
|
||||
/>
|
||||
<div className="hint">
|
||||
From{' '}
|
||||
<a href="https://app.hyperliquid.xyz/API" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--amber-ink)' }}>
|
||||
app.hyperliquid.xyz/API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveState === 'error' && errorMsg && (
|
||||
<p style={{ fontSize: 11, color: 'var(--down)', marginBottom: 8 }}>{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`btn ${saveState === 'success' ? 'ghost' : 'amber'}`}
|
||||
style={{ width: '100%' }}
|
||||
onClick={handleSaveKey}
|
||||
disabled={saveState === 'signing' || saveState === 'saving' || !apiKey.trim()}
|
||||
>
|
||||
{saveLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hlApiKeySet && (
|
||||
<details style={{ marginTop: 12 }}>
|
||||
<summary style={{ fontSize: 11, color: 'var(--ink-3)', cursor: 'pointer' }}>
|
||||
How to get your API key
|
||||
</summary>
|
||||
<ol style={{ marginTop: 8, fontSize: 11, color: 'var(--ink-3)', paddingLeft: 16, lineHeight: 1.7 }}>
|
||||
<li>Deposit USDC at app.hyperliquid.xyz</li>
|
||||
<li>Go to app.hyperliquid.xyz/API</li>
|
||||
<li>Click <strong>Generate API Wallet</strong></li>
|
||||
<li>Sign with MetaMask (no gas)</li>
|
||||
<li>Copy the private key → paste above</li>
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+106
-101
@@ -3,56 +3,64 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { TrumpPost, Candle } from '@/types'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import Pill from '@/components/ui/Pill'
|
||||
import PostCards, { MOCK_POSTS } from './PostCards'
|
||||
import ExpandDetail from './ExpandDetail'
|
||||
|
||||
interface ChartPanelProps {
|
||||
posts?: TrumpPost[]
|
||||
candles?: Candle[]
|
||||
externalSelectedId?: number | null
|
||||
onSelectPost?: (id: number | null) => void
|
||||
}
|
||||
|
||||
export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPanelProps) {
|
||||
const { asset, timeframe, setAsset, setTimeframe, selectedPostId, setSelectedPost } = useDashboardStore()
|
||||
export default function ChartPanel({ posts = [], candles = [], externalSelectedId, onSelectPost }: ChartPanelProps) {
|
||||
const { timeframe } = useDashboardStore()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<unknown>(null)
|
||||
const seriesRef = useRef<unknown>(null)
|
||||
const fittedRef = useRef(false)
|
||||
// Keep latest posts/selectedPostId accessible inside chart callbacks without re-subscribing
|
||||
const postsRef = useRef(posts)
|
||||
postsRef.current = posts
|
||||
const selectedPostIdRef = useRef(selectedPostId)
|
||||
selectedPostIdRef.current = selectedPostId
|
||||
const selectedPostIdRef = useRef(externalSelectedId)
|
||||
selectedPostIdRef.current = externalSelectedId
|
||||
const timeframeRef = useRef(timeframe)
|
||||
timeframeRef.current = timeframe
|
||||
const onSelectRef = useRef(onSelectPost)
|
||||
onSelectRef.current = onSelectPost
|
||||
|
||||
const assets: Array<'BTC' | 'ETH'> = ['BTC', 'ETH']
|
||||
const timeframes: Array<'5m' | '15m' | '1H' | '4H' | '1D' | '1W'> = ['5m', '15m', '1H', '4H', '1D', '1W']
|
||||
|
||||
const selectedPost = posts.find((p) => p.id === selectedPostId) ?? null
|
||||
// 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
|
||||
|
||||
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: '#050505' },
|
||||
textColor: '#555555',
|
||||
background: { color: colors.background },
|
||||
textColor: colors.textColor,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#111111' },
|
||||
horzLines: { color: '#111111' },
|
||||
vertLines: { color: colors.gridColor },
|
||||
horzLines: { color: colors.gridColor },
|
||||
},
|
||||
crosshair: { mode: CrosshairMode.Normal },
|
||||
rightPriceScale: { borderColor: '#1a1a1a' },
|
||||
rightPriceScale: { borderColor: colors.borderColor },
|
||||
timeScale: {
|
||||
borderColor: '#1a1a1a',
|
||||
borderColor: colors.borderColor,
|
||||
timeVisible: true,
|
||||
rightOffset: 5,
|
||||
barSpacing: 10,
|
||||
@@ -61,47 +69,54 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
|
||||
handleScale: true,
|
||||
})
|
||||
|
||||
// @ts-expect-error lightweight-charts type
|
||||
chartRef.current = chart
|
||||
|
||||
const series = chart.addCandlestickSeries({
|
||||
upColor: '#4ade80',
|
||||
downColor: '#ef4444',
|
||||
borderUpColor: '#4ade80',
|
||||
borderDownColor: '#ef4444',
|
||||
wickUpColor: '#4ade80',
|
||||
wickDownColor: '#ef4444',
|
||||
upColor: '#26a69a',
|
||||
downColor: '#ef5350',
|
||||
borderUpColor: '#26a69a',
|
||||
borderDownColor: '#ef5350',
|
||||
wickUpColor: '#26a69a',
|
||||
wickDownColor: '#ef5350',
|
||||
})
|
||||
|
||||
// @ts-expect-error lightweight-charts type
|
||||
seriesRef.current = series
|
||||
|
||||
// Click on chart: find nearest post marker within 2-bar tolerance
|
||||
chart.subscribeClick((param: { time?: number | string }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
chart.subscribeClick((param: any) => {
|
||||
if (!param.time) return
|
||||
const clickTime = typeof param.time === 'number' ? param.time : 0
|
||||
if (!clickTime) return
|
||||
|
||||
const allPosts = postsRef.current
|
||||
let closest: TrumpPost | null = null
|
||||
let closestDiff = Infinity
|
||||
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
|
||||
|
||||
for (const p of allPosts) {
|
||||
if (!p.published_at) continue
|
||||
const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
|
||||
const diff = Math.abs(pt - clickTime)
|
||||
if (diff < closestDiff) {
|
||||
closestDiff = diff
|
||||
closest = p
|
||||
}
|
||||
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
|
||||
})
|
||||
.sort((a, b) => (b.ai_confidence ?? 0) - (a.ai_confidence ?? 0))
|
||||
|
||||
if (inBucket.length === 0) {
|
||||
onSelectRef.current?.(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Only select if click is within 2 hours of a post (7200 seconds)
|
||||
if (closest && closestDiff <= 7200) {
|
||||
const newId = closest.id === selectedPostIdRef.current ? null : closest.id
|
||||
setSelectedPost(newId)
|
||||
const currentIdx = inBucket.findIndex((p) => p.id === selectedPostIdRef.current)
|
||||
if (currentIdx >= 0) {
|
||||
const nextIdx = currentIdx + 1
|
||||
if (nextIdx >= inBucket.length) {
|
||||
onSelectRef.current?.(null)
|
||||
} else {
|
||||
onSelectRef.current?.(inBucket[nextIdx].id)
|
||||
}
|
||||
} else {
|
||||
setSelectedPost(null)
|
||||
onSelectRef.current?.(inBucket[0].id)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -112,9 +127,7 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
|
||||
})
|
||||
ro.observe(containerRef.current)
|
||||
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
}
|
||||
return () => { ro.disconnect() }
|
||||
})
|
||||
|
||||
return () => {
|
||||
@@ -130,7 +143,7 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Update candles + markers whenever data changes
|
||||
// Update candles + markers
|
||||
useEffect(() => {
|
||||
const series = seriesRef.current
|
||||
const chart = chartRef.current
|
||||
@@ -147,7 +160,6 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
|
||||
close: c.close,
|
||||
})))
|
||||
|
||||
// Show markers for all posts within visible time range
|
||||
const minTime = sorted[0].time
|
||||
const maxTime = sorted[sorted.length - 1].time
|
||||
|
||||
@@ -158,20 +170,44 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
|
||||
})
|
||||
|
||||
if (visible.length > 0) {
|
||||
const markers = [...visible]
|
||||
.sort((a, b) => new Date(a.published_at).getTime() - new Date(b.published_at).getTime())
|
||||
.map((p) => ({
|
||||
time: Math.floor(new Date(p.published_at).getTime() / 1000) as number,
|
||||
position: 'aboveBar' as const,
|
||||
color: p.id === selectedPostId
|
||||
? '#fb923c'
|
||||
: p.sentiment === 'bearish'
|
||||
? '#ef4444'
|
||||
: '#f97316',
|
||||
shape: 'circle' as const,
|
||||
text: '',
|
||||
size: p.id === selectedPostId ? 2 : 1,
|
||||
}))
|
||||
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)
|
||||
}
|
||||
@@ -181,45 +217,14 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
|
||||
chart.timeScale().fitContent()
|
||||
fittedRef.current = true
|
||||
}
|
||||
}, [candles, posts, selectedPostId])
|
||||
}, [candles, posts, externalSelectedId])
|
||||
|
||||
// Reset fit flag on timeframe/asset switch
|
||||
useEffect(() => {
|
||||
fittedRef.current = false
|
||||
}, [asset, timeframe])
|
||||
useEffect(() => { fittedRef.current = false }, [timeframe])
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-4 flex flex-col gap-3">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{assets.map((a) => (
|
||||
<Pill key={a} active={asset === a} onClick={() => setAsset(a)}>
|
||||
{a}
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{timeframes.map((tf) => (
|
||||
<Pill key={tf} active={timeframe === tf} onClick={() => setTimeframe(tf as '4H' | '1D' | '1W')}>
|
||||
{tf}
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full rounded-lg overflow-hidden cursor-crosshair"
|
||||
style={{ height: 360, background: '#050505' }}
|
||||
/>
|
||||
|
||||
{/* Selected post detail — shown immediately below chart */}
|
||||
<ExpandDetail post={selectedPost} />
|
||||
|
||||
{/* Post cards list */}
|
||||
<PostCards posts={posts} />
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ width: '100%', height: 360, borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { TrumpPost } from '@/types'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { formatPct } from '@/lib/utils'
|
||||
|
||||
interface ExpandDetailProps {
|
||||
post: TrumpPost | null
|
||||
}
|
||||
|
||||
export default function ExpandDetail({ post }: ExpandDetailProps) {
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-in-out ${
|
||||
post ? 'max-h-[300px] opacity-100 mt-3' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
{post && (
|
||||
<div className="bg-[#0a0a0a] border border-[#f97316] rounded-[10px] p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Post content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Badge variant={post.source} />
|
||||
<Badge variant={post.sentiment} />
|
||||
<span className="text-[11px] text-[#555555]">
|
||||
{new Date(post.published_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-[#e2e8f0] leading-relaxed">{post.text}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="shrink-0 w-[260px]">
|
||||
{/* AI Confidence */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[11px] uppercase tracking-wider text-[#555555]">
|
||||
AI Confidence
|
||||
</span>
|
||||
<span className="text-[13px] font-medium text-[#818cf8]">
|
||||
{post.ai_confidence}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-[#141414] rounded-full">
|
||||
<div
|
||||
className="h-full bg-[#818cf8] rounded-full transition-all duration-500"
|
||||
style={{ width: `${post.ai_confidence}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price impact */}
|
||||
{post.price_impact ? (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-[#555555] mb-2">
|
||||
Price Impact ({post.price_impact.asset})
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ label: '5m', value: post.price_impact.m5 },
|
||||
{ label: '15m', value: post.price_impact.m15 },
|
||||
{ label: '1h', value: post.price_impact.m1h },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="bg-[#050505] border border-[#141414] rounded-lg p-2 text-center">
|
||||
<p className="text-[10px] text-[#555555] mb-1">{item.label}</p>
|
||||
<p
|
||||
className={`text-[13px] font-medium ${
|
||||
item.value >= 0 ? 'text-[#4ade80]' : 'text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
{formatPct(item.value)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] text-[#333333]">No significant price impact detected.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import type { BotPerformance } from '@/types'
|
||||
import { formatPrice, formatPct } from '@/lib/utils'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
|
||||
interface KpiRowProps {
|
||||
performance?: BotPerformance
|
||||
postsCount?: number
|
||||
}
|
||||
|
||||
interface StatCard {
|
||||
labelKey: string
|
||||
value: string
|
||||
change?: string
|
||||
changePositive?: boolean
|
||||
}
|
||||
|
||||
export default function KpiRow({ performance, postsCount }: KpiRowProps) {
|
||||
const t = useTranslations('kpi')
|
||||
const { livePrices } = useDashboardStore()
|
||||
|
||||
const stats: StatCard[] = [
|
||||
{
|
||||
labelKey: 'btcPrice',
|
||||
value: formatPrice(livePrices.BTC ?? 94230),
|
||||
change: livePrices.BTC ? 'live' : '+1.4%',
|
||||
changePositive: true,
|
||||
},
|
||||
{
|
||||
labelKey: 'ethPrice',
|
||||
value: formatPrice(livePrices.ETH ?? 1847),
|
||||
change: livePrices.ETH ? 'live' : '-0.8%',
|
||||
changePositive: false,
|
||||
},
|
||||
{
|
||||
labelKey: 'postsTracked',
|
||||
value: postsCount != null ? postsCount.toLocaleString() : '1,247',
|
||||
change: '+12 today',
|
||||
changePositive: true,
|
||||
},
|
||||
{
|
||||
labelKey: 'avgMove',
|
||||
value: performance
|
||||
? formatPct(performance.net_pnl_usd > 0 ? 2.3 : -2.3)
|
||||
: formatPct(2.3),
|
||||
change: 'after Trump posts',
|
||||
changePositive: true,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.labelKey}
|
||||
className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5"
|
||||
>
|
||||
<p className="text-[11px] uppercase tracking-wider text-[#555555] mb-2">
|
||||
{t(stat.labelKey as 'btcPrice' | 'ethPrice' | 'postsTracked' | 'avgMove')}
|
||||
</p>
|
||||
<p className="text-[22px] font-medium text-white leading-none mb-1.5">{stat.value}</p>
|
||||
{stat.change && (
|
||||
<p
|
||||
className={`text-[12px] ${
|
||||
stat.changePositive ? 'text-[#4ade80]' : 'text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
{stat.change}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,181 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import type { TrumpPost } from '@/types'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import { formatPct } from '@/lib/utils'
|
||||
|
||||
const MOCK_POSTS: TrumpPost[] = [
|
||||
{
|
||||
id: 1,
|
||||
text: 'BITCOIN IS THE FUTURE OF MONEY! We will make America the crypto capital of the world. BIG things coming very soon. The dollar will be STRONGER than ever with Bitcoin as our reserve!',
|
||||
source: 'truth',
|
||||
published_at: new Date(Date.now() - 1000 * 60 * 14).toISOString(),
|
||||
sentiment: 'bullish',
|
||||
ai_confidence: 91,
|
||||
relevant: true,
|
||||
price_impact: {
|
||||
asset: 'BTC',
|
||||
m5: 1.2,
|
||||
m15: 2.8,
|
||||
m1h: 3.4,
|
||||
price_at_post: 92800,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
text: 'Ethereum and all these so-called "smart contracts" are nothing but a scam by the radical left globalists. Very bad for America. We need REAL money, not fake computer tricks!',
|
||||
source: 'x',
|
||||
published_at: new Date(Date.now() - 1000 * 60 * 47).toISOString(),
|
||||
sentiment: 'bearish',
|
||||
ai_confidence: 84,
|
||||
relevant: true,
|
||||
price_impact: {
|
||||
asset: 'ETH',
|
||||
m5: -1.9,
|
||||
m15: -3.1,
|
||||
m1h: -4.2,
|
||||
price_at_post: 1920,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
text: "Met with many great business leaders today. Discussed the future of America's economy. Jobs are coming back FAST. Nobody builds like Trump!",
|
||||
source: 'truth',
|
||||
published_at: new Date(Date.now() - 1000 * 60 * 120).toISOString(),
|
||||
sentiment: 'neutral',
|
||||
ai_confidence: 42,
|
||||
relevant: false,
|
||||
price_impact: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
text: 'Crypto regulations will be ELIMINATED under my watch. No more Biden weaponization of the SEC against Bitcoin holders. We will have the most CRYPTO FRIENDLY government in history!',
|
||||
source: 'truth',
|
||||
published_at: new Date(Date.now() - 1000 * 60 * 210).toISOString(),
|
||||
sentiment: 'bullish',
|
||||
ai_confidence: 88,
|
||||
relevant: true,
|
||||
price_impact: {
|
||||
asset: 'BTC',
|
||||
m5: 0.9,
|
||||
m15: 2.1,
|
||||
m1h: 2.7,
|
||||
price_at_post: 91500,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
interface PostCardsProps {
|
||||
posts?: TrumpPost[]
|
||||
function fmtPct(n: number | null | undefined) {
|
||||
if (n == null || isNaN(n)) return '—'
|
||||
const s = n.toFixed(2) + '%'
|
||||
return n >= 0 ? '+' + s : s
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
function timeAgo(iso: string) {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return `${Math.floor(hours / 24)}d ago`
|
||||
const m = Math.floor(diff / 60000)
|
||||
if (m < 1) return 'just now'
|
||||
if (m < 60) return m + 'm'
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return h + 'h'
|
||||
return Math.floor(h / 24) + 'd'
|
||||
}
|
||||
|
||||
export default function PostCards({ posts = MOCK_POSTS }: PostCardsProps) {
|
||||
const t = useTranslations('common')
|
||||
const { selectedPostId, setSelectedPost } = useDashboardStore()
|
||||
const selectedRef = useRef<HTMLDivElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
function SourceIcon({ source }: { source: string }) {
|
||||
if (source === 'x') return <div className="src-ico x">𝕏</div>
|
||||
return <div className="src-ico truth">T</div>
|
||||
}
|
||||
|
||||
// Auto-scroll selected card into view
|
||||
useEffect(() => {
|
||||
if (selectedRef.current && scrollRef.current) {
|
||||
selectedRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' })
|
||||
}
|
||||
}, [selectedPostId])
|
||||
function SignalPill({ signal }: { signal: string | null }) {
|
||||
if (!signal || signal === 'hold') return <span className="sig hold">HOLD</span>
|
||||
return <span className={`sig ${signal}`}>{signal.toUpperCase()}</span>
|
||||
}
|
||||
|
||||
interface PostRowProps {
|
||||
post: TrumpPost
|
||||
selected?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export default function PostRow({ post, selected, onClick }: PostRowProps) {
|
||||
const impact = post.price_impact
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-[#555555]">
|
||||
Posts · {posts.length}
|
||||
</span>
|
||||
{selectedPostId && (
|
||||
<button
|
||||
onClick={() => setSelectedPost(null)}
|
||||
className="text-[11px] text-[#f97316] hover:text-[#fb923c] transition-colors"
|
||||
>
|
||||
clear selection ×
|
||||
</button>
|
||||
)}
|
||||
<div className={`post-row ${selected ? 'selected' : ''}`} onClick={onClick}>
|
||||
<SourceIcon source={post.source} />
|
||||
<div className="post-body">
|
||||
<div className="meta">
|
||||
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>@realDonaldTrump</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(post.published_at)} ago</span>
|
||||
<span>·</span>
|
||||
<span className={`chip ${post.sentiment === 'bullish' ? 'up' : post.sentiment === 'bearish' ? 'down' : 'neutral'}`} style={{ padding: '2px 8px', fontSize: 11 }}>
|
||||
{post.sentiment}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text">{post.text.slice(0, 180)}{post.text.length > 180 ? '…' : ''}</p>
|
||||
</div>
|
||||
|
||||
{/* Horizontally scrollable row */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-3 overflow-x-auto pb-2"
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
>
|
||||
{posts.map((post) => {
|
||||
const isSelected = selectedPostId === post.id
|
||||
const impact = post.price_impact
|
||||
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
ref={isSelected ? selectedRef : null}
|
||||
onClick={() => setSelectedPost(isSelected ? null : post.id)}
|
||||
className={`shrink-0 w-[200px] bg-[#0a0a0a] border rounded-[10px] p-3 cursor-pointer transition-colors hover:bg-[#0d0d0d] ${
|
||||
isSelected ? 'border-[#f97316]' : 'border-[#141414]'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant={post.source} />
|
||||
<Badge variant={post.sentiment} />
|
||||
</div>
|
||||
<span className="text-[10px] text-[#555555]">{timeAgo(post.published_at)}</span>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<p className="text-[11px] text-[#e2e8f0] leading-relaxed mb-2 line-clamp-3">
|
||||
{post.text.slice(0, 90)}
|
||||
{post.text.length > 90 ? '…' : ''}
|
||||
</p>
|
||||
|
||||
{/* Confidence bar */}
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-[#555555]">AI</span>
|
||||
<span className="text-[10px] text-[#818cf8]">{post.ai_confidence}%</span>
|
||||
</div>
|
||||
<div className="h-[2px] bg-[#141414] rounded-full">
|
||||
<div
|
||||
className="h-full bg-[#818cf8] rounded-full"
|
||||
style={{ width: `${post.ai_confidence}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price impact */}
|
||||
{impact ? (
|
||||
<span
|
||||
className={`text-[11px] font-medium ${
|
||||
impact.m1h >= 0 ? 'text-[#4ade80]' : 'text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
{formatPct(impact.m1h)} 1h
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-[#333333]">{t('neutral')}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="post-aside">
|
||||
<SignalPill signal={post.signal} />
|
||||
<div className="impact-mini">
|
||||
{impact ? (
|
||||
<>
|
||||
<span className="tf">1h</span>
|
||||
<span className={`delta ${(impact.m1h ?? 0) >= 0 ? 'up' : 'down'}`}>{fmtPct(impact.m1h)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="tf">no data</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="row gap-s" style={{ fontSize: 11, color: 'var(--ink-3)' }}>
|
||||
<span>AI</span>
|
||||
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>{post.ai_confidence}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { MOCK_POSTS }
|
||||
export { SignalPill, SourceIcon, fmtPct, timeAgo }
|
||||
|
||||
+120
-51
@@ -1,74 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAccount } from 'wagmi'
|
||||
import { useConnectModal } from '@rainbow-me/rainbowkit'
|
||||
import { shortenAddress } from '@/lib/utils'
|
||||
import { useAccount, useConnect, useDisconnect } from 'wagmi'
|
||||
import { injected } from 'wagmi/connectors'
|
||||
|
||||
const navItems = [
|
||||
{ key: 'overview', href: '' },
|
||||
{ key: 'posts', href: '/posts' },
|
||||
{ key: 'trades', href: '/trades' },
|
||||
{ key: 'analytics', href: '/analytics' },
|
||||
] as const
|
||||
|
||||
interface NavbarProps {
|
||||
locale: string
|
||||
function BrandMark() {
|
||||
return <span className="brand-mark">α</span>
|
||||
}
|
||||
|
||||
export default function Navbar({ locale }: NavbarProps) {
|
||||
const t = useTranslations('nav')
|
||||
const pathname = usePathname()
|
||||
const { address, isConnected } = useAccount()
|
||||
const { openConnectModal } = useConnectModal()
|
||||
function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('light')
|
||||
|
||||
function isActive(href: string) {
|
||||
const full = `/${locale}${href}`
|
||||
if (href === '') return pathname === `/${locale}` || pathname === `/${locale}/`
|
||||
return pathname.startsWith(full)
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('theme') as 'light' | 'dark' | null
|
||||
const t = stored || 'light'
|
||||
setTheme(t)
|
||||
document.documentElement.dataset.theme = t
|
||||
}, [])
|
||||
|
||||
function toggle() {
|
||||
const next = theme === 'dark' ? 'light' : 'dark'
|
||||
setTheme(next)
|
||||
localStorage.setItem('theme', next)
|
||||
document.documentElement.dataset.theme = next
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 h-14 bg-[#000000] border-b border-[#141414] flex items-center px-6">
|
||||
{/* Logo */}
|
||||
<Link href={`/${locale}`} className="flex items-center gap-2 mr-10 shrink-0">
|
||||
<span className="text-[#f97316] font-bold text-lg leading-none">TS</span>
|
||||
<span className="text-white font-medium text-sm">TrumpSignal</span>
|
||||
</Link>
|
||||
<button className="icon-btn theme-toggle" onClick={toggle}>
|
||||
{theme === 'dark' ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Center nav */}
|
||||
<div className="flex items-center gap-6 flex-1">
|
||||
{navItems.map((item) => (
|
||||
export default function Navbar() {
|
||||
const pathname = usePathname()
|
||||
const { address, isConnected } = useAccount()
|
||||
const { connect } = useConnect()
|
||||
const { disconnect } = useDisconnect()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
const locale = pathname.split('/')[1] || 'en'
|
||||
const path = '/' + pathname.split('/').slice(2).join('/')
|
||||
|
||||
const tabs = [
|
||||
{ key: '/', label: 'Overview' },
|
||||
{ key: '/posts', label: 'Signals' },
|
||||
{ key: '/trades', label: 'Trades' },
|
||||
{ key: '/analytics', label: 'Analytics' },
|
||||
{ key: '/settings', label: 'Settings' },
|
||||
]
|
||||
|
||||
function isActive(key: string) {
|
||||
if (key === '/') return path === '/' || path === '//'
|
||||
return path.startsWith(key)
|
||||
}
|
||||
|
||||
const shortAddr = address ? `${address.slice(0, 6)}…${address.slice(-4)}` : null
|
||||
|
||||
return (
|
||||
<nav className="nav">
|
||||
<div className="brand">
|
||||
<BrandMark />
|
||||
<span>Trump Alpha</span>
|
||||
</div>
|
||||
|
||||
<div className="nav-tabs">
|
||||
{tabs.map(t => (
|
||||
<Link
|
||||
key={item.key}
|
||||
href={`/${locale}${item.href}`}
|
||||
className={`text-[13px] transition-colors ${
|
||||
isActive(item.href) ? 'text-[#f97316]' : 'text-[#555555] hover:text-white'
|
||||
}`}
|
||||
key={t.key}
|
||||
href={`/${locale}${t.key === '/' ? '' : t.key}`}
|
||||
className={`nav-tab ${isActive(t.key) ? 'active' : ''}`}
|
||||
>
|
||||
{t(item.key)}
|
||||
{t.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button className="text-[13px] text-[#555555] hover:text-white border border-[#141414] rounded-full px-3 py-1 transition-colors">
|
||||
{t('language')} ▾
|
||||
</button>
|
||||
<div className="nav-spacer" />
|
||||
|
||||
{isConnected && address ? (
|
||||
<button className="text-[13px] bg-[#141414] text-white border border-[#1a1a1a] rounded-full px-3 py-1">
|
||||
{shortenAddress(address)}
|
||||
</button>
|
||||
<div className="nav-right">
|
||||
<ThemeToggle />
|
||||
{!mounted ? (
|
||||
<button className="connect-btn lg" suppressHydrationWarning>Connect wallet</button>
|
||||
) : isConnected && shortAddr ? (
|
||||
<div className="wallet-menu-wrap" style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="wallet-chip"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const menu = (e.currentTarget.nextElementSibling as HTMLElement | null)
|
||||
if (menu) menu.style.display = menu.style.display === 'block' ? 'none' : 'block'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const menu = e.currentTarget.nextElementSibling as HTMLElement | null
|
||||
setTimeout(() => { if (menu) menu.style.display = 'none' }, 150)
|
||||
}}
|
||||
>
|
||||
<span className="ava" />
|
||||
<span className="mono">{shortAddr}</span>
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
display: 'none', position: 'absolute', right: 0, top: 'calc(100% + 6px)',
|
||||
background: 'var(--bg-elev)', border: '1px solid var(--line)', borderRadius: 'var(--r-sm)',
|
||||
minWidth: 180, padding: 6, zIndex: 1000, boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
if (address) navigator.clipboard?.writeText(address)
|
||||
}}
|
||||
style={{ width: '100%', padding: '8px 10px', fontSize: 13, textAlign: 'left', borderRadius: 6, color: 'var(--ink)' }}
|
||||
>
|
||||
Copy address
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(e) => { e.preventDefault(); disconnect() }}
|
||||
style={{ width: '100%', padding: '8px 10px', fontSize: 13, textAlign: 'left', borderRadius: 6, color: 'var(--down)' }}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={openConnectModal}
|
||||
className="text-[13px] bg-[#f97316] text-black font-medium rounded-full px-4 py-1.5 hover:bg-[#fb923c] transition-colors"
|
||||
>
|
||||
{t('connectWallet')}
|
||||
<button className="connect-btn lg" onClick={() => connect({ connector: injected() })}>
|
||||
Connect wallet
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import type { BotTrade } from '@/types'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { formatPrice, formatHold } from '@/lib/utils'
|
||||
|
||||
const MOCK_TRADES: BotTrade[] = [
|
||||
{
|
||||
id: 1,
|
||||
asset: 'BTC',
|
||||
side: 'long',
|
||||
entry_price: 92800,
|
||||
exit_price: 95100,
|
||||
pnl_usd: 2300,
|
||||
hold_seconds: 52 * 60,
|
||||
trigger_post_id: 1,
|
||||
opened_at: new Date(Date.now() - 1000 * 60 * 80).toISOString(),
|
||||
closed_at: new Date(Date.now() - 1000 * 60 * 28).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
asset: 'ETH',
|
||||
side: 'short',
|
||||
entry_price: 1920,
|
||||
exit_price: 1847,
|
||||
pnl_usd: 1460,
|
||||
hold_seconds: 34 * 60,
|
||||
trigger_post_id: 2,
|
||||
opened_at: new Date(Date.now() - 1000 * 60 * 120).toISOString(),
|
||||
closed_at: new Date(Date.now() - 1000 * 60 * 86).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
asset: 'BTC',
|
||||
side: 'long',
|
||||
entry_price: 91500,
|
||||
exit_price: 90200,
|
||||
pnl_usd: -1300,
|
||||
hold_seconds: 22 * 60,
|
||||
trigger_post_id: 4,
|
||||
opened_at: new Date(Date.now() - 1000 * 60 * 240).toISOString(),
|
||||
closed_at: new Date(Date.now() - 1000 * 60 * 218).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
asset: 'ETH',
|
||||
side: 'long',
|
||||
entry_price: 1780,
|
||||
exit_price: 1840,
|
||||
pnl_usd: 900,
|
||||
hold_seconds: 8 * 60,
|
||||
trigger_post_id: 1,
|
||||
opened_at: new Date(Date.now() - 1000 * 60 * 360).toISOString(),
|
||||
closed_at: new Date(Date.now() - 1000 * 60 * 352).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
asset: 'BTC',
|
||||
side: 'short',
|
||||
entry_price: 93400,
|
||||
exit_price: 91800,
|
||||
pnl_usd: 3200,
|
||||
hold_seconds: 18 * 60,
|
||||
trigger_post_id: 2,
|
||||
opened_at: new Date(Date.now() - 1000 * 60 * 500).toISOString(),
|
||||
closed_at: new Date(Date.now() - 1000 * 60 * 482).toISOString(),
|
||||
},
|
||||
]
|
||||
|
||||
interface TradesTableProps {
|
||||
trades?: BotTrade[]
|
||||
}
|
||||
|
||||
export default function TradesTable({ trades = MOCK_TRADES }: TradesTableProps) {
|
||||
const t = useTranslations('trades')
|
||||
|
||||
return (
|
||||
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[#141414]">
|
||||
<p className="text-[13px] font-medium text-white">{t('title')}</p>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-[#050505]">
|
||||
{[
|
||||
t('asset'),
|
||||
t('side'),
|
||||
t('entry'),
|
||||
t('exit'),
|
||||
t('pnl'),
|
||||
t('hold'),
|
||||
t('trigger'),
|
||||
].map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-[#555555] font-medium"
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.map((trade) => (
|
||||
<tr
|
||||
key={trade.id}
|
||||
className="border-b border-[#141414] hover:bg-[#0d0d0d] transition-colors"
|
||||
>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-[13px] font-medium text-white">{trade.asset}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<Badge variant={trade.side} />
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-[13px] text-[#e2e8f0]">{formatPrice(trade.entry_price)}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-[13px] text-[#e2e8f0]">{formatPrice(trade.exit_price)}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span
|
||||
className={`text-[13px] font-medium ${
|
||||
trade.pnl_usd >= 0 ? 'text-[#4ade80]' : 'text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
{trade.pnl_usd >= 0 ? '+' : ''}${trade.pnl_usd.toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-[13px] text-[#555555]">{formatHold(trade.hold_seconds)}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-[12px] text-[#818cf8] hover:text-[#a5b4fc] cursor-pointer">
|
||||
#{trade.trigger_post_id}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { MOCK_TRADES }
|
||||
@@ -1,36 +0,0 @@
|
||||
type BadgeVariant = 'bullish' | 'bearish' | 'neutral' | 'long' | 'short' | 'x' | 'truth'
|
||||
|
||||
interface BadgeProps {
|
||||
variant: BadgeVariant
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const variantStyles: Record<BadgeVariant, string> = {
|
||||
bullish: 'bg-[#0d2e1a] text-[#4ade80] border border-[#1a4a2a]',
|
||||
bearish: 'bg-[#2e0d0d] text-[#ef4444] border border-[#4a1a1a]',
|
||||
neutral: 'bg-[#141414] text-[#555555] border border-[#1e1e1e]',
|
||||
long: 'bg-[#0d2e1a] text-[#4ade80] border border-[#1a4a2a]',
|
||||
short: 'bg-[#2e0d0d] text-[#ef4444] border border-[#4a1a1a]',
|
||||
x: 'bg-[#141414] text-white border border-[#222222]',
|
||||
truth: 'bg-[#1a1a2e] text-[#818cf8] border border-[#2a2a4a]',
|
||||
}
|
||||
|
||||
const variantLabels: Record<BadgeVariant, string> = {
|
||||
bullish: 'Bullish',
|
||||
bearish: 'Bearish',
|
||||
neutral: 'Neutral',
|
||||
long: 'Long',
|
||||
short: 'Short',
|
||||
x: 'X',
|
||||
truth: 'Truth',
|
||||
}
|
||||
|
||||
export default function Badge({ variant, children }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${variantStyles[variant]}`}
|
||||
>
|
||||
{children ?? variantLabels[variant]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Card({ children, className = '' }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
'use client'
|
||||
|
||||
interface PillProps {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function Pill({ active, onClick, children }: PillProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={
|
||||
`rounded-full px-3 py-1 text-xs transition-colors ` +
|
||||
(active
|
||||
? 'bg-[#141414] text-white border border-[#222222]'
|
||||
: 'text-[#555555] border border-[#141414] hover:text-white')
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user