From 46f8496371dbd6160d008315f2e662fb99cf8318 Mon Sep 17 00:00:00 2001 From: sheeki003 Date: Fri, 16 Jan 2026 23:45:17 +0530 Subject: [PATCH 1/5] feat: add full-stack analystOS application Backend (FastAPI): - JWT auth with rotating refresh tokens and token family tracking - Research router with document upload, URL scraping, report generation - Crypto router with price data, trending, market overview, AI chat - Automation router for Notion workflow integration - Job manager with ARQ support (dev: asyncio, prod: Redis) - TTL cache service with stale-while-error support - Sliding window rate limiter (100/min auth, 30/min unauth) - Upload middleware with 50MB streaming limit Frontend (Next.js 14): - Terminal Luxe "Bloomberg Terminal" design aesthetic - Complete auth flow with access/refresh token handling - Research page: document upload, URL analysis, model selector, chat - Crypto page: AI chat interface, price charts, watchlist, trending - Automation page: workflow status, queue, history - Settings page: profile, security, notifications, appearance - Reusable UI components: Button, Input, Card, Dialog, Tabs, etc. - API proxy for development (localhost:3000/api/* -> localhost:8000/*) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 32 +- frontend/.env.local.example | 7 + frontend/.gitignore | 32 + frontend/app/(dashboard)/automation/page.tsx | 274 + frontend/app/(dashboard)/crypto/page.tsx | 200 + frontend/app/(dashboard)/layout.tsx | 39 + frontend/app/(dashboard)/research/page.tsx | 245 + frontend/app/(dashboard)/settings/page.tsx | 436 + frontend/app/api/[...path]/route.ts | 149 + frontend/app/layout.tsx | 38 + frontend/app/login/page.tsx | 210 + frontend/app/page.tsx | 33 + .../components/automation/history-list.tsx | 158 + frontend/components/automation/index.ts | 4 + frontend/components/automation/queue-list.tsx | 138 + .../automation/status-indicator.tsx | 62 + .../components/automation/workflow-card.tsx | 122 + frontend/components/crypto/chat-interface.tsx | 251 + frontend/components/crypto/coin-search.tsx | 261 + frontend/components/crypto/index.ts | 6 + .../components/crypto/market-overview.tsx | 161 + frontend/components/crypto/price-card.tsx | 183 + frontend/components/crypto/price-chart.tsx | 227 + frontend/components/crypto/trending-list.tsx | 112 + frontend/components/layout/header.tsx | 170 + frontend/components/layout/index.ts | 3 + frontend/components/layout/shell.tsx | 140 + frontend/components/layout/sidebar.tsx | 150 + frontend/components/research/chat-panel.tsx | 193 + .../components/research/document-upload.tsx | 221 + frontend/components/research/entity-panel.tsx | 266 + frontend/components/research/index.ts | 7 + .../components/research/model-selector.tsx | 199 + frontend/components/research/report-card.tsx | 118 + .../components/research/report-viewer.tsx | 242 + frontend/components/research/url-input.tsx | 106 + frontend/components/ui/badge.tsx | 42 + frontend/components/ui/button.tsx | 101 + frontend/components/ui/card.tsx | 106 + frontend/components/ui/dialog.tsx | 209 + frontend/components/ui/dropdown.tsx | 269 + frontend/components/ui/index.ts | 35 + frontend/components/ui/input.tsx | 74 + frontend/components/ui/progress.tsx | 71 + frontend/components/ui/skeleton.tsx | 38 + frontend/components/ui/spinner.tsx | 55 + frontend/components/ui/tabs.tsx | 124 + frontend/components/ui/textarea.tsx | 58 + frontend/contexts/auth-context.tsx | 173 + frontend/lib/api/auth.ts | 67 + frontend/lib/api/automation.ts | 69 + frontend/lib/api/client.ts | 199 + frontend/lib/api/crypto.ts | 69 + frontend/lib/api/index.ts | 5 + frontend/lib/api/research.ts | 121 + frontend/lib/hooks/index.ts | 1 + frontend/lib/hooks/use-debounce.ts | 17 + frontend/lib/utils/cn.ts | 10 + frontend/lib/utils/formatters.ts | 127 + frontend/lib/utils/index.ts | 2 + frontend/next.config.js | 21 + frontend/package-lock.json | 7698 +++++++++++++++++ frontend/package.json | 37 + frontend/postcss.config.js | 6 + frontend/styles/globals.css | 348 + frontend/tailwind.config.ts | 193 + frontend/tsconfig.json | 26 + frontend/types/index.ts | 271 + requirements.txt | 7 + src/api_main.py | 266 + src/jobs/__init__.py | 22 + src/jobs/generate_job.py | 247 + src/jobs/scrape_job.py | 203 + src/jobs/upload_job.py | 170 + src/models/auth_models.py | 75 + src/models/job_models.py | 78 + src/models/research_models.py | 179 + src/routers/auth_router.py | 486 ++ src/routers/automation_router.py | 333 + src/routers/crypto_router.py | 407 + src/routers/research_router.py | 607 ++ src/services/__init__.py | 86 +- src/services/cache_service.py | 359 + src/services/job_manager.py | 404 + src/services/rate_limiter.py | 340 + src/services/token_store.py | 550 ++ src/services/upload_middleware.py | 289 + src/worker.py | 160 + 88 files changed, 21102 insertions(+), 3 deletions(-) create mode 100644 frontend/.env.local.example create mode 100644 frontend/.gitignore create mode 100644 frontend/app/(dashboard)/automation/page.tsx create mode 100644 frontend/app/(dashboard)/crypto/page.tsx create mode 100644 frontend/app/(dashboard)/layout.tsx create mode 100644 frontend/app/(dashboard)/research/page.tsx create mode 100644 frontend/app/(dashboard)/settings/page.tsx create mode 100644 frontend/app/api/[...path]/route.ts create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/login/page.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/components/automation/history-list.tsx create mode 100644 frontend/components/automation/index.ts create mode 100644 frontend/components/automation/queue-list.tsx create mode 100644 frontend/components/automation/status-indicator.tsx create mode 100644 frontend/components/automation/workflow-card.tsx create mode 100644 frontend/components/crypto/chat-interface.tsx create mode 100644 frontend/components/crypto/coin-search.tsx create mode 100644 frontend/components/crypto/index.ts create mode 100644 frontend/components/crypto/market-overview.tsx create mode 100644 frontend/components/crypto/price-card.tsx create mode 100644 frontend/components/crypto/price-chart.tsx create mode 100644 frontend/components/crypto/trending-list.tsx create mode 100644 frontend/components/layout/header.tsx create mode 100644 frontend/components/layout/index.ts create mode 100644 frontend/components/layout/shell.tsx create mode 100644 frontend/components/layout/sidebar.tsx create mode 100644 frontend/components/research/chat-panel.tsx create mode 100644 frontend/components/research/document-upload.tsx create mode 100644 frontend/components/research/entity-panel.tsx create mode 100644 frontend/components/research/index.ts create mode 100644 frontend/components/research/model-selector.tsx create mode 100644 frontend/components/research/report-card.tsx create mode 100644 frontend/components/research/report-viewer.tsx create mode 100644 frontend/components/research/url-input.tsx create mode 100644 frontend/components/ui/badge.tsx create mode 100644 frontend/components/ui/button.tsx create mode 100644 frontend/components/ui/card.tsx create mode 100644 frontend/components/ui/dialog.tsx create mode 100644 frontend/components/ui/dropdown.tsx create mode 100644 frontend/components/ui/index.ts create mode 100644 frontend/components/ui/input.tsx create mode 100644 frontend/components/ui/progress.tsx create mode 100644 frontend/components/ui/skeleton.tsx create mode 100644 frontend/components/ui/spinner.tsx create mode 100644 frontend/components/ui/tabs.tsx create mode 100644 frontend/components/ui/textarea.tsx create mode 100644 frontend/contexts/auth-context.tsx create mode 100644 frontend/lib/api/auth.ts create mode 100644 frontend/lib/api/automation.ts create mode 100644 frontend/lib/api/client.ts create mode 100644 frontend/lib/api/crypto.ts create mode 100644 frontend/lib/api/index.ts create mode 100644 frontend/lib/api/research.ts create mode 100644 frontend/lib/hooks/index.ts create mode 100644 frontend/lib/hooks/use-debounce.ts create mode 100644 frontend/lib/utils/cn.ts create mode 100644 frontend/lib/utils/formatters.ts create mode 100644 frontend/lib/utils/index.ts create mode 100644 frontend/next.config.js create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/styles/globals.css create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/types/index.ts create mode 100644 src/api_main.py create mode 100644 src/jobs/__init__.py create mode 100644 src/jobs/generate_job.py create mode 100644 src/jobs/scrape_job.py create mode 100644 src/jobs/upload_job.py create mode 100644 src/models/auth_models.py create mode 100644 src/models/job_models.py create mode 100644 src/models/research_models.py create mode 100644 src/routers/auth_router.py create mode 100644 src/routers/automation_router.py create mode 100644 src/routers/crypto_router.py create mode 100644 src/routers/research_router.py create mode 100644 src/services/cache_service.py create mode 100644 src/services/job_manager.py create mode 100644 src/services/rate_limiter.py create mode 100644 src/services/token_store.py create mode 100644 src/services/upload_middleware.py create mode 100644 src/worker.py diff --git a/.gitignore b/.gitignore index 7318da3..f170f93 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ @@ -145,3 +145,31 @@ yarn-error.log* # FewWord context plugin .fewword/scratch/ .fewword/index/ + +# FewWord context plugin +.fewword/scratch/ +.fewword/index/ + +# FewWord context plugin +.fewword/scratch/ +.fewword/index/ + +# FewWord context plugin +.fewword/scratch/ +.fewword/index/ + +# FewWord context plugin +.fewword/scratch/ +.fewword/index/ + +# FewWord context plugin +.fewword/scratch/ +.fewword/index/ + +# FewWord context plugin +.fewword/scratch/ +.fewword/index/ + +# FewWord context plugin +.fewword/scratch/ +.fewword/index/ diff --git a/frontend/.env.local.example b/frontend/.env.local.example new file mode 100644 index 0000000..c093f94 --- /dev/null +++ b/frontend/.env.local.example @@ -0,0 +1,7 @@ +# API Configuration +# In development, the frontend proxies to the backend via /api/* +# In production, set this to your API URL (e.g., https://api.analystos.com) +NEXT_PUBLIC_API_BASE_URL= + +# Environment +NODE_ENV=development diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..6687378 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,32 @@ +# Dependencies +node_modules/ + +# Next.js +.next/ +out/ + +# Production +build/ + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Misc +.DS_Store +*.pem + +# Vercel +.vercel + +# TypeScript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/app/(dashboard)/automation/page.tsx b/frontend/app/(dashboard)/automation/page.tsx new file mode 100644 index 0000000..4cc4b03 --- /dev/null +++ b/frontend/app/(dashboard)/automation/page.tsx @@ -0,0 +1,274 @@ +'use client' + +import { useState, useEffect } from 'react' +import { PageContainer, PageHeader, PageSection, Grid } from '@/components/layout' +import { + WorkflowCard, + QueueList, + StatusIndicator, + HistoryList, +} from '@/components/automation' +import { Button, Badge, Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui' +import { RefreshCw, Play, Clock, CheckCircle, ListTodo, History } from 'lucide-react' +import { automationApi } from '@/lib/api' +import type { AutomationStatus, QueueItem, AutomationHistory } from '@/types' + +export default function AutomationPage() { + const [status, setStatus] = useState(null) + const [queue, setQueue] = useState([]) + const [history, setHistory] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isRefreshing, setIsRefreshing] = useState(false) + const [activeTab, setActiveTab] = useState<'overview' | 'queue' | 'history'>('overview') + + useEffect(() => { + loadData() + const interval = setInterval(loadData, 30000) // Refresh every 30 seconds + return () => clearInterval(interval) + }, []) + + const loadData = async () => { + try { + setIsLoading(true) + const [statusData, queueData, historyData] = await Promise.all([ + automationApi.getStatus(), + automationApi.getQueue(), + automationApi.getHistory(), + ]) + setStatus(statusData) + setQueue(queueData) + setHistory(historyData) + } catch (error) { + console.error('Failed to load automation data:', error) + } finally { + setIsLoading(false) + } + } + + const handleRefresh = async () => { + setIsRefreshing(true) + await loadData() + setIsRefreshing(false) + } + + const handleTrigger = async (itemId: string) => { + try { + await automationApi.trigger(itemId) + // Reload queue + const queueData = await automationApi.getQueue() + setQueue(queueData) + } catch (error) { + console.error('Failed to trigger automation:', error) + } + } + + const completedCount = history.filter((h) => h.status === 'completed').length + const failedCount = history.filter((h) => h.status === 'failed').length + + return ( + + + {status && ( + + {status.is_running ? 'Running' : 'Idle'} + + )} + + + } + /> + + {/* Status Cards */} +
+ } + variant={status?.is_running ? 'success' : 'default'} + /> + } + variant={queue.length > 0 ? 'warning' : 'default'} + /> + } + variant="success" + /> + } + /> +
+ + setActiveTab(v as typeof activeTab)}> + + + + Overview + + + + Queue + {queue.length > 0 && ( + + {queue.length} + + )} + + + + History + + + + + + +
+ handleTrigger('process_new')} + /> + handleTrigger('sync_updates')} + /> + handleTrigger('generate_summaries')} + /> +
+
+ + + + +
+
+ + + + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) : queue.length === 0 ? ( +
+ +

+ Queue is empty +

+

+ All items have been processed. New items will appear here automatically. +

+
+ ) : ( + + )} + + + + + + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : history.length === 0 ? ( +
+ +

+ No history yet +

+

+ Run an automation to start tracking history. +

+
+ ) : ( + + )} + + + + + ) +} + +interface StatusCardProps { + title: string + value: string + icon: React.ReactNode + variant?: 'default' | 'success' | 'warning' | 'danger' +} + +function StatusCard({ title, value, icon, variant = 'default' }: StatusCardProps) { + const variantClasses = { + default: 'text-text-muted', + success: 'text-accent-success', + warning: 'text-accent-secondary', + danger: 'text-accent-danger', + } + + return ( +
+
+ {title} + {icon} +
+

{value}

+
+ ) +} + +function formatRelativeTime(dateString: string): string { + const date = new Date(dateString) + const now = new Date() + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000) + + if (diffInSeconds < 60) return 'Just now' + if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago` + if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago` + return `${Math.floor(diffInSeconds / 86400)}d ago` +} diff --git a/frontend/app/(dashboard)/crypto/page.tsx b/frontend/app/(dashboard)/crypto/page.tsx new file mode 100644 index 0000000..45f2a93 --- /dev/null +++ b/frontend/app/(dashboard)/crypto/page.tsx @@ -0,0 +1,200 @@ +'use client' + +import { useState, useEffect } from 'react' +import { PageContainer, PageHeader, PageSection, Grid } from '@/components/layout' +import { + ChatInterface, + PriceCard, + PriceChart, + TrendingList, + MarketOverview, + CoinSearch, +} from '@/components/crypto' +import { Tabs, TabsList, TabsTrigger, TabsContent, Badge } from '@/components/ui' +import { MessageSquare, TrendingUp, BarChart3, Search } from 'lucide-react' +import { cryptoApi } from '@/lib/api' +import type { CoinPrice, TrendingCoin, MarketData } from '@/types' + +export default function CryptoPage() { + const [watchlist, setWatchlist] = useState(['bitcoin', 'ethereum', 'solana']) + const [prices, setPrices] = useState>({}) + const [trending, setTrending] = useState([]) + const [marketData, setMarketData] = useState(null) + const [selectedCoin, setSelectedCoin] = useState('bitcoin') + const [isLoading, setIsLoading] = useState(true) + const [activeTab, setActiveTab] = useState<'chat' | 'market' | 'search'>('chat') + + useEffect(() => { + loadMarketData() + const interval = setInterval(loadMarketData, 60000) // Refresh every minute + return () => clearInterval(interval) + }, []) + + useEffect(() => { + if (watchlist.length > 0) { + loadPrices() + } + }, [watchlist]) + + const loadMarketData = async () => { + try { + const [trendingData, overview] = await Promise.all([ + cryptoApi.getTrending(), + cryptoApi.getMarketOverview(), + ]) + setTrending(trendingData) + setMarketData(overview) + } catch (error) { + console.error('Failed to load market data:', error) + } + } + + const loadPrices = async () => { + try { + setIsLoading(true) + const pricePromises = watchlist.map(async (coinId) => { + const price = await cryptoApi.getPrice(coinId) + return [coinId, price] as const + }) + const results = await Promise.all(pricePromises) + setPrices(Object.fromEntries(results)) + } catch (error) { + console.error('Failed to load prices:', error) + } finally { + setIsLoading(false) + } + } + + const addToWatchlist = (coinId: string) => { + if (!watchlist.includes(coinId)) { + setWatchlist([...watchlist, coinId]) + } + } + + const removeFromWatchlist = (coinId: string) => { + setWatchlist(watchlist.filter((id) => id !== coinId)) + } + + return ( + + = 0 ? 'success' : 'danger'}> + Market {marketData.market_cap_change_24h >= 0 ? '↑' : '↓'}{' '} + {Math.abs(marketData.market_cap_change_24h).toFixed(2)}% + + ) + } + /> + + setActiveTab(v as typeof activeTab)}> + + + + AI Chat + + + + Market + + + + Search + + + + +
+ {/* Left Column - Chat Interface */} +
+ +
+ + {/* Right Column - Price Cards & Trending */} +
+ +
+ {isLoading ? ( + Array.from({ length: 3 }).map((_, i) => ( +
+ )) + ) : ( + watchlist.map((coinId) => ( + setSelectedCoin(coinId)} + onRemove={() => removeFromWatchlist(coinId)} + /> + )) + )} +
+ + + + { + addToWatchlist(coinId) + setSelectedCoin(coinId) + }} + /> + +
+
+ + + +
+
+ + + +
+ +
+ + + +
+
+ +
+ + + {watchlist.map((coinId) => ( + setSelectedCoin(coinId)} + compact + /> + ))} + + +
+
+ + + { + addToWatchlist(coinId) + setSelectedCoin(coinId) + setActiveTab('chat') + }} + /> + + + + ) +} diff --git a/frontend/app/(dashboard)/layout.tsx b/frontend/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..8b572e3 --- /dev/null +++ b/frontend/app/(dashboard)/layout.tsx @@ -0,0 +1,39 @@ +'use client' + +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { useAuth } from '@/contexts/auth-context' +import { Shell } from '@/components/layout' +import { Spinner } from '@/components/ui' + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode +}) { + const router = useRouter() + const { user, isLoading } = useAuth() + + useEffect(() => { + if (!isLoading && !user) { + router.replace('/login') + } + }, [user, isLoading, router]) + + if (isLoading) { + return ( +
+
+ +

Loading...

+
+
+ ) + } + + if (!user) { + return null + } + + return {children} +} diff --git a/frontend/app/(dashboard)/research/page.tsx b/frontend/app/(dashboard)/research/page.tsx new file mode 100644 index 0000000..9e8753b --- /dev/null +++ b/frontend/app/(dashboard)/research/page.tsx @@ -0,0 +1,245 @@ +'use client' + +import { useState, useEffect } from 'react' +import { PageContainer, PageHeader, PageSection, Grid } from '@/components/layout' +import { + DocumentUpload, + UrlInput, + ModelSelector, + ReportViewer, + EntityPanel, + ChatPanel, + ReportCard, +} from '@/components/research' +import { Button, Badge, Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui' +import { Plus, FileText, Clock, CheckCircle, AlertCircle } from 'lucide-react' +import { researchApi } from '@/lib/api' +import type { Report, Job } from '@/types' + +export default function ResearchPage() { + const [reports, setReports] = useState([]) + const [activeJobs, setActiveJobs] = useState([]) + const [selectedReport, setSelectedReport] = useState(null) + const [activeTab, setActiveTab] = useState<'new' | 'reports'>('new') + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + loadReports() + }, []) + + const loadReports = async () => { + try { + setIsLoading(true) + const data = await researchApi.getReports() + setReports(data) + } catch (error) { + console.error('Failed to load reports:', error) + } finally { + setIsLoading(false) + } + } + + const handleJobCreated = (job: Job) => { + setActiveJobs((prev) => [...prev, job]) + // Poll for job status + pollJobStatus(job.id) + } + + const pollJobStatus = async (jobId: string) => { + const poll = async () => { + try { + const status = await researchApi.getJobStatus(jobId) + setActiveJobs((prev) => + prev.map((j) => (j.id === jobId ? { ...j, ...status } : j)) + ) + + if (status.status === 'completed') { + // Reload reports + loadReports() + // Remove from active jobs after a delay + setTimeout(() => { + setActiveJobs((prev) => prev.filter((j) => j.id !== jobId)) + }, 3000) + } else if (status.status === 'failed') { + setTimeout(() => { + setActiveJobs((prev) => prev.filter((j) => j.id !== jobId)) + }, 5000) + } else { + // Continue polling + setTimeout(poll, 2000) + } + } catch (error) { + console.error('Failed to poll job status:', error) + } + } + poll() + } + + return ( + + + {activeJobs.length > 0 && ( + + + {activeJobs.length} job{activeJobs.length > 1 ? 's' : ''} running + + )} +
+ } + /> + + {/* Active Jobs Status Bar */} + {activeJobs.length > 0 && ( +
+ {activeJobs.map((job) => ( + + ))} +
+ )} + + setActiveTab(v as 'new' | 'reports')}> + + + + New Research + + + + Reports + {reports.length > 0 && ( + + {reports.length} + + )} + + + + +
+ {/* Left Column - Input Methods */} +
+ + + + + + + + + + + +
+ + {/* Right Column - Chat */} +
+ + + +
+
+
+ + + {selectedReport ? ( +
+
+ setSelectedReport(null)} + /> +
+
+ +
+
+ ) : ( + + {isLoading ? ( + Array.from({ length: 6 }).map((_, i) => ( +
+ )) + ) : reports.length === 0 ? ( +
+ +

+ No reports yet +

+

+ Upload a document or analyze a URL to create your first report +

+ +
+ ) : ( + reports.map((report) => ( + setSelectedReport(report)} + /> + )) + )} + + )} + + + + ) +} + +function JobStatusBar({ job }: { job: Job }) { + const getStatusIcon = () => { + switch (job.status) { + case 'completed': + return + case 'failed': + return + default: + return ( +
+ ) + } + } + + return ( +
+ {getStatusIcon()} +
+

+ {job.type === 'upload' && 'Processing document...'} + {job.type === 'scrape' && 'Analyzing URL...'} + {job.type === 'generate' && 'Generating report...'} +

+ {job.progress !== undefined && job.status === 'processing' && ( +
+
+
+ )} +
+ + {job.status} + +
+ ) +} diff --git a/frontend/app/(dashboard)/settings/page.tsx b/frontend/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..a4b4a27 --- /dev/null +++ b/frontend/app/(dashboard)/settings/page.tsx @@ -0,0 +1,436 @@ +'use client' + +import { useState } from 'react' +import { PageContainer, PageHeader, PageSection } from '@/components/layout' +import { Button, Input, Card, CardContent, Badge, Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui' +import { useAuth } from '@/contexts/auth-context' +import { User, Key, Bell, Shield, Palette, Save, LogOut } from 'lucide-react' +import { cn } from '@/lib/utils' + +export default function SettingsPage() { + const { user, logout } = useAuth() + const [activeTab, setActiveTab] = useState('profile') + const [isSaving, setIsSaving] = useState(false) + + // Form states + const [username, setUsername] = useState(user?.username || '') + const [email, setEmail] = useState(user?.email || '') + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + + const handleSaveProfile = async () => { + setIsSaving(true) + // Simulate save + await new Promise((resolve) => setTimeout(resolve, 1000)) + setIsSaving(false) + } + + const handleChangePassword = async () => { + if (newPassword !== confirmPassword) { + return + } + setIsSaving(true) + // Simulate save + await new Promise((resolve) => setTimeout(resolve, 1000)) + setCurrentPassword('') + setNewPassword('') + setConfirmPassword('') + setIsSaving(false) + } + + return ( + + + +
+ {/* Sidebar Navigation */} +
+ + +
+ +
+
+ + {/* Content */} +
+ {activeTab === 'profile' && ( + + +

+ Profile Information +

+ +
+
+
+ + {user?.username?.charAt(0).toUpperCase() || 'U'} + +
+
+

{user?.username}

+

{user?.email}

+
+
+ +
+
+ + setUsername(e.target.value)} + /> +
+
+ + setEmail(e.target.value)} + /> +
+
+ +
+ +
+
+
+
+ )} + + {activeTab === 'security' && ( + + +

+ Change Password +

+ +
+
+ + setCurrentPassword(e.target.value)} + /> +
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + setConfirmPassword(e.target.value)} + /> + {newPassword && confirmPassword && newPassword !== confirmPassword && ( +

+ Passwords do not match +

+ )} +
+ + +
+
+
+ )} + + {activeTab === 'notifications' && ( + + +

+ Notification Preferences +

+ +
+ + + + +
+
+
+ )} + + {activeTab === 'appearance' && ( + + +

+ Appearance +

+ +
+
+ +
+ + + +
+

+ Terminal Luxe theme is currently the only available option. +

+
+ +
+ +
+ + + + + +
+
+
+
+
+ )} + + {activeTab === 'api' && ( + + +

+ API Keys +

+ +
+
+
+ + Personal Access Token + + Active +
+ + •••••••••••••••••••••••••••••••• + +
+ + +
+
+ +
+

+ Need to integrate with external services? Generate an API key for + programmatic access. +

+ +
+
+
+
+ )} +
+
+
+ ) +} + +interface SettingsNavItemProps { + icon: React.ReactNode + label: string + isActive: boolean + onClick: () => void +} + +function SettingsNavItem({ icon, label, isActive, onClick }: SettingsNavItemProps) { + return ( + + ) +} + +interface NotificationToggleProps { + title: string + description: string + defaultChecked?: boolean +} + +function NotificationToggle({ + title, + description, + defaultChecked, +}: NotificationToggleProps) { + const [checked, setChecked] = useState(defaultChecked ?? false) + + return ( +
+
+

{title}

+

{description}

+
+ +
+ ) +} + +function ThemeOption({ + label, + isActive, + disabled, +}: { + label: string + isActive?: boolean + disabled?: boolean +}) { + return ( + + ) +} + +function ColorSwatch({ color, isActive }: { color: string; isActive?: boolean }) { + return ( + +
+
+
+ + + + +
+

+ By signing in, you agree to our Terms of Service and Privacy Policy. +

+
+
+
+ + ) +} + +function FeatureCard({ title, description }: { title: string; description: string }) { + return ( +
+

{title}

+

{description}

+
+ ) +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..5ceaed6 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,33 @@ +'use client' + +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { useAuth } from '@/contexts/auth-context' +import { Spinner } from '@/components/ui' + +export default function HomePage() { + const router = useRouter() + const { user, isLoading } = useAuth() + + useEffect(() => { + if (!isLoading) { + if (user) { + router.replace('/research') + } else { + router.replace('/login') + } + } + }, [user, isLoading, router]) + + return ( +
+
+
+ A +
+ +

Initializing...

+
+
+ ) +} diff --git a/frontend/components/automation/history-list.tsx b/frontend/components/automation/history-list.tsx new file mode 100644 index 0000000..f3419f5 --- /dev/null +++ b/frontend/components/automation/history-list.tsx @@ -0,0 +1,158 @@ +'use client' + +import { CheckCircle, XCircle, Clock, FileText, ExternalLink } from 'lucide-react' +import { Badge } from '@/components/ui' +import { cn } from '@/lib/utils' +import { formatDate, formatRelativeTime } from '@/lib/utils/formatters' +import type { AutomationHistory } from '@/types' + +interface HistoryListProps { + history: AutomationHistory[] + compact?: boolean +} + +export function HistoryList({ history, compact }: HistoryListProps) { + if (compact) { + return ( +
+ {history.map((item) => ( + + ))} +
+ ) + } + + return ( +
+ {history.map((item) => ( + + ))} +
+ ) +} + +interface HistoryItemProps { + item: AutomationHistory +} + +function HistoryItem({ item }: HistoryItemProps) { + const isSuccess = item.status === 'completed' + const isFailed = item.status === 'failed' + + return ( +
+
+
+
+ {isSuccess ? ( + + ) : isFailed ? ( + + ) : ( + + )} +
+ +
+

{item.title}

+

{item.workflow_name}

+ {item.error_message && ( +

+ Error: {item.error_message} +

+ )} +
+
+ + + {item.status} + +
+ +
+
+ Started: {formatDate(item.started_at)} + {item.completed_at && ( + + Duration:{' '} + {formatDuration( + new Date(item.completed_at).getTime() - + new Date(item.started_at).getTime() + )} + + )} +
+ + {item.output_url && ( + + View Output + + + )} +
+
+ ) +} + +function CompactHistoryItem({ item }: HistoryItemProps) { + const isSuccess = item.status === 'completed' + const isFailed = item.status === 'failed' + + return ( +
+
+ {isSuccess ? ( + + ) : isFailed ? ( + + ) : ( + + )} + +
+

{item.title}

+

+ {formatRelativeTime(item.started_at)} +

+
+
+ + + {item.status} + +
+ ) +} + +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + if (minutes < 60) return `${minutes}m ${remainingSeconds}s` + const hours = Math.floor(minutes / 60) + const remainingMinutes = minutes % 60 + return `${hours}h ${remainingMinutes}m` +} diff --git a/frontend/components/automation/index.ts b/frontend/components/automation/index.ts new file mode 100644 index 0000000..8860af1 --- /dev/null +++ b/frontend/components/automation/index.ts @@ -0,0 +1,4 @@ +export { WorkflowCard } from './workflow-card' +export { QueueList } from './queue-list' +export { StatusIndicator } from './status-indicator' +export { HistoryList } from './history-list' diff --git a/frontend/components/automation/queue-list.tsx b/frontend/components/automation/queue-list.tsx new file mode 100644 index 0000000..f39548a --- /dev/null +++ b/frontend/components/automation/queue-list.tsx @@ -0,0 +1,138 @@ +'use client' + +import { useState } from 'react' +import { Play, Clock, FileText, ExternalLink, Loader2 } from 'lucide-react' +import { Button, Badge } from '@/components/ui' +import { StatusIndicator } from './status-indicator' +import { cn } from '@/lib/utils' +import { formatRelativeTime } from '@/lib/utils/formatters' +import type { QueueItem } from '@/types' + +interface QueueListProps { + queue: QueueItem[] + onTrigger: (itemId: string) => void +} + +export function QueueList({ queue, onTrigger }: QueueListProps) { + return ( +
+ {queue.map((item) => ( + onTrigger(item.id)} /> + ))} +
+ ) +} + +interface QueueItemCardProps { + item: QueueItem + onTrigger: () => void +} + +function QueueItemCard({ item, onTrigger }: QueueItemCardProps) { + const [isTriggering, setIsTriggering] = useState(false) + + const handleTrigger = async () => { + setIsTriggering(true) + try { + await onTrigger() + } finally { + setTimeout(() => setIsTriggering(false), 1000) + } + } + + const statusMap: Record = { + pending: 'Pending', + processing: 'Processing', + completed: 'Completed', + failed: 'Failed', + } + + const badgeVariant: Record = { + pending: 'secondary', + processing: 'warning', + completed: 'success', + failed: 'danger', + } + + return ( +
+
+ + +
+
+ +
+
+

{item.title}

+
+ + Added {formatRelativeTime(item.created_at)} + + {item.source_url && ( + e.stopPropagation()} + > + View in Notion + + + )} +
+
+
+
+ +
+ {statusMap[item.status]} + + {(item.status === 'pending' || item.status === 'failed') && ( + + )} + + {item.status === 'processing' && ( +
+ + Processing... +
+ )} +
+
+ ) +} diff --git a/frontend/components/automation/status-indicator.tsx b/frontend/components/automation/status-indicator.tsx new file mode 100644 index 0000000..aec243d --- /dev/null +++ b/frontend/components/automation/status-indicator.tsx @@ -0,0 +1,62 @@ +'use client' + +import { cn } from '@/lib/utils' + +type Status = 'idle' | 'running' | 'completed' | 'failed' | 'pending' + +interface StatusIndicatorProps { + status: Status + size?: 'sm' | 'md' | 'lg' + showPulse?: boolean +} + +const STATUS_COLORS: Record = { + idle: 'bg-text-muted', + running: 'bg-accent-secondary', + completed: 'bg-accent-success', + failed: 'bg-accent-danger', + pending: 'bg-text-muted', +} + +const STATUS_GLOW: Record = { + idle: '', + running: 'shadow-[0_0_8px_2px_rgba(245,158,11,0.4)]', + completed: 'shadow-[0_0_8px_2px_rgba(34,197,94,0.4)]', + failed: 'shadow-[0_0_8px_2px_rgba(239,68,68,0.4)]', + pending: '', +} + +export function StatusIndicator({ + status, + size = 'md', + showPulse = true, +}: StatusIndicatorProps) { + const sizeClasses = { + sm: 'w-2 h-2', + md: 'w-3 h-3', + lg: 'w-4 h-4', + } + + return ( +
+ + {status === 'running' && showPulse && ( + + )} +
+ ) +} diff --git a/frontend/components/automation/workflow-card.tsx b/frontend/components/automation/workflow-card.tsx new file mode 100644 index 0000000..3a0f14e --- /dev/null +++ b/frontend/components/automation/workflow-card.tsx @@ -0,0 +1,122 @@ +'use client' + +import { useState } from 'react' +import { Play, Clock, CheckCircle, AlertCircle, Loader2, MoreVertical } from 'lucide-react' +import { Button, Badge } from '@/components/ui' +import { StatusIndicator } from './status-indicator' +import { cn } from '@/lib/utils' +import { formatRelativeTime } from '@/lib/utils/formatters' + +interface WorkflowCardProps { + title: string + description: string + status: 'idle' | 'running' | 'completed' | 'failed' + lastRun?: string + onTrigger: () => void +} + +export function WorkflowCard({ + title, + description, + status, + lastRun, + onTrigger, +}: WorkflowCardProps) { + const [isTriggering, setIsTriggering] = useState(false) + + const handleTrigger = async () => { + setIsTriggering(true) + try { + await onTrigger() + } finally { + setTimeout(() => setIsTriggering(false), 1000) + } + } + + const statusConfig = { + idle: { + badge: 'secondary' as const, + label: 'Idle', + icon: , + }, + running: { + badge: 'warning' as const, + label: 'Running', + icon: , + }, + completed: { + badge: 'success' as const, + label: 'Completed', + icon: , + }, + failed: { + badge: 'danger' as const, + label: 'Failed', + icon: , + }, + } + + const config = statusConfig[status] + + return ( +
+
+
+ +
+

{title}

+

{description}

+
+
+ + +
+ +
+
+ + {config.icon} + {config.label} + + {lastRun && ( + + Last run: {formatRelativeTime(lastRun)} + + )} +
+ + +
+
+ ) +} diff --git a/frontend/components/crypto/chat-interface.tsx b/frontend/components/crypto/chat-interface.tsx new file mode 100644 index 0000000..ce2b95a --- /dev/null +++ b/frontend/components/crypto/chat-interface.tsx @@ -0,0 +1,251 @@ +'use client' + +import { useState, useRef, useEffect } from 'react' +import { Send, Bot, User, Loader2, TrendingUp, TrendingDown } from 'lucide-react' +import { Button, Input, Badge } from '@/components/ui' +import { cn } from '@/lib/utils' +import { cryptoApi } from '@/lib/api' + +interface Message { + id: string + role: 'user' | 'assistant' + content: string + timestamp: Date + coinMentions?: string[] +} + +interface ChatInterfaceProps { + selectedCoin: string +} + +export function ChatInterface({ selectedCoin }: ChatInterfaceProps) { + const [messages, setMessages] = useState([ + { + id: '1', + role: 'assistant', + content: + 'Hello! I\'m your crypto AI assistant. Ask me about market trends, specific coins, technical analysis, or investment strategies. What would you like to know?', + timestamp: new Date(), + }, + ]) + const [input, setInput] = useState('') + const [isLoading, setIsLoading] = useState(false) + const messagesEndRef = useRef(null) + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) + + // Add context message when selected coin changes + useEffect(() => { + if (selectedCoin && messages.length > 1) { + const contextMessage: Message = { + id: Date.now().toString(), + role: 'assistant', + content: `I see you're interested in ${selectedCoin.charAt(0).toUpperCase() + selectedCoin.slice(1)}. Feel free to ask me any questions about it!`, + timestamp: new Date(), + coinMentions: [selectedCoin], + } + setMessages((prev) => [...prev, contextMessage]) + } + }, [selectedCoin]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!input.trim() || isLoading) return + + const userMessage: Message = { + id: Date.now().toString(), + role: 'user', + content: input.trim(), + timestamp: new Date(), + } + + setMessages((prev) => [...prev, userMessage]) + setInput('') + setIsLoading(true) + + try { + const response = await cryptoApi.chat(input.trim(), selectedCoin) + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: response.message, + timestamp: new Date(), + coinMentions: response.coins_mentioned, + } + setMessages((prev) => [...prev, assistantMessage]) + } catch (error) { + // Fallback to mock response + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: generateMockCryptoResponse(input.trim(), selectedCoin), + timestamp: new Date(), + } + setMessages((prev) => [...prev, assistantMessage]) + } finally { + setIsLoading(false) + } + } + + const suggestedQuestions = [ + `What's the outlook for ${selectedCoin}?`, + 'Top coins to watch this week?', + 'Explain DeFi to me', + 'Market sentiment analysis', + ] + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Crypto AI

