first day of vibe coding
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState } from 'react'
|
||||
import { useAccount } from 'wagmi'
|
||||
import { useConnectModal } from '@rainbow-me/rainbowkit'
|
||||
import type { BotPerformance } from '@/types'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import { formatPct, formatHold } from '@/lib/utils'
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
interface BotPanelProps {
|
||||
performance?: BotPerformance
|
||||
}
|
||||
|
||||
export default function BotPanel({ performance = MOCK_PERFORMANCE }: BotPanelProps) {
|
||||
const t = useTranslations('bot')
|
||||
const { isSubscribed, setSubscribed } = useDashboardStore()
|
||||
const { address, isConnected } = useAccount()
|
||||
const { openConnectModal } = useConnectModal()
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
|
||||
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) },
|
||||
]
|
||||
|
||||
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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPanelProps) {
|
||||
const { asset, timeframe, setAsset, setTimeframe, selectedPostId, setSelectedPost } = 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 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
|
||||
|
||||
// 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 chart = createChart(containerRef.current, {
|
||||
width: containerRef.current.clientWidth,
|
||||
height: 360,
|
||||
layout: {
|
||||
background: { color: '#050505' },
|
||||
textColor: '#555555',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#111111' },
|
||||
horzLines: { color: '#111111' },
|
||||
},
|
||||
crosshair: { mode: CrosshairMode.Normal },
|
||||
rightPriceScale: { borderColor: '#1a1a1a' },
|
||||
timeScale: {
|
||||
borderColor: '#1a1a1a',
|
||||
timeVisible: true,
|
||||
rightOffset: 5,
|
||||
barSpacing: 10,
|
||||
},
|
||||
handleScroll: true,
|
||||
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',
|
||||
})
|
||||
|
||||
// @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 }) => {
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
} else {
|
||||
setSelectedPost(null)
|
||||
}
|
||||
})
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (containerRef.current && !destroyed) {
|
||||
chart.applyOptions({ width: containerRef.current.clientWidth })
|
||||
}
|
||||
})
|
||||
ro.observe(containerRef.current)
|
||||
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
destroyed = true
|
||||
fittedRef.current = false
|
||||
if (chartRef.current) {
|
||||
// @ts-expect-error lightweight-charts type
|
||||
chartRef.current.remove()
|
||||
chartRef.current = null
|
||||
seriesRef.current = null
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Update candles + markers whenever data changes
|
||||
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,
|
||||
})))
|
||||
|
||||
// Show markers for all posts within visible time range
|
||||
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
|
||||
})
|
||||
|
||||
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,
|
||||
}))
|
||||
// @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, selectedPostId])
|
||||
|
||||
// Reset fit flag on timeframe/asset switch
|
||||
useEffect(() => {
|
||||
fittedRef.current = false
|
||||
}, [asset, 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
'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 timeAgo(iso: string): 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`
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Auto-scroll selected card into view
|
||||
useEffect(() => {
|
||||
if (selectedRef.current && scrollRef.current) {
|
||||
selectedRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' })
|
||||
}
|
||||
}, [selectedPostId])
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { MOCK_POSTS }
|
||||
Reference in New Issue
Block a user