+

Powered by GPT-4

+
+
+ {selectedCoin && ( + + Analyzing: {selectedCoin} + + )} +
+ + {/* Messages */} +
+ {messages.map((message) => ( + + ))} + {isLoading && ( +
+
+ +
+
+ + Analyzing market data... +
+
+ )} +
+
+ + {/* Suggested Questions */} + {messages.length < 3 && ( +
+

Suggested questions:

+
+ {suggestedQuestions.map((question, i) => ( + + ))} +
+
+ )} + + {/* Input */} +
+
+ setInput(e.target.value)} + placeholder="Ask about crypto markets..." + disabled={isLoading} + className="flex-1" + /> + +
+
+
+ ) +} + +interface ChatMessageProps { + message: Message +} + +function ChatMessage({ message }: ChatMessageProps) { + const isUser = message.role === 'user' + + return ( +
+
+ {isUser ? ( + + ) : ( + + )} +
+
+
+

{message.content}

+
+ + {/* Coin mentions */} + {message.coinMentions && message.coinMentions.length > 0 && ( +
+ {message.coinMentions.map((coin) => ( + + {coin} + + ))} +
+ )} + +

+ {formatTime(message.timestamp)} +

+
+
+ ) +} + +function formatTime(date: Date): string { + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }) +} + +function generateMockCryptoResponse(query: string, coin: string): string { + const responses = [ + `Based on my analysis of ${coin}, the current market conditions suggest cautious optimism. Key indicators show strong support levels with potential for upward movement. However, always consider market volatility and do your own research.`, + `Looking at the technical indicators for ${coin}, we're seeing interesting patterns. The RSI is neutral, and the MACD shows potential momentum shift. Volume has been steady, suggesting continued interest from traders.`, + `The broader crypto market is showing mixed signals. While ${coin} has shown resilience, macroeconomic factors continue to influence price action. It's important to maintain a diversified approach and stay updated on regulatory developments.`, + `Great question! ${coin} has been performing within expected ranges. The key levels to watch are the support around recent lows and resistance at recent highs. Market sentiment appears cautiously bullish based on social metrics.`, + ] + return responses[Math.floor(Math.random() * responses.length)] +} diff --git a/frontend/components/crypto/coin-search.tsx b/frontend/components/crypto/coin-search.tsx new file mode 100644 index 0000000..43c406c --- /dev/null +++ b/frontend/components/crypto/coin-search.tsx @@ -0,0 +1,261 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { Search, Plus, Loader2, X, TrendingUp, TrendingDown } from 'lucide-react' +import { Input, Badge } from '@/components/ui' +import { cn } from '@/lib/utils' +import { cryptoApi } from '@/lib/api' +import { formatCurrency, formatPercent } from '@/lib/utils/formatters' +import { useDebounce } from '@/lib/hooks/use-debounce' + +interface CoinSearchProps { + onSelect: (coinId: string) => void +} + +interface SearchResult { + id: string + name: string + symbol: string + thumb?: string + market_cap_rank?: number + price?: number + change_24h?: number +} + +export function CoinSearch({ onSelect }: CoinSearchProps) { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [hasSearched, setHasSearched] = useState(false) + + const debouncedQuery = useDebounce(query, 300) + + useEffect(() => { + if (debouncedQuery.trim()) { + searchCoins(debouncedQuery) + } else { + setResults([]) + setHasSearched(false) + } + }, [debouncedQuery]) + + const searchCoins = async (searchQuery: string) => { + setIsLoading(true) + setHasSearched(true) + try { + const data = await cryptoApi.search(searchQuery) + setResults(data.coins || []) + } catch (error) { + console.error('Search failed:', error) + // Fallback to mock results + setResults(generateMockResults(searchQuery)) + } finally { + setIsLoading(false) + } + } + + const clearSearch = () => { + setQuery('') + setResults([]) + setHasSearched(false) + } + + return ( +
+ {/* Search Input */} +
+
+ +
+ setQuery(e.target.value)} + placeholder="Search for a cryptocurrency..." + className="pl-10 pr-10" + /> + {query && ( + + )} +
+ + {/* Loading State */} + {isLoading && ( +
+ +
+ )} + + {/* Results */} + {!isLoading && results.length > 0 && ( +
+

+ Found {results.length} result{results.length > 1 ? 's' : ''} +

+
+ {results.map((coin) => ( + onSelect(coin.id)} + /> + ))} +
+
+ )} + + {/* Empty State */} + {!isLoading && hasSearched && results.length === 0 && ( +
+ +

+ No results found +

+

+ Try searching for a different cryptocurrency +

+
+ )} + + {/* Initial State */} + {!isLoading && !hasSearched && ( +
+ +

+ Search for cryptocurrencies +

+

+ Enter a coin name or symbol to find it +

+ + {/* Popular Searches */} +
+

Popular searches:

+
+ {['Bitcoin', 'Ethereum', 'Solana', 'Cardano', 'Polygon'].map( + (coin) => ( + + ) + )} +
+
+
+ )} +
+ ) +} + +interface SearchResultCardProps { + coin: SearchResult + onSelect: () => void +} + +function SearchResultCard({ coin, onSelect }: SearchResultCardProps) { + const isPositive = coin.change_24h !== undefined ? coin.change_24h >= 0 : true + + return ( +
+
+
+ {coin.thumb ? ( + {coin.name} + ) : ( +
+ + {coin.symbol.charAt(0)} + +
+ )} +
+

+ {coin.name} +

+

{coin.symbol.toUpperCase()}

+
+
+ +
+ + {/* Price Info */} + {coin.price && ( +
+ + {formatCurrency(coin.price)} + + {coin.change_24h !== undefined && ( + + {isPositive ? ( + + ) : ( + + )} + {isPositive ? '+' : ''} + {coin.change_24h.toFixed(2)}% + + )} +
+ )} + + {/* Market Cap Rank */} + {coin.market_cap_rank && ( + + Rank #{coin.market_cap_rank} + + )} +
+ ) +} + +function generateMockResults(query: string): SearchResult[] { + const allCoins: SearchResult[] = [ + { id: 'bitcoin', name: 'Bitcoin', symbol: 'BTC', market_cap_rank: 1, price: 45000, change_24h: 2.5 }, + { id: 'ethereum', name: 'Ethereum', symbol: 'ETH', market_cap_rank: 2, price: 2800, change_24h: -1.2 }, + { id: 'solana', name: 'Solana', symbol: 'SOL', market_cap_rank: 5, price: 120, change_24h: 5.8 }, + { id: 'cardano', name: 'Cardano', symbol: 'ADA', market_cap_rank: 8, price: 0.55, change_24h: -0.8 }, + { id: 'polkadot', name: 'Polkadot', symbol: 'DOT', market_cap_rank: 12, price: 7.5, change_24h: 1.2 }, + { id: 'polygon', name: 'Polygon', symbol: 'MATIC', market_cap_rank: 15, price: 0.85, change_24h: 3.1 }, + { id: 'chainlink', name: 'Chainlink', symbol: 'LINK', market_cap_rank: 18, price: 15, change_24h: -2.3 }, + { id: 'avalanche', name: 'Avalanche', symbol: 'AVAX', market_cap_rank: 10, price: 35, change_24h: 4.5 }, + ] + + const lowerQuery = query.toLowerCase() + return allCoins.filter( + (coin) => + coin.name.toLowerCase().includes(lowerQuery) || + coin.symbol.toLowerCase().includes(lowerQuery) + ) +} diff --git a/frontend/components/crypto/index.ts b/frontend/components/crypto/index.ts new file mode 100644 index 0000000..d45b0fc --- /dev/null +++ b/frontend/components/crypto/index.ts @@ -0,0 +1,6 @@ +export { ChatInterface } from './chat-interface' +export { PriceCard } from './price-card' +export { PriceChart } from './price-chart' +export { TrendingList } from './trending-list' +export { MarketOverview } from './market-overview' +export { CoinSearch } from './coin-search' diff --git a/frontend/components/crypto/market-overview.tsx b/frontend/components/crypto/market-overview.tsx new file mode 100644 index 0000000..905bb44 --- /dev/null +++ b/frontend/components/crypto/market-overview.tsx @@ -0,0 +1,161 @@ +'use client' + +import { TrendingUp, TrendingDown, Activity, DollarSign, BarChart3 } from 'lucide-react' +import { cn } from '@/lib/utils' +import { formatCurrency, formatPercent } from '@/lib/utils/formatters' +import type { MarketData } from '@/types' + +interface MarketOverviewProps { + data: MarketData | null +} + +export function MarketOverview({ data }: MarketOverviewProps) { + if (!data) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ ) + } + + const isMarketUp = data.market_cap_change_24h >= 0 + + return ( +
+ {/* Total Market Cap */} + } + label="Total Market Cap" + value={formatLargeCurrency(data.total_market_cap)} + change={data.market_cap_change_24h} + iconColor="text-accent-primary" + /> + + {/* 24h Volume */} + } + label="24h Volume" + value={formatLargeCurrency(data.total_volume)} + iconColor="text-blue-400" + /> + + {/* BTC Dominance */} + } + label="BTC Dominance" + value={`${data.btc_dominance.toFixed(1)}%`} + iconColor="text-orange-400" + /> + + {/* Market Sentiment */} +
+
+ Market Sentiment + + {isMarketUp ? 'Bullish' : 'Bearish'} + +
+
+
+
+
+ Fear + Greed +
+
+ + {/* Active Coins */} + {data.active_coins && ( +
+
+ Active Cryptocurrencies + + {data.active_coins.toLocaleString()} + +
+
+ )} +
+ ) +} + +interface MetricCardProps { + icon: React.ReactNode + label: string + value: string + change?: number + iconColor?: string +} + +function MetricCard({ icon, label, value, change, iconColor }: MetricCardProps) { + const isPositive = change !== undefined ? change >= 0 : undefined + + return ( +
+
+
+
+ {icon} +
+
+

{label}

+

+ {value} +

+
+
+ + {change !== undefined && ( +
+ {isPositive ? ( + + ) : ( + + )} + + {isPositive ? '+' : ''} + {change.toFixed(2)}% + +
+ )} +
+
+ ) +} + +function formatLargeCurrency(value: number): string { + if (value >= 1e12) return '$' + (value / 1e12).toFixed(2) + 'T' + if (value >= 1e9) return '$' + (value / 1e9).toFixed(2) + 'B' + if (value >= 1e6) return '$' + (value / 1e6).toFixed(2) + 'M' + return '$' + value.toLocaleString() +} diff --git a/frontend/components/crypto/price-card.tsx b/frontend/components/crypto/price-card.tsx new file mode 100644 index 0000000..94bf2d8 --- /dev/null +++ b/frontend/components/crypto/price-card.tsx @@ -0,0 +1,183 @@ +'use client' + +import { TrendingUp, TrendingDown, X } from 'lucide-react' +import { cn } from '@/lib/utils' +import { formatCurrency, formatPercent } from '@/lib/utils/formatters' +import type { CoinPrice } from '@/types' + +interface PriceCardProps { + coinId: string + price?: CoinPrice + isSelected?: boolean + onClick?: () => void + onRemove?: () => void + compact?: boolean +} + +const COIN_ICONS: Record = { + bitcoin: 'BTC', + ethereum: 'ETH', + solana: 'SOL', + cardano: 'ADA', + polkadot: 'DOT', + avalanche: 'AVAX', + polygon: 'MATIC', + chainlink: 'LINK', +} + +const COIN_COLORS: Record = { + bitcoin: 'bg-orange-500/20 text-orange-400', + ethereum: 'bg-blue-500/20 text-blue-400', + solana: 'bg-purple-500/20 text-purple-400', + cardano: 'bg-blue-400/20 text-blue-300', + polkadot: 'bg-pink-500/20 text-pink-400', + avalanche: 'bg-red-500/20 text-red-400', + polygon: 'bg-purple-400/20 text-purple-300', + chainlink: 'bg-blue-600/20 text-blue-500', +} + +export function PriceCard({ + coinId, + price, + isSelected, + onClick, + onRemove, + compact, +}: PriceCardProps) { + const isPositive = price ? price.change_24h >= 0 : true + const symbol = COIN_ICONS[coinId] || coinId.substring(0, 3).toUpperCase() + const colorClass = COIN_COLORS[coinId] || 'bg-accent-primary/20 text-accent-primary' + + if (compact) { + return ( +
+
+
+ {symbol.charAt(0)} +
+ {symbol} +
+

+ {price ? formatCurrency(price.price) : '--'} +

+

+ {isPositive ? '+' : ''} + {price ? formatPercent(price.change_24h) : '--'} +

+
+ ) + } + + return ( +
+ {/* Remove button */} + {onRemove && ( + + )} + +
+
+
+ {symbol.charAt(0)} +
+
+

{coinId}

+

{symbol}

+
+
+ +
+

+ {price ? formatCurrency(price.price) : '--'} +

+
+ {isPositive ? ( + + ) : ( + + )} + + {isPositive ? '+' : ''} + {price ? formatPercent(price.change_24h) : '--'} + +
+
+
+ + {/* Additional stats */} + {price && ( +
+
+

24h High

+

+ {formatCurrency(price.high_24h)} +

+
+
+

24h Low

+

+ {formatCurrency(price.low_24h)} +

+
+
+

Volume

+

+ {formatCompactNumber(price.volume_24h)} +

+
+
+ )} + + {/* Selection indicator */} + {isSelected && ( +
+ )} +
+ ) +} + +function formatCompactNumber(num: number): string { + if (num >= 1e9) return (num / 1e9).toFixed(1) + 'B' + if (num >= 1e6) return (num / 1e6).toFixed(1) + 'M' + if (num >= 1e3) return (num / 1e3).toFixed(1) + 'K' + return num.toFixed(0) +} diff --git a/frontend/components/crypto/price-chart.tsx b/frontend/components/crypto/price-chart.tsx new file mode 100644 index 0000000..f6e620b --- /dev/null +++ b/frontend/components/crypto/price-chart.tsx @@ -0,0 +1,227 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Area, + AreaChart, +} from 'recharts' +import { Loader2 } from 'lucide-react' +import { cn } from '@/lib/utils' +import { cryptoApi } from '@/lib/api' +import { formatCurrency } from '@/lib/utils/formatters' + +interface PriceChartProps { + coinId: string +} + +type TimeRange = '1D' | '7D' | '1M' | '3M' | '1Y' + +const TIME_RANGES: { label: TimeRange; days: number }[] = [ + { label: '1D', days: 1 }, + { label: '7D', days: 7 }, + { label: '1M', days: 30 }, + { label: '3M', days: 90 }, + { label: '1Y', days: 365 }, +] + +interface ChartData { + timestamp: number + price: number + date: string +} + +export function PriceChart({ coinId }: PriceChartProps) { + const [timeRange, setTimeRange] = useState('7D') + const [data, setData] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + loadChartData() + }, [coinId, timeRange]) + + const loadChartData = async () => { + setIsLoading(true) + try { + const days = TIME_RANGES.find((r) => r.label === timeRange)?.days || 7 + const historical = await cryptoApi.getHistorical(coinId, days) + setData( + historical.prices.map(([timestamp, price]: [number, number]) => ({ + timestamp, + price, + date: formatChartDate(timestamp, timeRange), + })) + ) + } catch (error) { + // Generate mock data + setData(generateMockChartData(timeRange)) + } finally { + setIsLoading(false) + } + } + + const priceChange = data.length > 1 ? data[data.length - 1].price - data[0].price : 0 + const priceChangePercent = + data.length > 1 ? (priceChange / data[0].price) * 100 : 0 + const isPositive = priceChange >= 0 + + return ( +
+ {/* Header */} +
+
+

+ {coinId} Price +

+ {data.length > 0 && ( +
+ + {formatCurrency(data[data.length - 1]?.price || 0)} + + + {isPositive ? '+' : ''} + {priceChangePercent.toFixed(2)}% + +
+ )} +
+ + {/* Time Range Selector */} +
+ {TIME_RANGES.map(({ label }) => ( + + ))} +
+
+ + {/* Chart */} +
+ {isLoading ? ( +
+ +
+ ) : ( + + + + + + + + + + formatCompactCurrency(value)} + dx={-10} + /> + } + cursor={{ stroke: '#3f3f46', strokeDasharray: '5 5' }} + /> + + + + )} +
+
+ ) +} + +function CustomTooltip({ active, payload, label }: any) { + if (!active || !payload || !payload.length) return null + + return ( +
+

{label}

+

+ {formatCurrency(payload[0].value)} +

+
+ ) +} + +function formatChartDate(timestamp: number, range: TimeRange): string { + const date = new Date(timestamp) + switch (range) { + case '1D': + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) + case '7D': + case '1M': + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + case '3M': + case '1Y': + return date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }) + default: + return date.toLocaleDateString() + } +} + +function formatCompactCurrency(value: number): string { + if (value >= 1000) return '$' + (value / 1000).toFixed(1) + 'k' + return '$' + value.toFixed(0) +} + +function generateMockChartData(range: TimeRange): ChartData[] { + const days = TIME_RANGES.find((r) => r.label === range)?.days || 7 + const points = range === '1D' ? 24 : days + const basePrice = 45000 + Math.random() * 10000 + const data: ChartData[] = [] + + for (let i = 0; i < points; i++) { + const timestamp = Date.now() - (points - i) * (range === '1D' ? 3600000 : 86400000) + const variance = (Math.random() - 0.5) * basePrice * 0.05 + const trend = (i / points) * basePrice * 0.02 + data.push({ + timestamp, + price: basePrice + variance + trend, + date: formatChartDate(timestamp, range), + }) + } + + return data +} diff --git a/frontend/components/crypto/trending-list.tsx b/frontend/components/crypto/trending-list.tsx new file mode 100644 index 0000000..7693729 --- /dev/null +++ b/frontend/components/crypto/trending-list.tsx @@ -0,0 +1,112 @@ +'use client' + +import { TrendingUp, Flame, Plus } from 'lucide-react' +import { Button, Badge } from '@/components/ui' +import { cn } from '@/lib/utils' +import type { TrendingCoin } from '@/types' + +interface TrendingListProps { + coins: TrendingCoin[] + onSelect: (coinId: string) => void +} + +export function TrendingList({ coins, onSelect }: TrendingListProps) { + if (coins.length === 0) { + return ( +
+ +

No trending coins available

+
+ ) + } + + return ( +
+ {coins.slice(0, 7).map((coin, index) => ( + onSelect(coin.id)} + /> + ))} +
+ ) +} + +interface TrendingItemProps { + coin: TrendingCoin + rank: number + onSelect: () => void +} + +function TrendingItem({ coin, rank, onSelect }: TrendingItemProps) { + const rankColors: Record = { + 1: 'text-yellow-400', + 2: 'text-gray-400', + 3: 'text-orange-400', + } + + return ( +
+
+ {/* Rank */} + + {rank} + + + {/* Coin Icon/Symbol */} + {coin.thumb ? ( + {coin.name} + ) : ( +
+ + {coin.symbol.charAt(0)} + +
+ )} + + {/* Coin Info */} +
+

{coin.name}

+

{coin.symbol}

+
+
+ + {/* Market Cap Rank Badge */} +
+ {coin.market_cap_rank && ( + + #{coin.market_cap_rank} + + )} + +
+
+ ) +} diff --git a/frontend/components/layout/header.tsx b/frontend/components/layout/header.tsx new file mode 100644 index 0000000..ee0eecc --- /dev/null +++ b/frontend/components/layout/header.tsx @@ -0,0 +1,170 @@ +'use client' + +import { useState, useRef, useEffect } from 'react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { User, LogOut, Settings, ChevronDown, Bell } from 'lucide-react' +import { cn } from '@/lib/utils' +import { useAuth } from '@/contexts/auth-context' + +interface HeaderProps { + title?: string + subtitle?: string + actions?: React.ReactNode +} + +export function Header({ title, subtitle, actions }: HeaderProps) { + const pathname = usePathname() + const { user, logout } = useAuth() + const [isUserMenuOpen, setIsUserMenuOpen] = useState(false) + const menuRef = useRef(null) + + // Get page title from pathname + const getPageTitle = () => { + if (title) return title + const segments = pathname.split('/').filter(Boolean) + const lastSegment = segments[segments.length - 1] || 'Dashboard' + return lastSegment.charAt(0).toUpperCase() + lastSegment.slice(1) + } + + // Close menu on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setIsUserMenuOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + return ( +
+ {/* Left: Title */} +
+

+ {getPageTitle()} +

+ {subtitle && ( +

{subtitle}

+ )} +
+ + {/* Right: Actions & User Menu */} +
+ {actions} + + {/* Notifications */} + + + {/* User Menu */} +
+ + + {/* Dropdown Menu */} + {isUserMenuOpen && ( +
+ {/* User Info */} +
+

+ {user?.username || 'User'} +

+

+ {user?.email || 'user@example.com'} +

+
+ + {/* Menu Items */} +
+ setIsUserMenuOpen(false)} + > + + Settings + + setIsUserMenuOpen(false)} + > + + Profile + +
+ + {/* Logout */} +
+ +
+
+ )} +
+
+
+ ) +} diff --git a/frontend/components/layout/index.ts b/frontend/components/layout/index.ts new file mode 100644 index 0000000..c0d5209 --- /dev/null +++ b/frontend/components/layout/index.ts @@ -0,0 +1,3 @@ +export { Sidebar, SidebarSpacer } from './sidebar' +export { Header } from './header' +export { Shell, PageContainer, PageHeader, PageSection, Grid } from './shell' diff --git a/frontend/components/layout/shell.tsx b/frontend/components/layout/shell.tsx new file mode 100644 index 0000000..edbd478 --- /dev/null +++ b/frontend/components/layout/shell.tsx @@ -0,0 +1,140 @@ +'use client' + +import { type ReactNode } from 'react' +import { cn } from '@/lib/utils' +import { Sidebar } from './sidebar' +import { Header } from './header' + +interface ShellProps { + children: ReactNode + title?: string + subtitle?: string + actions?: ReactNode + fullWidth?: boolean +} + +export function Shell({ + children, + title, + subtitle, + actions, + fullWidth = false, +}: ShellProps) { + return ( +
+ {/* Sidebar */} + + + {/* Main content area */} +
+ {/* Header */} +
+ + {/* Page content */} +
+ {children} +
+
+
+ ) +} + +interface PageContainerProps { + children: ReactNode + className?: string +} + +export function PageContainer({ children, className }: PageContainerProps) { + return ( +
+ {children} +
+ ) +} + +interface PageHeaderProps { + title: string + description?: string + actions?: ReactNode +} + +export function PageHeader({ title, description, actions }: PageHeaderProps) { + return ( +
+
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ {actions &&
{actions}
} +
+ ) +} + +interface PageSectionProps { + children: ReactNode + title?: string + description?: string + className?: string +} + +export function PageSection({ + children, + title, + description, + className, +}: PageSectionProps) { + return ( +
+ {(title || description) && ( +
+ {title && ( +

+ {title} +

+ )} + {description && ( +

{description}

+ )} +
+ )} + {children} +
+ ) +} + +interface GridProps { + children: ReactNode + cols?: 1 | 2 | 3 | 4 + gap?: 'sm' | 'md' | 'lg' + className?: string +} + +export function Grid({ children, cols = 3, gap = 'md', className }: GridProps) { + const colsClass = { + 1: 'grid-cols-1', + 2: 'grid-cols-1 md:grid-cols-2', + 3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', + 4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4', + } + + const gapClass = { + sm: 'gap-3', + md: 'gap-4', + lg: 'gap-6', + } + + return ( +
+ {children} +
+ ) +} diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx new file mode 100644 index 0000000..d6df2e6 --- /dev/null +++ b/frontend/components/layout/sidebar.tsx @@ -0,0 +1,150 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { + FlaskConical, + Coins, + Workflow, + Settings, + HelpCircle, + ChevronLeft, + ChevronRight, +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { useState } from 'react' + +interface NavItem { + name: string + href: string + icon: React.ComponentType<{ className?: string }> + badge?: string | number +} + +const navigation: NavItem[] = [ + { name: 'Research', href: '/research', icon: FlaskConical }, + { name: 'Crypto', href: '/crypto', icon: Coins }, + { name: 'Automation', href: '/automation', icon: Workflow }, + { name: 'Settings', href: '/settings', icon: Settings }, +] + +export function Sidebar() { + const pathname = usePathname() + const [isCollapsed, setIsCollapsed] = useState(false) + + return ( + + ) +} + +export function SidebarSpacer() { + return
+} diff --git a/frontend/components/research/chat-panel.tsx b/frontend/components/research/chat-panel.tsx new file mode 100644 index 0000000..4d485a2 --- /dev/null +++ b/frontend/components/research/chat-panel.tsx @@ -0,0 +1,193 @@ +'use client' + +import { useState, useRef, useEffect } from 'react' +import { Send, Bot, User, Loader2, Sparkles } from 'lucide-react' +import { Button, Input } from '@/components/ui' +import { cn } from '@/lib/utils' + +interface Message { + id: string + role: 'user' | 'assistant' + content: string + timestamp: Date +} + +export function ChatPanel() { + const [messages, setMessages] = useState([ + { + id: '1', + role: 'assistant', + content: + 'Hello! I\'m your research assistant. Ask me questions about your uploaded documents or any research topic.', + timestamp: new Date(), + }, + ]) + const [input, setInput] = useState('') + const [isLoading, setIsLoading] = useState(false) + const messagesEndRef = useRef(null) + const inputRef = useRef(null) + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!input.trim() || isLoading) return + + const userMessage: Message = { + id: Date.now().toString(), + role: 'user', + content: input.trim(), + timestamp: new Date(), + } + + setMessages((prev) => [...prev, userMessage]) + setInput('') + setIsLoading(true) + + // Simulate AI response (replace with actual API call) + setTimeout(() => { + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: generateMockResponse(userMessage.content), + timestamp: new Date(), + } + setMessages((prev) => [...prev, assistantMessage]) + setIsLoading(false) + }, 1500) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSubmit(e) + } + } + + return ( +
+ {/* Header */} +
+
+ +
+
+

Research Assistant

+

Ask questions about your documents

+
+
+ + {/* Messages */} +
+ {messages.map((message) => ( + + ))} + {isLoading && ( +
+
+ +
+
+ + Thinking... +
+
+ )} +
+
+ + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Ask a question..." + disabled={isLoading} + className="flex-1" + /> + +
+

+ Press Enter to send, Shift+Enter for new line +

+
+
+ ) +} + +interface ChatMessageProps { + message: Message +} + +function ChatMessage({ message }: ChatMessageProps) { + const isUser = message.role === 'user' + + return ( +
+
+ {isUser ? ( + + ) : ( + + )} +
+
+

{message.content}

+

+ {formatTime(message.timestamp)} +

+
+
+ ) +} + +function formatTime(date: Date): string { + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }) +} + +// Mock response generator +function generateMockResponse(query: string): string { + const responses = [ + "Based on the documents I've analyzed, I can provide some insights on that topic. The key points include the importance of data-driven decision making and the role of AI in modern research workflows.", + "That's an interesting question. From my analysis of your documents, I found several relevant sections that discuss this. Would you like me to provide more specific details?", + "I found relevant information in your uploaded documents. The research suggests that this area has seen significant developments recently, particularly in terms of automation and efficiency improvements.", + "Let me help you with that. Based on the context from your documents, there are multiple perspectives to consider here. The main takeaways involve understanding both the technical and practical implications.", + ] + return responses[Math.floor(Math.random() * responses.length)] +} diff --git a/frontend/components/research/document-upload.tsx b/frontend/components/research/document-upload.tsx new file mode 100644 index 0000000..8325108 --- /dev/null +++ b/frontend/components/research/document-upload.tsx @@ -0,0 +1,221 @@ +'use client' + +import { useState, useRef, useCallback } from 'react' +import { Upload, File, X, AlertCircle, CheckCircle } from 'lucide-react' +import { Button, Badge, Progress } from '@/components/ui' +import { cn } from '@/lib/utils' +import { researchApi } from '@/lib/api' +import type { Job } from '@/types' + +interface DocumentUploadProps { + onJobCreated: (job: Job) => void +} + +const ACCEPTED_TYPES = { + 'application/pdf': '.pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx', + 'text/plain': '.txt', +} + +const MAX_SIZE = 50 * 1024 * 1024 // 50MB + +export function DocumentUpload({ onJobCreated }: DocumentUploadProps) { + const [isDragging, setIsDragging] = useState(false) + const [file, setFile] = useState(null) + const [error, setError] = useState(null) + const [isUploading, setIsUploading] = useState(false) + const [uploadProgress, setUploadProgress] = useState(0) + const inputRef = useRef(null) + + const validateFile = (file: File): string | null => { + if (!Object.keys(ACCEPTED_TYPES).includes(file.type)) { + return 'Invalid file type. Please upload a PDF, DOCX, or TXT file.' + } + if (file.size > MAX_SIZE) { + return 'File is too large. Maximum size is 50MB.' + } + return null + } + + const handleFile = (file: File) => { + const validationError = validateFile(file) + if (validationError) { + setError(validationError) + setFile(null) + return + } + setError(null) + setFile(file) + } + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + + const droppedFile = e.dataTransfer.files[0] + if (droppedFile) { + handleFile(droppedFile) + } + }, []) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + }, []) + + const handleInputChange = (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0] + if (selectedFile) { + handleFile(selectedFile) + } + } + + const handleUpload = async () => { + if (!file) return + + setIsUploading(true) + setUploadProgress(0) + + try { + // Simulate progress for UX + const progressInterval = setInterval(() => { + setUploadProgress((prev) => Math.min(prev + 10, 90)) + }, 200) + + const job = await researchApi.upload(file) + + clearInterval(progressInterval) + setUploadProgress(100) + + onJobCreated(job) + + // Reset after short delay + setTimeout(() => { + setFile(null) + setUploadProgress(0) + }, 1500) + } catch (err) { + setError(err instanceof Error ? err.message : 'Upload failed') + } finally { + setIsUploading(false) + } + } + + const clearFile = () => { + setFile(null) + setError(null) + if (inputRef.current) { + inputRef.current.value = '' + } + } + + const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return bytes + ' B' + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' + return (bytes / (1024 * 1024)).toFixed(1) + ' MB' + } + + return ( +
+ {/* Drop Zone */} +
inputRef.current?.click()} + className={cn( + 'relative flex flex-col items-center justify-center p-8', + 'border-2 border-dashed rounded-terminal cursor-pointer', + 'transition-all duration-200', + isDragging + ? 'border-accent-primary bg-accent-primary/5' + : 'border-border-default hover:border-text-muted hover:bg-bg-elevated/50', + file && 'border-accent-success bg-accent-success/5' + )} + > + + + {file ? ( +
+
+ +
+
+

{file.name}

+

{formatFileSize(file.size)}

+
+ +
+ ) : ( + <> +
+ +
+

+ Drop your document here or click to browse +

+

+ Supports PDF, DOCX, TXT (max 50MB) +

+ + )} +
+ + {/* Error Message */} + {error && ( +
+ +

{error}

+
+ )} + + {/* Upload Progress */} + {isUploading && ( +
+
+ Uploading... + {uploadProgress}% +
+ +
+ )} + + {/* Upload Button */} + {file && !isUploading && ( + + )} + + {/* Success State */} + {uploadProgress === 100 && ( +
+ +

+ Document uploaded! Processing started. +

+
+ )} +
+ ) +} diff --git a/frontend/components/research/entity-panel.tsx b/frontend/components/research/entity-panel.tsx new file mode 100644 index 0000000..c6b34d5 --- /dev/null +++ b/frontend/components/research/entity-panel.tsx @@ -0,0 +1,266 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + User, + Building2, + MapPin, + Calendar, + Tag, + Hash, + ExternalLink, + ChevronDown, + ChevronRight, + Loader2, +} from 'lucide-react' +import { Badge } from '@/components/ui' +import { cn } from '@/lib/utils' +import { researchApi } from '@/lib/api' +import type { Report, Entity } from '@/types' + +interface EntityPanelProps { + report: Report +} + +type EntityType = 'person' | 'organization' | 'location' | 'date' | 'topic' | 'other' + +const ENTITY_ICONS: Record = { + person: , + organization: , + location: , + date: , + topic: , + other: , +} + +const ENTITY_COLORS: Record = { + person: 'text-blue-400 bg-blue-400/10', + organization: 'text-purple-400 bg-purple-400/10', + location: 'text-green-400 bg-green-400/10', + date: 'text-orange-400 bg-orange-400/10', + topic: 'text-accent-primary bg-accent-primary/10', + other: 'text-text-muted bg-bg-elevated', +} + +export function EntityPanel({ report }: EntityPanelProps) { + const [entities, setEntities] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [expandedTypes, setExpandedTypes] = useState([ + 'person', + 'organization', + 'topic', + ]) + + useEffect(() => { + loadEntities() + }, [report.id]) + + const loadEntities = async () => { + setIsLoading(true) + try { + const data = await researchApi.extractEntities(report.id) + setEntities(data) + } catch (error) { + console.error('Failed to load entities:', error) + // Fallback to mock entities for demo + setEntities(generateMockEntities()) + } finally { + setIsLoading(false) + } + } + + const groupedEntities = entities.reduce((acc, entity) => { + const type = (entity.type as EntityType) || 'other' + if (!acc[type]) { + acc[type] = [] + } + acc[type].push(entity) + return acc + }, {} as Record) + + const toggleType = (type: EntityType) => { + setExpandedTypes((prev) => + prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type] + ) + } + + if (isLoading) { + return ( +
+
+ +
+
+ ) + } + + if (entities.length === 0) { + return ( +
+

+ Extracted Entities +

+
+ +

No entities found

+
+
+ ) + } + + return ( +
+
+

+ Extracted Entities +

+

+ {entities.length} entities found +

+
+ +
+ {(Object.keys(groupedEntities) as EntityType[]).map((type) => ( + toggleType(type)} + /> + ))} +
+
+ ) +} + +interface EntityGroupProps { + type: EntityType + entities: Entity[] + isExpanded: boolean + onToggle: () => void +} + +function EntityGroup({ type, entities, isExpanded, onToggle }: EntityGroupProps) { + return ( +
+ + + {isExpanded && ( +
+ {entities.map((entity, index) => ( + + ))} +
+ )} +
+ ) +} + +interface EntityCardProps { + entity: Entity + type: EntityType +} + +function EntityCard({ entity, type }: EntityCardProps) { + return ( +
+
+
+

+ {entity.name} +

+ {entity.description && ( +

+ {entity.description} +

+ )} +
+ {entity.confidence && ( + + {Math.round(entity.confidence * 100)}% + + )} +
+ + {entity.url && ( + + Learn more + + + )} + + {entity.mentions && entity.mentions > 1 && ( +

+ Mentioned {entity.mentions} times +

+ )} +
+ ) +} + +// Generate mock entities for demo +function generateMockEntities(): Entity[] { + return [ + { + name: 'OpenAI', + type: 'organization', + description: 'AI research company', + confidence: 0.95, + mentions: 12, + }, + { + name: 'Sam Altman', + type: 'person', + description: 'CEO of OpenAI', + confidence: 0.92, + mentions: 5, + }, + { + name: 'San Francisco', + type: 'location', + description: 'City in California, USA', + confidence: 0.88, + mentions: 3, + }, + { + name: 'Machine Learning', + type: 'topic', + description: 'Subset of AI focused on learning from data', + confidence: 0.96, + mentions: 8, + }, + { + name: 'GPT-4', + type: 'other', + description: 'Large language model', + confidence: 0.99, + mentions: 15, + }, + ] +} diff --git a/frontend/components/research/index.ts b/frontend/components/research/index.ts new file mode 100644 index 0000000..21efe9a --- /dev/null +++ b/frontend/components/research/index.ts @@ -0,0 +1,7 @@ +export { DocumentUpload } from './document-upload' +export { UrlInput } from './url-input' +export { ModelSelector } from './model-selector' +export { ReportViewer } from './report-viewer' +export { EntityPanel } from './entity-panel' +export { ChatPanel } from './chat-panel' +export { ReportCard } from './report-card' diff --git a/frontend/components/research/model-selector.tsx b/frontend/components/research/model-selector.tsx new file mode 100644 index 0000000..bfc9111 --- /dev/null +++ b/frontend/components/research/model-selector.tsx @@ -0,0 +1,199 @@ +'use client' + +import { useState } from 'react' +import { Sparkles, Zap, Brain, AlertCircle, Loader2 } from 'lucide-react' +import { Button, Textarea } from '@/components/ui' +import { cn } from '@/lib/utils' +import { researchApi } from '@/lib/api' +import type { Job } from '@/types' + +interface ModelSelectorProps { + onJobCreated: (job: Job) => void +} + +interface Model { + id: string + name: string + description: string + icon: React.ReactNode + speed: 'fast' | 'medium' | 'slow' + quality: 'good' | 'better' | 'best' +} + +const MODELS: Model[] = [ + { + id: 'gpt-4o-mini', + name: 'GPT-4o Mini', + description: 'Fast and efficient for most tasks', + icon: , + speed: 'fast', + quality: 'good', + }, + { + id: 'gpt-4o', + name: 'GPT-4o', + description: 'Balanced speed and intelligence', + icon: , + speed: 'medium', + quality: 'better', + }, + { + id: 'claude-3-5-sonnet', + name: 'Claude 3.5 Sonnet', + description: 'Best for complex analysis', + icon: , + speed: 'slow', + quality: 'best', + }, +] + +export function ModelSelector({ onJobCreated }: ModelSelectorProps) { + const [selectedModel, setSelectedModel] = useState('gpt-4o') + const [prompt, setPrompt] = useState('') + const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + if (!prompt.trim()) { + setError('Please enter a research topic or question') + return + } + + setIsSubmitting(true) + + try { + const job = await researchApi.generate(prompt.trim(), selectedModel) + onJobCreated(job) + setPrompt('') + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to generate report') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+ {/* Model Selection */} +
+ +
+ {MODELS.map((model) => ( + setSelectedModel(model.id)} + /> + ))} +
+
+ + {/* Research Prompt */} +
+ +