mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-05 05:42:39 +02:00
chore: merge upstream with local feature additions
- Merged dexscreener connector, composio connectors, crypto realtime tools from upstream - Kept local additions: dropbox/onedrive connectors, memory routes, model_list routes, RefreshToken model - Resolved frontend conflicts: kept tool UIs from both sides - Accepted upstream lock files (uv.lock, pnpm-lock.yaml)
This commit is contained in:
commit
6e86cd7e8a
803 changed files with 152168 additions and 14005 deletions
|
|
@ -2,6 +2,175 @@ import { Storage } from "@plasmohq/storage";
|
|||
import { getRenderedHtml, initQueues, initWebHistory } from "~utils/commons";
|
||||
import type { WebHistory } from "~utils/interfaces";
|
||||
|
||||
// Configure side panel to open when extension icon is clicked
|
||||
chrome.sidePanel
|
||||
.setPanelBehavior({ openPanelOnActionClick: true })
|
||||
.catch((error) => console.error("Failed to set side panel behavior:", error));
|
||||
|
||||
// ============================================
|
||||
// Context Menu Setup (Epic 4.3)
|
||||
// ============================================
|
||||
|
||||
// Create context menus on extension install
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
// Parent menu for SurfSense
|
||||
chrome.contextMenus.create({
|
||||
id: "surfsense-parent",
|
||||
title: "🧠 SurfSense",
|
||||
contexts: ["selection", "page", "link"],
|
||||
});
|
||||
|
||||
// Analyze Token - for selected text (token address or symbol)
|
||||
chrome.contextMenus.create({
|
||||
id: "analyze-token",
|
||||
parentId: "surfsense-parent",
|
||||
title: "🔍 Analyze Token",
|
||||
contexts: ["selection"],
|
||||
});
|
||||
|
||||
// Check Safety - for selected text
|
||||
chrome.contextMenus.create({
|
||||
id: "check-safety",
|
||||
parentId: "surfsense-parent",
|
||||
title: "🛡️ Check Safety",
|
||||
contexts: ["selection"],
|
||||
});
|
||||
|
||||
// Add to Watchlist - for selected text
|
||||
chrome.contextMenus.create({
|
||||
id: "add-watchlist",
|
||||
parentId: "surfsense-parent",
|
||||
title: "⭐ Add to Watchlist",
|
||||
contexts: ["selection"],
|
||||
});
|
||||
|
||||
// Separator
|
||||
chrome.contextMenus.create({
|
||||
id: "separator-1",
|
||||
parentId: "surfsense-parent",
|
||||
type: "separator",
|
||||
contexts: ["selection", "page", "link"],
|
||||
});
|
||||
|
||||
// Copy Address - for selected text
|
||||
chrome.contextMenus.create({
|
||||
id: "copy-address",
|
||||
parentId: "surfsense-parent",
|
||||
title: "📋 Copy Address",
|
||||
contexts: ["selection"],
|
||||
});
|
||||
|
||||
// View on Explorer - for selected text
|
||||
chrome.contextMenus.create({
|
||||
id: "view-explorer",
|
||||
parentId: "surfsense-parent",
|
||||
title: "🔗 View on Explorer",
|
||||
contexts: ["selection"],
|
||||
});
|
||||
|
||||
// Separator
|
||||
chrome.contextMenus.create({
|
||||
id: "separator-2",
|
||||
parentId: "surfsense-parent",
|
||||
type: "separator",
|
||||
contexts: ["selection", "page", "link"],
|
||||
});
|
||||
|
||||
// Capture Page - for page context
|
||||
chrome.contextMenus.create({
|
||||
id: "capture-page",
|
||||
parentId: "surfsense-parent",
|
||||
title: "📸 Capture This Page",
|
||||
contexts: ["page"],
|
||||
});
|
||||
|
||||
// Ask AI about this page
|
||||
chrome.contextMenus.create({
|
||||
id: "ask-ai-page",
|
||||
parentId: "surfsense-parent",
|
||||
title: "💬 Ask AI About This Page",
|
||||
contexts: ["page"],
|
||||
});
|
||||
});
|
||||
|
||||
// Handle context menu clicks
|
||||
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
const selectedText = info.selectionText?.trim() || "";
|
||||
const storage = new Storage({ area: "local" });
|
||||
|
||||
// Store the action for sidepanel to pick up
|
||||
const contextAction = {
|
||||
action: info.menuItemId,
|
||||
text: selectedText,
|
||||
pageUrl: info.pageUrl,
|
||||
linkUrl: info.linkUrl,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
await storage.set("pendingContextAction", contextAction);
|
||||
|
||||
// Open sidepanel to handle the action
|
||||
if (tab?.id) {
|
||||
try {
|
||||
await chrome.sidePanel.open({ tabId: tab.id });
|
||||
} catch (error) {
|
||||
console.error("Failed to open side panel:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Message Listeners
|
||||
// ============================================
|
||||
|
||||
// Listen for messages from content scripts
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === "OPEN_SIDEPANEL") {
|
||||
// Open sidepanel for the current tab
|
||||
if (sender.tab?.id) {
|
||||
chrome.sidePanel.open({ tabId: sender.tab.id })
|
||||
.catch((error) => console.error("Failed to open side panel:", error));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle context action from sidepanel
|
||||
if (message.type === "GET_CONTEXT_ACTION") {
|
||||
const storage = new Storage({ area: "local" });
|
||||
storage.get("pendingContextAction").then((action) => {
|
||||
sendResponse(action);
|
||||
// Clear the pending action
|
||||
storage.remove("pendingContextAction");
|
||||
});
|
||||
return true; // Keep channel open for async response
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Keyboard Shortcuts (Epic 4.5)
|
||||
// ============================================
|
||||
|
||||
chrome.commands.onCommand.addListener(async (command) => {
|
||||
const storage = new Storage({ area: "local" });
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
|
||||
if (!tab?.id) return;
|
||||
|
||||
// Store the keyboard command for sidepanel to pick up
|
||||
const keyboardAction = {
|
||||
action: command,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
await storage.set("pendingKeyboardAction", keyboardAction);
|
||||
|
||||
// Open sidepanel for all commands
|
||||
try {
|
||||
await chrome.sidePanel.open({ tabId: tab.id });
|
||||
} catch (error) {
|
||||
console.error("Failed to open side panel:", error);
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onCreated.addListener(async (tab: any) => {
|
||||
try {
|
||||
await initWebHistory(tab.id);
|
||||
|
|
|
|||
|
|
@ -5,3 +5,285 @@ export const config: PlasmoCSConfig = {
|
|||
all_frames: true,
|
||||
world: "MAIN",
|
||||
};
|
||||
|
||||
/**
|
||||
* Content script for page context detection
|
||||
* Extracts relevant data from crypto pages and sends to side panel
|
||||
*/
|
||||
|
||||
type PageType = "dexscreener" | "coingecko" | "twitter" | "generic";
|
||||
|
||||
interface TokenData {
|
||||
chain: string;
|
||||
pairAddress: string;
|
||||
tokenSymbol?: string;
|
||||
price?: string;
|
||||
volume24h?: string;
|
||||
liquidity?: string;
|
||||
}
|
||||
|
||||
interface PageContext {
|
||||
url: string;
|
||||
title: string;
|
||||
pageType: PageType;
|
||||
tokenData?: TokenData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect page type from URL
|
||||
*/
|
||||
function detectPageType(url: string): PageType {
|
||||
if (url.includes("dexscreener.com")) return "dexscreener";
|
||||
if (url.includes("coingecko.com")) return "coingecko";
|
||||
if (url.includes("twitter.com") || url.includes("x.com")) return "twitter";
|
||||
return "generic";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract DexScreener token data from DOM
|
||||
*/
|
||||
function extractDexScreenerData(): TokenData | undefined {
|
||||
const url = window.location.href;
|
||||
const match = url.match(/dexscreener\.com\/([^\/]+)\/([^\/\?]+)/);
|
||||
|
||||
if (!match) return undefined;
|
||||
|
||||
const [, chain, pairAddress] = match;
|
||||
|
||||
// Try to extract data from DOM
|
||||
// Note: DexScreener uses dynamic rendering, so selectors may need adjustment
|
||||
const tokenSymbol =
|
||||
document.querySelector('[data-test="token-symbol"]')?.textContent ||
|
||||
document.querySelector(".token-symbol")?.textContent ||
|
||||
undefined;
|
||||
|
||||
const price =
|
||||
document.querySelector('[data-test="token-price"]')?.textContent ||
|
||||
document.querySelector(".token-price")?.textContent ||
|
||||
undefined;
|
||||
|
||||
const volume24h =
|
||||
document.querySelector('[data-test="volume-24h"]')?.textContent ||
|
||||
document.querySelector(".volume-24h")?.textContent ||
|
||||
undefined;
|
||||
|
||||
const liquidity =
|
||||
document.querySelector('[data-test="liquidity"]')?.textContent ||
|
||||
document.querySelector(".liquidity")?.textContent ||
|
||||
undefined;
|
||||
|
||||
return {
|
||||
chain,
|
||||
pairAddress,
|
||||
tokenSymbol,
|
||||
price,
|
||||
volume24h,
|
||||
liquidity,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token mentions from Twitter/X
|
||||
* Detects $TOKEN format (e.g., $BONK, $SOL)
|
||||
*/
|
||||
function extractTwitterTokens(): TokenData[] {
|
||||
const tokens: TokenData[] = [];
|
||||
const pageText = document.body.innerText;
|
||||
|
||||
// Match $TOKEN pattern (e.g., $BONK, $SOL, $PEPE)
|
||||
const tokenPattern = /\$([A-Z]{2,10})\b/g;
|
||||
const matches = pageText.matchAll(tokenPattern);
|
||||
|
||||
const uniqueTokens = new Set<string>();
|
||||
for (const match of matches) {
|
||||
const symbol = match[1];
|
||||
if (!uniqueTokens.has(symbol)) {
|
||||
uniqueTokens.add(symbol);
|
||||
tokens.push({
|
||||
chain: "solana", // Default to Solana, can be enhanced
|
||||
pairAddress: "", // Will be resolved via API
|
||||
tokenSymbol: symbol,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract contract addresses from page content
|
||||
* Supports Solana and Ethereum address formats
|
||||
*/
|
||||
function extractContractAddresses(): TokenData[] {
|
||||
const tokens: TokenData[] = [];
|
||||
const pageText = document.body.innerText;
|
||||
|
||||
// Solana address pattern (base58, 32-44 characters)
|
||||
const solanaPattern = /\b([1-9A-HJ-NP-Za-km-z]{32,44})\b/g;
|
||||
|
||||
// Ethereum address pattern (0x followed by 40 hex characters)
|
||||
const ethPattern = /\b(0x[a-fA-F0-9]{40})\b/g;
|
||||
|
||||
// Extract Ethereum addresses
|
||||
const ethMatches = pageText.matchAll(ethPattern);
|
||||
for (const match of ethMatches) {
|
||||
const address = match[1];
|
||||
tokens.push({
|
||||
chain: "ethereum",
|
||||
pairAddress: address,
|
||||
tokenSymbol: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract Solana addresses (more selective to avoid false positives)
|
||||
const solanaMatches = pageText.matchAll(solanaPattern);
|
||||
const uniqueSolanaAddresses = new Set<string>();
|
||||
|
||||
for (const match of solanaMatches) {
|
||||
const address = match[1];
|
||||
// Basic validation: should not be all same character, should have variety
|
||||
if (address.length >= 32 &&
|
||||
address.length <= 44 &&
|
||||
new Set(address).size > 10 &&
|
||||
!uniqueSolanaAddresses.has(address)) {
|
||||
uniqueSolanaAddresses.add(address);
|
||||
tokens.push({
|
||||
chain: "solana",
|
||||
pairAddress: address,
|
||||
tokenSymbol: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.slice(0, 5); // Limit to first 5 to avoid spam
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract trading pairs from page content
|
||||
* Detects patterns like TOKEN/SOL, TOKEN/USDT, etc.
|
||||
*/
|
||||
function extractTradingPairs(): TokenData[] {
|
||||
const tokens: TokenData[] = [];
|
||||
const pageText = document.body.innerText;
|
||||
|
||||
// Match trading pair patterns (e.g., BONK/SOL, PEPE/USDT)
|
||||
const pairPattern = /\b([A-Z]{2,10})\/([A-Z]{2,10})\b/g;
|
||||
const matches = pageText.matchAll(pairPattern);
|
||||
|
||||
const uniquePairs = new Set<string>();
|
||||
for (const match of matches) {
|
||||
const baseToken = match[1];
|
||||
const quoteToken = match[2];
|
||||
const pairKey = `${baseToken}/${quoteToken}`;
|
||||
|
||||
if (!uniquePairs.has(pairKey)) {
|
||||
uniquePairs.add(pairKey);
|
||||
tokens.push({
|
||||
chain: "solana", // Default to Solana
|
||||
pairAddress: "", // Will be resolved via API
|
||||
tokenSymbol: baseToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.slice(0, 3); // Limit to first 3 pairs
|
||||
}
|
||||
|
||||
interface PageContext {
|
||||
url: string;
|
||||
title: string;
|
||||
pageType: PageType;
|
||||
tokenData?: TokenData;
|
||||
/** Detected tokens from page content (Twitter mentions, addresses, pairs) */
|
||||
detectedTokens?: TokenData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract page context based on page type
|
||||
*/
|
||||
function extractPageContext(): PageContext {
|
||||
const url = window.location.href;
|
||||
const title = document.title;
|
||||
const pageType = detectPageType(url);
|
||||
|
||||
const context: PageContext = {
|
||||
url,
|
||||
title,
|
||||
pageType,
|
||||
};
|
||||
|
||||
// Add page-specific data
|
||||
if (pageType === "dexscreener") {
|
||||
context.tokenData = extractDexScreenerData();
|
||||
} else if (pageType === "twitter") {
|
||||
// Extract Twitter token mentions
|
||||
const twitterTokens = extractTwitterTokens();
|
||||
const contractAddresses = extractContractAddresses();
|
||||
const tradingPairs = extractTradingPairs();
|
||||
|
||||
// Combine all detected tokens
|
||||
context.detectedTokens = [
|
||||
...twitterTokens,
|
||||
...contractAddresses,
|
||||
...tradingPairs,
|
||||
];
|
||||
|
||||
// Set primary token if available
|
||||
if (context.detectedTokens.length > 0) {
|
||||
context.tokenData = context.detectedTokens[0];
|
||||
}
|
||||
} else if (pageType === "generic") {
|
||||
// For generic pages, try to detect contract addresses and trading pairs
|
||||
const contractAddresses = extractContractAddresses();
|
||||
const tradingPairs = extractTradingPairs();
|
||||
|
||||
context.detectedTokens = [
|
||||
...contractAddresses,
|
||||
...tradingPairs,
|
||||
];
|
||||
|
||||
// Set primary token if available
|
||||
if (context.detectedTokens.length > 0) {
|
||||
context.tokenData = context.detectedTokens[0];
|
||||
}
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send context update to side panel
|
||||
*/
|
||||
function sendContextUpdate() {
|
||||
const context = extractPageContext();
|
||||
chrome.runtime.sendMessage({
|
||||
type: "PAGE_CONTEXT_UPDATE",
|
||||
data: context,
|
||||
});
|
||||
}
|
||||
|
||||
// Send initial context after page load
|
||||
if (document.readyState === "complete") {
|
||||
sendContextUpdate();
|
||||
} else {
|
||||
window.addEventListener("load", sendContextUpdate);
|
||||
}
|
||||
|
||||
// Listen for context requests from side panel
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === "GET_PAGE_CONTEXT") {
|
||||
sendContextUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for DOM changes (for dynamic content like DexScreener)
|
||||
const observer = new MutationObserver(() => {
|
||||
// Debounce updates
|
||||
clearTimeout((window as any).__contextUpdateTimeout);
|
||||
(window as any).__contextUpdateTimeout = setTimeout(sendContextUpdate, 1000);
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
|
|
|||
206
surfsense_browser_extension/contents/floating-button.tsx
Normal file
206
surfsense_browser_extension/contents/floating-button.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import type { PlasmoCSConfig, PlasmoGetInlineAnchor, PlasmoGetStyle } from "plasmo";
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
/**
|
||||
* Floating Quick Action Button (like Mevx)
|
||||
* Appears on crypto-related pages for quick token analysis
|
||||
*/
|
||||
|
||||
export const config: PlasmoCSConfig = {
|
||||
matches: [
|
||||
"*://dexscreener.com/*",
|
||||
"*://www.dexscreener.com/*",
|
||||
"*://twitter.com/*",
|
||||
"*://x.com/*",
|
||||
"*://coingecko.com/*",
|
||||
"*://www.coingecko.com/*",
|
||||
"*://coinmarketcap.com/*",
|
||||
"*://www.coinmarketcap.com/*",
|
||||
],
|
||||
};
|
||||
|
||||
export const getStyle: PlasmoGetStyle = () => {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
#surfsense-floating-button {
|
||||
all: initial;
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 999999;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
#surfsense-floating-popup {
|
||||
all: initial;
|
||||
position: fixed;
|
||||
bottom: 88px;
|
||||
right: 24px;
|
||||
z-index: 999999;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
`;
|
||||
return style;
|
||||
};
|
||||
|
||||
interface TokenQuickInfo {
|
||||
symbol: string;
|
||||
name: string;
|
||||
price: string;
|
||||
change24h: number;
|
||||
chain: string;
|
||||
}
|
||||
|
||||
function FloatingButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [tokenInfo, setTokenInfo] = useState<TokenQuickInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for token detection from content script
|
||||
const handleMessage = (message: any) => {
|
||||
if (message.type === "TOKEN_DETECTED") {
|
||||
setTokenInfo(message.data);
|
||||
}
|
||||
};
|
||||
|
||||
chrome.runtime.onMessage.addListener(handleMessage);
|
||||
return () => chrome.runtime.onMessage.removeListener(handleMessage);
|
||||
}, []);
|
||||
|
||||
const handleButtonClick = async () => {
|
||||
if (!isOpen) {
|
||||
setIsLoading(true);
|
||||
// Simulate fetching quick token info
|
||||
setTimeout(() => {
|
||||
setTokenInfo({
|
||||
symbol: "BONK",
|
||||
name: "Bonk",
|
||||
price: "$0.00001234",
|
||||
change24h: 156.7,
|
||||
chain: "Solana",
|
||||
});
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
}
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleOpenSidepanel = () => {
|
||||
chrome.runtime.sendMessage({ type: "OPEN_SIDEPANEL" });
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Button */}
|
||||
<div id="surfsense-floating-button">
|
||||
<button
|
||||
onClick={handleButtonClick}
|
||||
style={{
|
||||
width: "56px",
|
||||
height: "56px",
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
border: "none",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "all 0.2s ease",
|
||||
color: "white",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "scale(1.1)";
|
||||
e.currentTarget.style.boxShadow = "0 6px 16px rgba(0, 0, 0, 0.2)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "scale(1)";
|
||||
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.15)";
|
||||
}}
|
||||
>
|
||||
{isOpen ? <X size={24} /> : <Sparkles size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Info Popup */}
|
||||
{isOpen && (
|
||||
<div id="surfsense-floating-popup">
|
||||
<div
|
||||
style={{
|
||||
width: "320px",
|
||||
background: "white",
|
||||
borderRadius: "12px",
|
||||
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.15)",
|
||||
padding: "16px",
|
||||
border: "1px solid #e5e7eb",
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: "center", padding: "20px", color: "#6b7280" }}>
|
||||
Loading...
|
||||
</div>
|
||||
) : tokenInfo ? (
|
||||
<>
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<div style={{ fontSize: "18px", fontWeight: "600", color: "#111827" }}>
|
||||
{tokenInfo.symbol}
|
||||
</div>
|
||||
<div style={{ fontSize: "14px", color: "#6b7280" }}>{tokenInfo.name}</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<div style={{ fontSize: "24px", fontWeight: "700", color: "#111827" }}>
|
||||
{tokenInfo.price}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
color: tokenInfo.change24h >= 0 ? "#10b981" : "#ef4444",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{tokenInfo.change24h >= 0 ? "+" : ""}
|
||||
{tokenInfo.change24h.toFixed(2)}% (24h)
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenSidepanel}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
cursor: "pointer",
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = "0.9";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = "1";
|
||||
}}
|
||||
>
|
||||
Full Analysis
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ textAlign: "center", padding: "20px", color: "#6b7280" }}>
|
||||
No token detected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FloatingButton;
|
||||
|
||||
|
|
@ -4,18 +4,25 @@
|
|||
"version": "0.0.15",
|
||||
"description": "Extension to collect Browsing History for SurfSense.",
|
||||
"author": "https://github.com/MODSetter",
|
||||
"engines": {
|
||||
"node": ">=18.0.0 <23.0.0",
|
||||
"pnpm": ">=8.0.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"sharp": "^0.33.5"
|
||||
}
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"@swc/core",
|
||||
"esbuild",
|
||||
"lmdb",
|
||||
"msgpackr-extract",
|
||||
"sharp"
|
||||
],
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "plasmo dev",
|
||||
"build": "plasmo build",
|
||||
"build": "plasmo build && node scripts/fix-build-paths.js",
|
||||
"build:raw": "plasmo build",
|
||||
"package": "plasmo package"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -33,7 +40,6 @@
|
|||
"dom-to-semantic-markdown": "^1.2.11",
|
||||
"linkedom": "0.1.34",
|
||||
"lucide-react": "^0.454.0",
|
||||
"plasmo": "0.90.5",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"radix-ui": "^1.0.1",
|
||||
"react": "18.2.0",
|
||||
|
|
@ -51,6 +57,7 @@
|
|||
"@types/react": "18.2.48",
|
||||
"@types/react-dom": "18.2.18",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"plasmo": "0.90.5",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"typescript": "5.3.3"
|
||||
|
|
@ -61,12 +68,44 @@
|
|||
],
|
||||
"name": "SurfSense",
|
||||
"description": "Extension to collect Browsing History for SurfSense.",
|
||||
"version": "0.0.3"
|
||||
"version": "0.0.3",
|
||||
"commands": {
|
||||
"analyze-token": {
|
||||
"suggested_key": {
|
||||
"default": "Ctrl+Shift+A",
|
||||
"mac": "Command+Shift+A"
|
||||
},
|
||||
"description": "Analyze current token"
|
||||
},
|
||||
"add-watchlist": {
|
||||
"suggested_key": {
|
||||
"default": "Ctrl+Shift+W",
|
||||
"mac": "Command+Shift+W"
|
||||
},
|
||||
"description": "Add token to watchlist"
|
||||
},
|
||||
"capture-page": {
|
||||
"suggested_key": {
|
||||
"default": "Ctrl+Shift+E",
|
||||
"mac": "Command+Shift+E"
|
||||
},
|
||||
"description": "Capture current page"
|
||||
},
|
||||
"show-portfolio": {
|
||||
"suggested_key": {
|
||||
"default": "Ctrl+Shift+P",
|
||||
"mac": "Command+Shift+P"
|
||||
},
|
||||
"description": "Show portfolio"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": [
|
||||
"storage",
|
||||
"scripting",
|
||||
"unlimitedStorage",
|
||||
"activeTab"
|
||||
"activeTab",
|
||||
"sidePanel",
|
||||
"contextMenus"
|
||||
]
|
||||
}
|
||||
}
|
||||
1237
surfsense_browser_extension/pnpm-lock.yaml
generated
1237
surfsense_browser_extension/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Toaster } from "@/routes/ui/toaster";
|
||||
import { Routing } from "~routes";
|
||||
import { Routing } from "./routes";
|
||||
|
||||
function IndexPopup() {
|
||||
return (
|
||||
|
|
|
|||
63
surfsense_browser_extension/scripts/fix-build-paths.js
Normal file
63
surfsense_browser_extension/scripts/fix-build-paths.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post-build script to fix absolute paths in Plasmo-generated HTML files.
|
||||
*
|
||||
* Problem: Plasmo generates HTML files with absolute paths (e.g., href="/file.css")
|
||||
* which don't work in Chrome extensions (ERR_FILE_NOT_FOUND).
|
||||
*
|
||||
* Solution: Replace absolute paths with relative paths (e.g., href="./file.css")
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const BUILD_DIR = path.join(__dirname, '..', 'build', 'chrome-mv3-prod');
|
||||
const HTML_FILES = ['popup.html', 'sidepanel.html'];
|
||||
|
||||
function fixPaths(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(`⚠️ File not found: ${filePath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
const originalContent = content;
|
||||
|
||||
// Replace absolute paths with relative paths
|
||||
// href="/something" -> href="./something"
|
||||
// src="/something" -> src="./something"
|
||||
content = content.replace(/href="\/([^"]+)"/g, 'href="./$1"');
|
||||
content = content.replace(/src="\/([^"]+)"/g, 'src="./$1"');
|
||||
|
||||
if (content !== originalContent) {
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
console.log(`✅ Fixed paths in: ${path.basename(filePath)}`);
|
||||
return true;
|
||||
} else {
|
||||
console.log(`ℹ️ No changes needed: ${path.basename(filePath)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('🔧 Fixing build paths for Chrome extension...\n');
|
||||
|
||||
if (!fs.existsSync(BUILD_DIR)) {
|
||||
console.error(`❌ Build directory not found: ${BUILD_DIR}`);
|
||||
console.error(' Run "pnpm build" first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let fixedCount = 0;
|
||||
for (const htmlFile of HTML_FILES) {
|
||||
const filePath = path.join(BUILD_DIR, htmlFile);
|
||||
if (fixPaths(filePath)) {
|
||||
fixedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✨ Done! Fixed ${fixedCount} file(s).`);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
16
surfsense_browser_extension/sidepanel.tsx
Normal file
16
surfsense_browser_extension/sidepanel.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { SidePanelApp } from "./sidepanel/index";
|
||||
import "./tailwind.css";
|
||||
|
||||
/**
|
||||
* Side Panel entry point for SurfSense Extension
|
||||
* Opens as a Chrome Side Panel (not popup) for better UX
|
||||
*/
|
||||
function IndexSidePanel() {
|
||||
return (
|
||||
<div className="h-screen w-full bg-background text-foreground">
|
||||
<SidePanelApp />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default IndexSidePanel;
|
||||
|
|
@ -0,0 +1,385 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Shield,
|
||||
Users,
|
||||
Droplet,
|
||||
BarChart3,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface TokenAnalysisData {
|
||||
tokenAddress: string;
|
||||
tokenSymbol: string;
|
||||
tokenName: string;
|
||||
chain: string;
|
||||
timestamp: Date;
|
||||
|
||||
contract: {
|
||||
verified: boolean;
|
||||
renounced: boolean;
|
||||
isProxy: boolean;
|
||||
sourceCode: boolean;
|
||||
};
|
||||
|
||||
holders: {
|
||||
count: number;
|
||||
top10Percent: number;
|
||||
distribution: { address: string; percent: number }[];
|
||||
};
|
||||
|
||||
liquidity: {
|
||||
totalUSD: number;
|
||||
lpLocked: boolean;
|
||||
lpLockDuration?: number;
|
||||
liquidityMcapRatio: number;
|
||||
};
|
||||
|
||||
volume: {
|
||||
volume24h: number;
|
||||
trend: "increasing" | "decreasing" | "stable";
|
||||
volumeLiquidityRatio: number;
|
||||
};
|
||||
|
||||
price: {
|
||||
current: number;
|
||||
ath: number;
|
||||
atl: number;
|
||||
change7d: number;
|
||||
change30d: number;
|
||||
volatility: number;
|
||||
};
|
||||
|
||||
social: {
|
||||
twitterMentions: number;
|
||||
telegramActivity: number;
|
||||
redditDiscussions: number;
|
||||
sentimentScore: number; // -1 to 1
|
||||
sentiment: "positive" | "negative" | "neutral";
|
||||
};
|
||||
|
||||
aiSummary: string;
|
||||
recommendation: "buy" | "hold" | "sell" | "avoid";
|
||||
confidence: number; // 0-100
|
||||
}
|
||||
|
||||
export interface TokenAnalysisPanelProps {
|
||||
/** Token analysis data */
|
||||
analysis: TokenAnalysisData;
|
||||
/** Callback when refresh is clicked */
|
||||
onRefresh?: () => void;
|
||||
/** Callback when "View Full Report" is clicked */
|
||||
onViewFullReport?: () => void;
|
||||
/** Whether data is loading */
|
||||
isLoading?: boolean;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TokenAnalysisPanel - Comprehensive token analysis display
|
||||
*
|
||||
* Features:
|
||||
* - AI-generated summary with recommendation
|
||||
* - Contract analysis (verified, renounced, proxy)
|
||||
* - Holder distribution analysis
|
||||
* - Liquidity analysis with LP lock status
|
||||
* - Volume trends and trading activity
|
||||
* - Price history and volatility
|
||||
* - Social sentiment analysis
|
||||
*/
|
||||
export function TokenAnalysisPanel({
|
||||
analysis,
|
||||
onRefresh,
|
||||
onViewFullReport,
|
||||
isLoading = false,
|
||||
className,
|
||||
}: TokenAnalysisPanelProps) {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await onRefresh?.();
|
||||
setTimeout(() => setIsRefreshing(false), 1000);
|
||||
};
|
||||
|
||||
const formatTimeAgo = (date: Date) => {
|
||||
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
return `${Math.floor(minutes / 60)}h ago`;
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
if (value >= 1000000) return `$${(value / 1000000).toFixed(2)}M`;
|
||||
if (value >= 1000) return `$${(value / 1000).toFixed(1)}K`;
|
||||
return `$${value.toFixed(0)}`;
|
||||
};
|
||||
|
||||
const getRecommendationColor = (rec: string) => {
|
||||
switch (rec) {
|
||||
case "buy": return "text-green-600 dark:text-green-400";
|
||||
case "hold": return "text-yellow-600 dark:text-yellow-400";
|
||||
case "sell": return "text-orange-600 dark:text-orange-400";
|
||||
case "avoid": return "text-red-600 dark:text-red-400";
|
||||
default: return "text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
const getSentimentEmoji = (sentiment: string) => {
|
||||
switch (sentiment) {
|
||||
case "positive": return "😊";
|
||||
case "negative": return "😟";
|
||||
case "neutral": return "😐";
|
||||
default: return "🤔";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="font-semibold">Token Analysis</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{analysis.tokenSymbol} • <ChainIcon chain={analysis.chain} size="xs" className="inline" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* AI Summary */}
|
||||
<div className="p-4 bg-primary/5 rounded-lg border border-primary/20">
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<div className="text-lg">🤖</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-sm mb-1">AI Summary</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{analysis.aiSummary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommendation */}
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-primary/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Recommendation:</span>
|
||||
<span className={cn("font-bold text-sm uppercase", getRecommendationColor(analysis.recommendation))}>
|
||||
{analysis.recommendation}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">Confidence:</span>
|
||||
<span className="font-semibold text-sm">{analysis.confidence}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contract Analysis */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-semibold text-sm">Contract</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded">
|
||||
{analysis.contract.verified ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
<span className="text-xs">Verified</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded">
|
||||
{analysis.contract.renounced ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
<span className="text-xs">Renounced</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded">
|
||||
{!analysis.contract.isProxy ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600" />
|
||||
)}
|
||||
<span className="text-xs">{analysis.contract.isProxy ? "Proxy" : "Direct"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded">
|
||||
{analysis.contract.sourceCode ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
<span className="text-xs">Source Code</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Holder Distribution */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-semibold text-sm">Holders</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-2 bg-muted/50 rounded">
|
||||
<span className="text-xs text-muted-foreground">Total Holders</span>
|
||||
<span className="font-semibold text-sm">{analysis.holders.count.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 bg-muted/50 rounded">
|
||||
<span className="text-xs text-muted-foreground">Top 10 Holdings</span>
|
||||
<span className={cn(
|
||||
"font-semibold text-sm",
|
||||
analysis.holders.top10Percent > 50 ? "text-red-600" :
|
||||
analysis.holders.top10Percent > 30 ? "text-yellow-600" :
|
||||
"text-green-600"
|
||||
)}>
|
||||
{analysis.holders.top10Percent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liquidity */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Droplet className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-semibold text-sm">Liquidity</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-2 bg-muted/50 rounded">
|
||||
<span className="text-xs text-muted-foreground">Total Liquidity</span>
|
||||
<span className="font-semibold text-sm">{formatCurrency(analysis.liquidity.totalUSD)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 bg-muted/50 rounded">
|
||||
<span className="text-xs text-muted-foreground">LP Lock Status</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{analysis.liquidity.lpLocked ? (
|
||||
<>
|
||||
<CheckCircle className="h-3 w-3 text-green-600" />
|
||||
<span className="font-semibold text-xs text-green-600">
|
||||
{analysis.liquidity.lpLockDuration ? `${analysis.liquidity.lpLockDuration}d` : "Locked"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-3 w-3 text-red-600" />
|
||||
<span className="font-semibold text-xs text-red-600">Unlocked</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume & Price */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-xs text-muted-foreground">Volume 24h</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-lg">{formatCurrency(analysis.volume.volume24h)}</span>
|
||||
{analysis.volume.trend === "increasing" ? (
|
||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||
) : analysis.volume.trend === "decreasing" ? (
|
||||
<TrendingDown className="h-4 w-4 text-red-600" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-xs text-muted-foreground">Price</h3>
|
||||
<div className="space-y-1">
|
||||
<div className="font-bold text-lg">${analysis.price.current.toFixed(8)}</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className={cn(
|
||||
analysis.price.change7d >= 0 ? "text-green-600" : "text-red-600"
|
||||
)}>
|
||||
7d: {analysis.price.change7d >= 0 ? "+" : ""}{analysis.price.change7d.toFixed(1)}%
|
||||
</span>
|
||||
<span className={cn(
|
||||
analysis.price.change30d >= 0 ? "text-green-600" : "text-red-600"
|
||||
)}>
|
||||
30d: {analysis.price.change30d >= 0 ? "+" : ""}{analysis.price.change30d.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Sentiment */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-semibold text-sm">Social Sentiment</h3>
|
||||
</div>
|
||||
<div className="p-3 bg-muted/50 rounded">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
{getSentimentEmoji(analysis.social.sentiment)} {analysis.social.sentiment.charAt(0).toUpperCase() + analysis.social.sentiment.slice(1)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Score: {(analysis.social.sentimentScore * 100).toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-muted-foreground">Twitter</div>
|
||||
<div className="font-semibold">{analysis.social.twitterMentions}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Telegram</div>
|
||||
<div className="font-semibold">{analysis.social.telegramActivity}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Reddit</div>
|
||||
<div className="font-semibold">{analysis.social.redditDiscussions}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Updated */}
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
Last updated: {formatTimeAgo(analysis.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t p-3">
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={onViewFullReport}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
View Full Report
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Target,
|
||||
AlertCircle,
|
||||
Info,
|
||||
DollarSign,
|
||||
Percent,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface TradingSuggestion {
|
||||
tokenAddress: string;
|
||||
tokenSymbol: string;
|
||||
tokenName: string;
|
||||
chain: string;
|
||||
currentPrice: number;
|
||||
timestamp: Date;
|
||||
|
||||
entry: {
|
||||
min: number;
|
||||
max: number;
|
||||
reasoning: string;
|
||||
};
|
||||
|
||||
targets: {
|
||||
level: number;
|
||||
price: number;
|
||||
percentGain: number;
|
||||
confidence: number;
|
||||
}[];
|
||||
|
||||
stopLoss: {
|
||||
price: number;
|
||||
percentLoss: number;
|
||||
reasoning: string;
|
||||
};
|
||||
|
||||
riskReward: number;
|
||||
overallConfidence: number;
|
||||
|
||||
technicalLevels: {
|
||||
support: number[];
|
||||
resistance: number[];
|
||||
};
|
||||
|
||||
reasoning: string[];
|
||||
invalidationConditions: string[];
|
||||
}
|
||||
|
||||
export interface TradingSuggestionPanelProps {
|
||||
/** Trading suggestion data */
|
||||
suggestion: TradingSuggestion;
|
||||
/** Callback when "Set Alerts" is clicked */
|
||||
onSetAlerts?: () => void;
|
||||
/** Callback when "View Chart" is clicked */
|
||||
onViewChart?: () => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TradingSuggestionPanel - AI-powered entry/exit suggestions
|
||||
*
|
||||
* Features:
|
||||
* - Entry zone recommendations
|
||||
* - Multiple take-profit targets
|
||||
* - Stop-loss suggestions
|
||||
* - Risk/reward ratio calculation
|
||||
* - Technical analysis levels
|
||||
* - AI reasoning and invalidation conditions
|
||||
*/
|
||||
export function TradingSuggestionPanel({
|
||||
suggestion,
|
||||
onSetAlerts,
|
||||
onViewChart,
|
||||
className,
|
||||
}: TradingSuggestionPanelProps) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
if (price < 0.01) return `$${price.toFixed(8)}`;
|
||||
if (price < 1) return `$${price.toFixed(6)}`;
|
||||
return `$${price.toFixed(4)}`;
|
||||
};
|
||||
|
||||
const getRiskRewardColor = (ratio: number) => {
|
||||
if (ratio >= 3) return "text-green-600 dark:text-green-400";
|
||||
if (ratio >= 2) return "text-yellow-600 dark:text-yellow-400";
|
||||
return "text-red-600 dark:text-red-400";
|
||||
};
|
||||
|
||||
const getRiskRewardLabel = (ratio: number) => {
|
||||
if (ratio >= 3) return "Excellent";
|
||||
if (ratio >= 2) return "Good";
|
||||
if (ratio >= 1.5) return "Fair";
|
||||
return "Poor";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="font-semibold">Trading Suggestion</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{suggestion.tokenSymbol} • <ChainIcon chain={suggestion.chain} size="xs" className="inline" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-muted-foreground">Confidence</div>
|
||||
<div className="font-bold text-sm">{suggestion.overallConfidence}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Current Price */}
|
||||
<div className="p-3 bg-muted/50 rounded-lg">
|
||||
<div className="text-xs text-muted-foreground mb-1">Current Price</div>
|
||||
<div className="font-bold text-2xl">{formatPrice(suggestion.currentPrice)}</div>
|
||||
</div>
|
||||
|
||||
{/* Entry Zone */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
<h3 className="font-semibold text-sm">Entry Zone</h3>
|
||||
</div>
|
||||
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-bold text-lg text-green-600 dark:text-green-400">
|
||||
{formatPrice(suggestion.entry.min)} - {formatPrice(suggestion.entry.max)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{suggestion.entry.reasoning}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Targets */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-semibold text-sm">Take Profit Targets</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{suggestion.targets.map((target) => (
|
||||
<div
|
||||
key={target.level}
|
||||
className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-semibold text-sm">🎯 Target {target.level}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Confidence: {target.confidence}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-blue-600 dark:text-blue-400">
|
||||
{formatPrice(target.price)}
|
||||
</span>
|
||||
<span className="font-semibold text-sm text-green-600 dark:text-green-400">
|
||||
+{target.percentGain.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stop Loss */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<h3 className="font-semibold text-sm">Stop Loss</h3>
|
||||
</div>
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-bold text-lg text-red-600 dark:text-red-400">
|
||||
{formatPrice(suggestion.stopLoss.price)}
|
||||
</span>
|
||||
<span className="font-semibold text-sm text-red-600 dark:text-red-400">
|
||||
{suggestion.stopLoss.percentLoss.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{suggestion.stopLoss.reasoning}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk/Reward */}
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Risk/Reward Ratio</span>
|
||||
<span className={cn("font-bold text-lg", getRiskRewardColor(suggestion.riskReward))}>
|
||||
1:{suggestion.riskReward.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"px-2 py-1 rounded text-xs font-medium",
|
||||
suggestion.riskReward >= 3 ? "bg-green-500/20 text-green-600 dark:text-green-400" :
|
||||
suggestion.riskReward >= 2 ? "bg-yellow-500/20 text-yellow-600 dark:text-yellow-400" :
|
||||
"bg-red-500/20 text-red-600 dark:text-red-400"
|
||||
)}>
|
||||
{getRiskRewardLabel(suggestion.riskReward)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Why? Section */}
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
>
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-semibold text-sm">Why?</h3>
|
||||
<div className={cn(
|
||||
"ml-auto transition-transform",
|
||||
showDetails && "rotate-180"
|
||||
)}>
|
||||
▼
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{showDetails && (
|
||||
<div className="space-y-3 pl-6">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-muted-foreground mb-1">Reasoning:</h4>
|
||||
<ul className="space-y-1">
|
||||
{suggestion.reasoning.map((reason, i) => (
|
||||
<li key={i} className="text-xs flex items-start gap-2">
|
||||
<span className="text-green-600 dark:text-green-400">•</span>
|
||||
<span>{reason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-muted-foreground mb-1">Invalidation Conditions:</h4>
|
||||
<ul className="space-y-1">
|
||||
{suggestion.invalidationConditions.map((condition, i) => (
|
||||
<li key={i} className="text-xs flex items-start gap-2">
|
||||
<AlertCircle className="h-3 w-3 text-red-600 dark:text-red-400 mt-0.5" />
|
||||
<span>{condition}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="border-t p-3 space-y-2">
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={onSetAlerts}
|
||||
>
|
||||
Set Alerts for These Levels
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={onViewChart}
|
||||
>
|
||||
View Chart
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
Minus,
|
||||
ArrowRight,
|
||||
Type,
|
||||
Circle,
|
||||
Square,
|
||||
TrendingUp,
|
||||
Eraser,
|
||||
Undo,
|
||||
Redo,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
|
||||
export type AnnotationType = "line" | "arrow" | "text" | "circle" | "rectangle" | "fibonacci";
|
||||
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
type: AnnotationType;
|
||||
coordinates: { x: number; y: number }[];
|
||||
text?: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface AnnotationToolsProps {
|
||||
/** Callback when annotation is added */
|
||||
onAnnotationAdd?: (annotation: Annotation) => void;
|
||||
/** Callback when annotation is removed */
|
||||
onAnnotationRemove?: (id: string) => void;
|
||||
/** Callback when undo is clicked */
|
||||
onUndo?: () => void;
|
||||
/** Callback when redo is clicked */
|
||||
onRedo?: () => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AnnotationTools - Drawing tools for chart annotations
|
||||
*
|
||||
* Features:
|
||||
* - Line tool for trend lines, support/resistance
|
||||
* - Arrow tool for directional indicators
|
||||
* - Text tool for labels
|
||||
* - Shape tools (circle, rectangle)
|
||||
* - Fibonacci retracement tool
|
||||
* - Color picker
|
||||
* - Undo/Redo functionality
|
||||
*/
|
||||
export function AnnotationTools({
|
||||
onAnnotationAdd,
|
||||
onAnnotationRemove,
|
||||
onUndo,
|
||||
onRedo,
|
||||
className,
|
||||
}: AnnotationToolsProps) {
|
||||
const [selectedTool, setSelectedTool] = useState<AnnotationType | null>(null);
|
||||
const [selectedColor, setSelectedColor] = useState("#3b82f6"); // blue-500
|
||||
|
||||
const tools: { type: AnnotationType; icon: any; label: string }[] = [
|
||||
{ type: "line", icon: Minus, label: "Line" },
|
||||
{ type: "arrow", icon: ArrowRight, label: "Arrow" },
|
||||
{ type: "text", icon: Type, label: "Text" },
|
||||
{ type: "circle", icon: Circle, label: "Circle" },
|
||||
{ type: "rectangle", icon: Square, label: "Rectangle" },
|
||||
{ type: "fibonacci", icon: TrendingUp, label: "Fibonacci" },
|
||||
];
|
||||
|
||||
const colors = [
|
||||
{ value: "#3b82f6", label: "Blue" },
|
||||
{ value: "#ef4444", label: "Red" },
|
||||
{ value: "#22c55e", label: "Green" },
|
||||
{ value: "#eab308", label: "Yellow" },
|
||||
{ value: "#a855f7", label: "Purple" },
|
||||
{ value: "#ffffff", label: "White" },
|
||||
];
|
||||
|
||||
const handleToolSelect = (tool: AnnotationType) => {
|
||||
setSelectedTool(tool === selectedTool ? null : tool);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{/* Drawing Tools */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">Drawing Tools</h4>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{tools.map(({ type, icon: Icon, label }) => (
|
||||
<button
|
||||
key={type}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 p-2 rounded border transition-colors",
|
||||
selectedTool === type
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-muted hover:bg-muted/80"
|
||||
)}
|
||||
onClick={() => handleToolSelect(type)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="text-xs">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Picker */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">Color</h4>
|
||||
<div className="flex gap-2">
|
||||
{colors.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full border-2 transition-all",
|
||||
selectedColor === value
|
||||
? "border-primary scale-110"
|
||||
: "border-muted hover:scale-105"
|
||||
)}
|
||||
style={{ backgroundColor: value }}
|
||||
onClick={() => setSelectedColor(value)}
|
||||
title={label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={onUndo}
|
||||
>
|
||||
<Undo className="h-3 w-3 mr-1" />
|
||||
Undo
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={onRedo}
|
||||
>
|
||||
<Redo className="h-3 w-3 mr-1" />
|
||||
Redo
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedTool(null)}
|
||||
>
|
||||
<Eraser className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
{selectedTool && (
|
||||
<div className="p-2 bg-muted/50 rounded text-xs text-muted-foreground">
|
||||
{selectedTool === "line" && "Click and drag to draw a line"}
|
||||
{selectedTool === "arrow" && "Click and drag to draw an arrow"}
|
||||
{selectedTool === "text" && "Click to add text label"}
|
||||
{selectedTool === "circle" && "Click and drag to draw a circle"}
|
||||
{selectedTool === "rectangle" && "Click and drag to draw a rectangle"}
|
||||
{selectedTool === "fibonacci" && "Click two points to draw Fibonacci retracement"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
Camera,
|
||||
Download,
|
||||
Copy,
|
||||
Twitter,
|
||||
MessageSquare,
|
||||
Image as ImageIcon,
|
||||
Palette,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { AnnotationTools } from "./AnnotationTools";
|
||||
|
||||
export interface ChartCaptureMetadata {
|
||||
tokenSymbol: string;
|
||||
tokenName: string;
|
||||
price: number;
|
||||
change24h: number;
|
||||
volume: number;
|
||||
liquidity: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface ChartCaptureSettings {
|
||||
style: "dark" | "light" | "neon";
|
||||
includeTokenInfo: boolean;
|
||||
includePriceChange: boolean;
|
||||
includeVolumeLiquidity: boolean;
|
||||
includeTimestamp: boolean;
|
||||
includeWatermark: boolean;
|
||||
}
|
||||
|
||||
export interface ChartCapturePanelProps {
|
||||
/** Current token metadata */
|
||||
metadata?: ChartCaptureMetadata;
|
||||
/** Callback when capture is clicked */
|
||||
onCapture?: () => void;
|
||||
/** Callback when export is clicked */
|
||||
onExport?: (format: "twitter" | "telegram" | "instagram" | "clipboard") => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ChartCapturePanel - Chart screenshot tool with annotations
|
||||
*
|
||||
* Features:
|
||||
* - One-click chart capture from DexScreener
|
||||
* - Auto-add metadata overlay (token info, price, volume, etc.)
|
||||
* - Drawing tools (lines, arrows, text, shapes, Fibonacci)
|
||||
* - Template styles (dark, light, neon)
|
||||
* - Export options (Twitter, Telegram, Instagram, clipboard)
|
||||
*/
|
||||
export function ChartCapturePanel({
|
||||
metadata,
|
||||
onCapture,
|
||||
onExport,
|
||||
className,
|
||||
}: ChartCapturePanelProps) {
|
||||
const [settings, setSettings] = useState<ChartCaptureSettings>({
|
||||
style: "dark",
|
||||
includeTokenInfo: true,
|
||||
includePriceChange: true,
|
||||
includeVolumeLiquidity: true,
|
||||
includeTimestamp: true,
|
||||
includeWatermark: false,
|
||||
});
|
||||
|
||||
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
|
||||
const handleCapture = async () => {
|
||||
setIsCapturing(true);
|
||||
await onCapture?.();
|
||||
// Mock: simulate capture
|
||||
setTimeout(() => {
|
||||
setCapturedImage("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
|
||||
setIsCapturing(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleExport = (format: "twitter" | "telegram" | "instagram" | "clipboard") => {
|
||||
onExport?.(format);
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
if (value >= 1000000) return `$${(value / 1000000).toFixed(2)}M`;
|
||||
if (value >= 1000) return `$${(value / 1000).toFixed(1)}K`;
|
||||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Camera className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="font-semibold">Chart Capture</h2>
|
||||
{metadata && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{metadata.tokenSymbol}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Capture Button */}
|
||||
{!capturedImage && (
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={handleCapture}
|
||||
disabled={isCapturing}
|
||||
>
|
||||
<Camera className="h-4 w-4 mr-2" />
|
||||
{isCapturing ? "Capturing..." : "Capture Chart"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{capturedImage && (
|
||||
<div className="space-y-3">
|
||||
<div className="relative border rounded-lg overflow-hidden bg-muted/50">
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt="Captured chart"
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
{/* Metadata Overlay Preview */}
|
||||
{metadata && settings.includeTokenInfo && (
|
||||
<div className="absolute top-2 left-2 bg-background/90 backdrop-blur-sm p-2 rounded text-xs">
|
||||
<div className="font-bold">{metadata.tokenSymbol}</div>
|
||||
{settings.includePriceChange && (
|
||||
<div className={cn(
|
||||
"font-semibold",
|
||||
metadata.change24h >= 0 ? "text-green-600" : "text-red-600"
|
||||
)}>
|
||||
${metadata.price.toFixed(6)} ({metadata.change24h >= 0 ? "+" : ""}{metadata.change24h.toFixed(2)}%)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Annotation Tools */}
|
||||
<AnnotationTools />
|
||||
|
||||
{/* Recapture Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setCapturedImage(null)}
|
||||
>
|
||||
<Camera className="h-4 w-4 mr-2" />
|
||||
Recapture
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Style Selection */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-semibold text-sm">Style</h3>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(["dark", "light", "neon"] as const).map((style) => (
|
||||
<button
|
||||
key={style}
|
||||
className={cn(
|
||||
"flex-1 p-2 rounded border text-xs font-medium transition-colors",
|
||||
settings.style === style
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-muted hover:bg-muted/80"
|
||||
)}
|
||||
onClick={() => setSettings({ ...settings, style })}
|
||||
>
|
||||
{style.charAt(0).toUpperCase() + style.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Options */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-semibold text-sm">Metadata</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ key: "includeTokenInfo" as const, label: "Token info" },
|
||||
{ key: "includePriceChange" as const, label: "Price & change" },
|
||||
{ key: "includeVolumeLiquidity" as const, label: "Volume & liquidity" },
|
||||
{ key: "includeTimestamp" as const, label: "Timestamp" },
|
||||
{ key: "includeWatermark" as const, label: "Watermark" },
|
||||
].map(({ key, label }) => (
|
||||
<label key={key} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings[key]}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, [key]: e.target.checked })
|
||||
}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer - Export Options */}
|
||||
{capturedImage && (
|
||||
<div className="border-t p-3 space-y-2">
|
||||
<h3 className="font-semibold text-xs text-muted-foreground mb-2">Export</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport("twitter")}
|
||||
>
|
||||
<Twitter className="h-3 w-3 mr-1" />
|
||||
Twitter
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport("telegram")}
|
||||
>
|
||||
<MessageSquare className="h-3 w-3 mr-1" />
|
||||
Telegram
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport("instagram")}
|
||||
>
|
||||
<ImageIcon className="h-3 w-3 mr-1" />
|
||||
Instagram
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport("clipboard")}
|
||||
>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={() => handleExport("clipboard")}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Save to File
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
283
surfsense_browser_extension/sidepanel/chat/ChatHeader.tsx
Normal file
283
surfsense_browser_extension/sidepanel/chat/ChatHeader.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Settings,
|
||||
ChevronDown,
|
||||
User,
|
||||
LogOut,
|
||||
ExternalLink,
|
||||
Star,
|
||||
Bell,
|
||||
MessageSquare,
|
||||
Plug,
|
||||
Search,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/routes/ui/popover";
|
||||
|
||||
export interface SearchSpace {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ChatHeaderProps {
|
||||
/** Available search spaces */
|
||||
searchSpaces?: SearchSpace[];
|
||||
/** Currently selected search space */
|
||||
selectedSpace?: SearchSpace;
|
||||
/** Callback when search space is changed */
|
||||
onSpaceChange?: (space: SearchSpace) => void;
|
||||
/** User display name */
|
||||
userName?: string;
|
||||
/** User avatar URL */
|
||||
userAvatar?: string;
|
||||
/** Callback when logout is clicked */
|
||||
onLogout?: () => void;
|
||||
/** Callback when settings item is clicked */
|
||||
onSettingsClick?: (item: string) => void;
|
||||
/** Callback when token search is triggered */
|
||||
onTokenSearch?: (query: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced Chat header with branding, token search, space selector, settings, and user menu
|
||||
*
|
||||
* Features:
|
||||
* - Universal token search bar (works on any page)
|
||||
* - Search space selector dropdown
|
||||
* - Settings dropdown with full menu
|
||||
* - User avatar with logout option
|
||||
*/
|
||||
export function ChatHeader({
|
||||
searchSpaces = [],
|
||||
selectedSpace,
|
||||
onSpaceChange,
|
||||
userName,
|
||||
userAvatar,
|
||||
onLogout,
|
||||
onSettingsClick,
|
||||
onTokenSearch,
|
||||
}: ChatHeaderProps) {
|
||||
const [spaceOpen, setSpaceOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const defaultSpaces: SearchSpace[] = [
|
||||
{ id: "crypto", name: "Crypto", icon: "🪙" },
|
||||
{ id: "general", name: "General", icon: "📚" },
|
||||
{ id: "research", name: "Research", icon: "🔬" },
|
||||
];
|
||||
|
||||
const spaces = searchSpaces.length > 0 ? searchSpaces : defaultSpaces;
|
||||
const currentSpace = selectedSpace || spaces[0];
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim() && onTokenSearch) {
|
||||
onTokenSearch(searchQuery.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
{/* Top row: Logo, Space Selector, Settings */}
|
||||
<div className="flex items-center justify-between p-3 pb-2">
|
||||
{/* Logo and brand */}
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src="/assets/icon.png"
|
||||
alt="SurfSense"
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
<h1 className="font-semibold text-base">SurfSense</h1>
|
||||
</div>
|
||||
|
||||
{/* Search Space Selector */}
|
||||
<Popover open={spaceOpen} onOpenChange={setSpaceOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1 px-2"
|
||||
>
|
||||
<span>{currentSpace.icon}</span>
|
||||
<span className="max-w-[80px] truncate">{currentSpace.name}</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-1" align="center">
|
||||
<div className="space-y-0.5">
|
||||
{spaces.map((space) => (
|
||||
<button
|
||||
key={space.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors",
|
||||
currentSpace.id === space.id
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
onClick={() => {
|
||||
onSpaceChange?.(space);
|
||||
setSpaceOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{space.icon}</span>
|
||||
<span>{space.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Settings Dropdown */}
|
||||
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-1" align="end">
|
||||
<SettingsMenu
|
||||
onItemClick={(item) => {
|
||||
onSettingsClick?.(item);
|
||||
setSettingsOpen(false);
|
||||
}}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* User Avatar */}
|
||||
<UserAvatar
|
||||
name={userName}
|
||||
avatarUrl={userAvatar}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom row: Token Search Bar */}
|
||||
<div className="px-3 pb-2">
|
||||
<form onSubmit={handleSearch} className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search token (symbol, name, or address)..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full h-8 pl-9 pr-8 text-sm rounded-md border border-input bg-background/50 focus:bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 h-5 w-5 flex items-center justify-center rounded-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings menu items
|
||||
*/
|
||||
function SettingsMenu({
|
||||
onItemClick,
|
||||
onLogout,
|
||||
}: {
|
||||
onItemClick?: (item: string) => void;
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
const menuItems = [
|
||||
{ id: "connectors", label: "Manage Connectors", icon: Plug },
|
||||
{ id: "chats", label: "View All Chats", icon: MessageSquare },
|
||||
{ id: "watchlist", label: "Manage Watchlist", icon: Star },
|
||||
{ id: "alerts", label: "Alert History", icon: Bell },
|
||||
{ id: "settings", label: "Full Settings", icon: Settings, external: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{menuItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-muted transition-colors"
|
||||
onClick={() => onItemClick?.(item.id)}
|
||||
>
|
||||
<item.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1 text-left">{item.label}</span>
|
||||
{item.external && <ExternalLink className="h-3 w-3 opacity-50" />}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="my-1 border-t" />
|
||||
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-destructive/10 text-destructive transition-colors"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* User avatar component
|
||||
*/
|
||||
function UserAvatar({
|
||||
name,
|
||||
avatarUrl,
|
||||
onLogout,
|
||||
}: {
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
const initials = name
|
||||
? name.split(" ").map(n => n[0]).join("").toUpperCase().slice(0, 2)
|
||||
: "U";
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center overflow-hidden hover:ring-2 hover:ring-primary/20 transition-all">
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt={name || "User"} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs font-medium text-primary">{initials}</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-2" align="end">
|
||||
<div className="text-center pb-2 border-b mb-2">
|
||||
<p className="font-medium text-sm">{name || "User"}</p>
|
||||
</div>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-destructive/10 text-destructive transition-colors"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
256
surfsense_browser_extension/sidepanel/chat/ChatInput.tsx
Normal file
256
surfsense_browser_extension/sidepanel/chat/ChatInput.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { useState, useRef } from "react";
|
||||
import { Send, Paperclip, X, FileText, Image, File } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export interface AttachedFile {
|
||||
/** File ID */
|
||||
id: string;
|
||||
/** File name */
|
||||
name: string;
|
||||
/** File type */
|
||||
type: string;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** File object */
|
||||
file: File;
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
/** Callback when message is sent */
|
||||
onSend: (content: string, attachments?: AttachedFile[]) => void;
|
||||
/** Whether input is disabled */
|
||||
disabled?: boolean;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Whether to show attachment button */
|
||||
showAttachment?: boolean;
|
||||
/** Accepted file types */
|
||||
acceptedFileTypes?: string;
|
||||
/** Max file size in bytes (default 10MB) */
|
||||
maxFileSize?: number;
|
||||
/** Quick action suggestions */
|
||||
suggestions?: string[];
|
||||
/** Callback when suggestion is clicked */
|
||||
onSuggestionClick?: (suggestion: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced chat input with attachment support and suggestions
|
||||
*
|
||||
* Features:
|
||||
* - Text input with send button
|
||||
* - File attachment button
|
||||
* - Attached files preview
|
||||
* - Quick action suggestions
|
||||
* - Keyboard shortcuts (Enter to send)
|
||||
*/
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder,
|
||||
showAttachment = true,
|
||||
acceptedFileTypes = ".pdf,.txt,.md,.json,.csv,image/*",
|
||||
maxFileSize = 10 * 1024 * 1024, // 10MB
|
||||
suggestions = [],
|
||||
onSuggestionClick,
|
||||
}: ChatInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [attachments, setAttachments] = useState<AttachedFile[]>([]);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if ((input.trim() || attachments.length > 0) && !disabled) {
|
||||
onSend(input.trim(), attachments.length > 0 ? attachments : undefined);
|
||||
setInput("");
|
||||
setAttachments([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
|
||||
const newAttachments: AttachedFile[] = [];
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
// Check file size
|
||||
if (file.size > maxFileSize) {
|
||||
console.warn(`File ${file.name} exceeds max size of ${maxFileSize / 1024 / 1024}MB`);
|
||||
return;
|
||||
}
|
||||
|
||||
newAttachments.push({
|
||||
id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
file,
|
||||
});
|
||||
});
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
};
|
||||
|
||||
const handleRemoveAttachment = (id: string) => {
|
||||
setAttachments(prev => prev.filter(a => a.id !== id));
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
if (type.startsWith("image/")) return Image;
|
||||
if (type.includes("pdf") || type.includes("text")) return FileText;
|
||||
return File;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t">
|
||||
{/* Quick suggestions */}
|
||||
{suggestions.length > 0 && input.length === 0 && attachments.length === 0 && (
|
||||
<div className="px-3 pt-2 flex gap-2 flex-wrap">
|
||||
{suggestions.slice(0, 3).map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="text-xs px-2 py-1 rounded-full bg-muted hover:bg-muted/80 text-muted-foreground transition-colors"
|
||||
onClick={() => onSuggestionClick?.(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attached files preview */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="px-3 pt-2 flex gap-2 flex-wrap">
|
||||
{attachments.map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.type);
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted text-sm group"
|
||||
>
|
||||
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="max-w-[100px] truncate">{attachment.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({formatFileSize(attachment.size)})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveAttachment(attachment.id)}
|
||||
className="ml-1 p-0.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input form */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={cn(
|
||||
"p-3 transition-colors",
|
||||
dragOver && "bg-primary/5"
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
{/* Attachment button */}
|
||||
{showAttachment && (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={acceptedFileTypes}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Text input */}
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder || "Type a message..."}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-md bg-background text-sm",
|
||||
"focus:outline-none focus:ring-2 focus:ring-primary",
|
||||
"resize-none min-h-[38px] max-h-[120px]",
|
||||
"scrollbar-thin scrollbar-thumb-muted"
|
||||
)}
|
||||
style={{
|
||||
height: "auto",
|
||||
minHeight: "38px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Send button */}
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
disabled={disabled || (!input.trim() && attachments.length === 0)}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Drag hint */}
|
||||
{dragOver && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 rounded-md border-2 border-dashed border-primary pointer-events-none">
|
||||
<p className="text-sm text-primary font-medium">Drop files here</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
648
surfsense_browser_extension/sidepanel/chat/ChatInterface.tsx
Normal file
648
surfsense_browser_extension/sidepanel/chat/ChatInterface.tsx
Normal file
|
|
@ -0,0 +1,648 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { usePageContext } from "../context/PageContextProvider";
|
||||
import { TokenInfoCard } from "../dexscreener/TokenInfoCard";
|
||||
import { QuickCapture } from "./QuickCapture";
|
||||
import { ChatHeader, type SearchSpace } from "./ChatHeader";
|
||||
import { ChatMessages, type Message, type MessageWidget } from "./ChatMessages";
|
||||
import { ChatInput, type AttachedFile } from "./ChatInput";
|
||||
import { ThinkingStepsDisplay, type ThinkingStep } from "./ThinkingStepsDisplay";
|
||||
import {
|
||||
MOCK_MODE,
|
||||
MOCK_SEARCH_SPACES,
|
||||
MOCK_WATCHLIST_TOKENS,
|
||||
MOCK_WATCHLIST_ALERTS,
|
||||
MOCK_SAFETY_SCORE,
|
||||
MOCK_SAFETY_FACTORS,
|
||||
MOCK_SAFETY_SOURCES,
|
||||
MOCK_WHALE_TRANSACTIONS,
|
||||
MOCK_TRADING_SUGGESTION,
|
||||
MOCK_PORTFOLIO,
|
||||
} from "../mock/mockData";
|
||||
import { SafetyScoreDisplay } from "../crypto/SafetyScoreDisplay";
|
||||
import { WatchlistPanel } from "../crypto/WatchlistPanel";
|
||||
import { AlertConfigModal } from "../crypto/AlertConfigModal";
|
||||
import { DetectedTokensList } from "../components/DetectedTokensList";
|
||||
import { useContextAction, getMessageForAction } from "../hooks/useContextAction";
|
||||
import { useKeyboardShortcuts, getMessageForKeyboardAction } from "../hooks/useKeyboardShortcuts";
|
||||
import type { WatchlistItem } from "../widgets";
|
||||
import type { TokenData } from "../context/PageContextProvider";
|
||||
|
||||
type ViewMode = "chat" | "watchlist" | "safety";
|
||||
|
||||
/**
|
||||
* Natural language command patterns for conversational UX
|
||||
*/
|
||||
const COMMAND_PATTERNS = {
|
||||
// Epic 1: Basic commands
|
||||
ADD_WATCHLIST: /add\s+(\w+)\s+to\s+(my\s+)?watchlist/i,
|
||||
REMOVE_WATCHLIST: /remove\s+(\w+)\s+from\s+(my\s+)?watchlist/i,
|
||||
SHOW_WATCHLIST: /(show|display|view)\s+(my\s+)?watchlist/i,
|
||||
SET_ALERT: /set\s+alert\s+(if|when)\s+(\w+)\s+(drops?|pumps?|reaches?|changes?)\s+(\d+)%?/i,
|
||||
ANALYZE_TOKEN: /(analyze|research|check)\s+(\w+)/i,
|
||||
SAFETY_CHECK: /(is\s+)?(\w+)\s+(safe|risky|rug)/i,
|
||||
|
||||
// Epic 2: Smart Monitoring & Alerts
|
||||
SHOW_WHALE_ACTIVITY: /(show|display|view)\s+(whale|large)\s+(activity|transactions|trades)/i,
|
||||
|
||||
// Epic 3: Trading Intelligence
|
||||
TRADING_SUGGESTION: /(suggest|recommend|entry|exit|trade)\s+(for\s+)?(\w+)/i,
|
||||
SHOW_PORTFOLIO: /(show|display|view)\s+(my\s+)?portfolio/i,
|
||||
|
||||
// Epic 4: Content Creation & Productivity
|
||||
CAPTURE_CHART: /(capture|screenshot|snap|grab)\s+(chart|graph)/i,
|
||||
GENERATE_THREAD: /(generate|create|write)\s+(thread|tweet)/i,
|
||||
};
|
||||
|
||||
/**
|
||||
* Main chat interface for side panel
|
||||
* Adapts UI based on page context (e.g., shows token card on DexScreener)
|
||||
*
|
||||
* Features:
|
||||
* - Context-aware UI (DexScreener token detection)
|
||||
* - Welcome screen for new users
|
||||
* - Thinking steps visualization
|
||||
* - File attachments support
|
||||
* - Search space selection
|
||||
* - Watchlist panel
|
||||
* - Safety analysis view
|
||||
*/
|
||||
export function ChatInterface() {
|
||||
const { context, isMockMode } = usePageContext();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [thinkingSteps, setThinkingSteps] = useState<ThinkingStep[]>([]);
|
||||
const [selectedSpace, setSelectedSpace] = useState<SearchSpace>(
|
||||
MOCK_SEARCH_SPACES[0]
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("chat");
|
||||
const [showAlertModal, setShowAlertModal] = useState(false);
|
||||
const [selectedTokenForAlert, setSelectedTokenForAlert] = useState<string | null>(null);
|
||||
const [watchlistTokens, setWatchlistTokens] = useState(MOCK_WATCHLIST_TOKENS);
|
||||
const [isInWatchlist, setIsInWatchlist] = useState(false);
|
||||
|
||||
// Context menu action hook
|
||||
const { pendingAction, clearAction } = useContextAction();
|
||||
|
||||
// Keyboard shortcuts hook
|
||||
const { pendingAction: pendingKeyboardAction, clearAction: clearKeyboardAction } = useKeyboardShortcuts();
|
||||
|
||||
// Mock user data - in production, this would come from auth context
|
||||
const userName = "Crypto Trader";
|
||||
|
||||
// Handle context menu actions
|
||||
useEffect(() => {
|
||||
if (pendingAction) {
|
||||
const message = getMessageForAction(pendingAction);
|
||||
if (message) {
|
||||
// Auto-send the message
|
||||
handleSendMessage(message);
|
||||
}
|
||||
clearAction();
|
||||
}
|
||||
}, [pendingAction, clearAction]);
|
||||
|
||||
// Handle keyboard shortcut actions
|
||||
useEffect(() => {
|
||||
if (pendingKeyboardAction) {
|
||||
const message = getMessageForKeyboardAction(pendingKeyboardAction);
|
||||
if (message) {
|
||||
// Auto-send the message
|
||||
handleSendMessage(message);
|
||||
}
|
||||
clearKeyboardAction();
|
||||
}
|
||||
}, [pendingKeyboardAction, clearKeyboardAction]);
|
||||
|
||||
const handleSendMessage = async (content: string, attachments?: AttachedFile[]) => {
|
||||
console.log("Sending message:", content, attachments);
|
||||
setIsStreaming(true);
|
||||
setViewMode("chat");
|
||||
|
||||
// Add user message
|
||||
const userMessage: Message = {
|
||||
id: `msg-${Date.now()}`,
|
||||
role: "user",
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
// Simulate thinking steps
|
||||
setThinkingSteps([
|
||||
{ id: "1", type: "thinking", title: "Understanding your question...", isActive: true },
|
||||
]);
|
||||
|
||||
setTimeout(() => {
|
||||
setThinkingSteps([
|
||||
{ id: "1", type: "thinking", title: "Understanding your question...", isComplete: true },
|
||||
{ id: "2", type: "searching", title: "Searching knowledge base...", isActive: true },
|
||||
]);
|
||||
}, 500);
|
||||
|
||||
setTimeout(() => {
|
||||
setThinkingSteps([
|
||||
{ id: "1", type: "thinking", title: "Understanding your question...", isComplete: true },
|
||||
{ id: "2", type: "searching", title: "Searching knowledge base...", isComplete: true },
|
||||
{ id: "3", type: "analyzing", title: "Analyzing results...", isActive: true },
|
||||
]);
|
||||
}, 1000);
|
||||
|
||||
// Generate response based on content - with embedded widgets
|
||||
setTimeout(() => {
|
||||
setThinkingSteps([]);
|
||||
|
||||
let responseContent = "";
|
||||
let widget: MessageWidget | undefined;
|
||||
const tokenSymbol = context?.tokenData?.tokenSymbol || "BULLA";
|
||||
|
||||
// Check for natural language commands
|
||||
const addWatchlistMatch = content.match(COMMAND_PATTERNS.ADD_WATCHLIST);
|
||||
const showWatchlistMatch = content.match(COMMAND_PATTERNS.SHOW_WATCHLIST);
|
||||
const setAlertMatch = content.match(COMMAND_PATTERNS.SET_ALERT);
|
||||
const showWhaleActivityMatch = content.match(COMMAND_PATTERNS.SHOW_WHALE_ACTIVITY);
|
||||
const tradingSuggestionMatch = content.match(COMMAND_PATTERNS.TRADING_SUGGESTION);
|
||||
const showPortfolioMatch = content.match(COMMAND_PATTERNS.SHOW_PORTFOLIO);
|
||||
const captureChartMatch = content.match(COMMAND_PATTERNS.CAPTURE_CHART);
|
||||
const generateThreadMatch = content.match(COMMAND_PATTERNS.GENERATE_THREAD);
|
||||
|
||||
if (addWatchlistMatch || (content.toLowerCase().includes("add") && content.toLowerCase().includes("watchlist"))) {
|
||||
// Add to watchlist command
|
||||
const token = addWatchlistMatch?.[1] || tokenSymbol;
|
||||
responseContent = `Done! ✅\n\nI've added ${token} to your watchlist.`;
|
||||
widget = {
|
||||
type: "action_confirmation",
|
||||
actionType: "watchlist_add",
|
||||
tokenSymbol: token,
|
||||
details: [
|
||||
"Price change ±20%",
|
||||
"Liquidity drop >10%",
|
||||
"Whale movement >$50K",
|
||||
],
|
||||
};
|
||||
// Actually add to watchlist
|
||||
if (!watchlistTokens.find(t => t.symbol === token)) {
|
||||
const newToken = {
|
||||
id: `token-${Date.now()}`,
|
||||
symbol: token,
|
||||
name: token + " Token",
|
||||
chain: context?.tokenData?.chain || "solana",
|
||||
contractAddress: context?.tokenData?.pairAddress || "unknown",
|
||||
price: context?.tokenData?.price || "$0.00001234",
|
||||
priceChange24h: 156.7,
|
||||
hasAlerts: true,
|
||||
alertCount: 3,
|
||||
};
|
||||
setWatchlistTokens(prev => [...prev, newToken]);
|
||||
setIsInWatchlist(true);
|
||||
}
|
||||
} else if (showWatchlistMatch || content.toLowerCase().includes("watchlist") && (content.toLowerCase().includes("show") || content.toLowerCase().includes("view"))) {
|
||||
// Show watchlist command
|
||||
responseContent = `Here's your watchlist:`;
|
||||
const watchlistItems: WatchlistItem[] = watchlistTokens.map(t => ({
|
||||
id: t.id,
|
||||
symbol: t.symbol,
|
||||
name: t.name,
|
||||
chain: t.chain,
|
||||
price: t.price,
|
||||
priceChange24h: t.priceChange24h,
|
||||
alertCount: t.alertCount,
|
||||
}));
|
||||
widget = {
|
||||
type: "watchlist",
|
||||
tokens: watchlistItems,
|
||||
};
|
||||
if (watchlistTokens.length > 0) {
|
||||
const bestPerformer = watchlistTokens.reduce((a, b) =>
|
||||
a.priceChange24h > b.priceChange24h ? a : b
|
||||
);
|
||||
responseContent += `\n\n${bestPerformer.symbol} is up ${bestPerformer.priceChange24h.toFixed(1)}% - your best performer! Want me to analyze if it's time to take profits?`;
|
||||
}
|
||||
} else if (setAlertMatch || content.toLowerCase().includes("alert") && (content.toLowerCase().includes("set") || content.toLowerCase().includes("notify"))) {
|
||||
// Set alert command
|
||||
const match = content.match(/(\d+)%/);
|
||||
const percentage = match ? match[1] : "20";
|
||||
const direction = content.toLowerCase().includes("drop") ? "drops" : "changes";
|
||||
responseContent = `I'll set that up for you:`;
|
||||
widget = {
|
||||
type: "alert_config",
|
||||
config: {
|
||||
tokenSymbol: tokenSymbol,
|
||||
condition: `Price ${direction} ${percentage}%`,
|
||||
currentPrice: context?.tokenData?.price || "$0.00001234",
|
||||
triggerPrice: "$0.00000987",
|
||||
channels: {
|
||||
browser: true,
|
||||
inApp: true,
|
||||
email: false,
|
||||
},
|
||||
},
|
||||
isNew: true,
|
||||
};
|
||||
responseContent += `\n\nDone! I'll notify you if ${tokenSymbol} ${direction} ${percentage}% from current price. Want to set any other alerts?`;
|
||||
} else if (content.toLowerCase().includes("safe") || content.toLowerCase().includes("rug") || content.toLowerCase().includes("analyze") || content.toLowerCase().includes("research")) {
|
||||
// Token analysis with embedded widget
|
||||
responseContent = `Here's my analysis of ${tokenSymbol}:`;
|
||||
widget = {
|
||||
type: "token_analysis",
|
||||
data: {
|
||||
symbol: tokenSymbol,
|
||||
name: context?.tokenData?.tokenName || "Bulla Token",
|
||||
chain: context?.tokenData?.chain || "solana",
|
||||
price: context?.tokenData?.price || "$0.00001234",
|
||||
priceChange24h: 156.7,
|
||||
marketCap: "$2.1M",
|
||||
volume24h: "$1.2M",
|
||||
liquidity: "$450K",
|
||||
safetyScore: MOCK_SAFETY_SCORE,
|
||||
holderCount: 12456,
|
||||
top10HolderPercent: 35,
|
||||
},
|
||||
isInWatchlist: isInWatchlist,
|
||||
};
|
||||
responseContent += `\n\nBased on your moderate risk profile, suggested allocation: 2-5% of portfolio. The safety score of ${MOCK_SAFETY_SCORE}/100 indicates medium risk - proceed with caution.`;
|
||||
} else if (showWhaleActivityMatch || (content.toLowerCase().includes("whale") && (content.toLowerCase().includes("show") || content.toLowerCase().includes("activity")))) {
|
||||
// Show whale activity command (Epic 2)
|
||||
responseContent = `Here's recent whale activity for ${tokenSymbol}:`;
|
||||
widget = {
|
||||
type: "whale_activity",
|
||||
transactions: MOCK_WHALE_TRANSACTIONS.slice(0, 5), // Show top 5
|
||||
};
|
||||
responseContent += `\n\n🐋 I'm tracking 5 large transactions (>$10K) in the last hour. The smart money wallet 0x742d...35BA just bought $50K worth - this could be a bullish signal!`;
|
||||
} else if (tradingSuggestionMatch || (content.toLowerCase().includes("suggest") || content.toLowerCase().includes("entry") || content.toLowerCase().includes("exit"))) {
|
||||
// Trading suggestion command (Epic 3)
|
||||
const token = tradingSuggestionMatch?.[3] || tokenSymbol;
|
||||
responseContent = `Here's my trading analysis for ${token}:`;
|
||||
widget = {
|
||||
type: "trading_suggestion",
|
||||
suggestion: MOCK_TRADING_SUGGESTION,
|
||||
};
|
||||
responseContent += `\n\n📊 Based on technical analysis, I've identified optimal entry zones and profit targets. The risk/reward ratio of 1:3.3 looks favorable. Would you like me to set price alerts for these levels?`;
|
||||
} else if (showPortfolioMatch || (content.toLowerCase().includes("portfolio") && (content.toLowerCase().includes("show") || content.toLowerCase().includes("view")))) {
|
||||
// Show portfolio command (Epic 3)
|
||||
responseContent = `Here's your portfolio overview:`;
|
||||
widget = {
|
||||
type: "portfolio",
|
||||
portfolio: MOCK_PORTFOLIO,
|
||||
};
|
||||
responseContent += `\n\n💼 Your portfolio is up $234 (4.7%) in the last 24 hours! BULLA is your best performer at +15%. Want me to analyze if it's time to take profits?`;
|
||||
} else if (captureChartMatch || (content.toLowerCase().includes("capture") || content.toLowerCase().includes("screenshot")) && content.toLowerCase().includes("chart")) {
|
||||
// Capture chart command (Epic 4)
|
||||
responseContent = `I'll help you capture this chart:`;
|
||||
widget = {
|
||||
type: "chart_capture",
|
||||
metadata: {
|
||||
tokenSymbol: tokenSymbol,
|
||||
tokenName: context?.tokenData?.tokenName || "Bulla Token",
|
||||
chain: context?.tokenData?.chain || "solana",
|
||||
price: context?.tokenData?.price || "$0.00001234",
|
||||
priceChange24h: 156.7,
|
||||
volume24h: context?.tokenData?.volume24h || "$1.2M",
|
||||
liquidity: context?.tokenData?.liquidity || "$450K",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
};
|
||||
responseContent += `\n\n📸 I've prepared the chart capture tool with auto-filled metadata. Choose your style (dark/light/neon) and export format (Twitter/Telegram/Instagram). The metadata overlay will make your chart look professional!`;
|
||||
} else if (generateThreadMatch || (content.toLowerCase().includes("generate") || content.toLowerCase().includes("create")) && content.toLowerCase().includes("thread")) {
|
||||
// Generate thread command (Epic 4)
|
||||
responseContent = `I'll help you create a Twitter thread about ${tokenSymbol}:`;
|
||||
widget = {
|
||||
type: "thread_generator",
|
||||
tokenAddress: context?.tokenData?.pairAddress,
|
||||
tokenSymbol: tokenSymbol,
|
||||
chain: context?.tokenData?.chain || "solana",
|
||||
};
|
||||
responseContent += `\n\n🧵 I've prepared the thread generator with token info auto-filled. Choose your tone (bullish/neutral/bearish) and thread length (5-10 tweets). I'll generate a professional thread structure: Hook → Analysis → Implications → Conclusion. You can edit each tweet before posting!`;
|
||||
} else if (content.toLowerCase().includes("holder")) {
|
||||
responseContent = `**Holder Analysis for ${tokenSymbol}:**
|
||||
|
||||
📊 **Distribution:**
|
||||
- Total Holders: 12,456
|
||||
- Top 10 Holders: 35% of supply
|
||||
- Top 50 Holders: 52% of supply
|
||||
|
||||
🐋 **Whale Activity (24h):**
|
||||
- 3 large buys (>$10K each)
|
||||
- 1 large sell ($25K)
|
||||
- Net flow: +$15K
|
||||
|
||||
⚠️ **Concentration Risk:** Medium
|
||||
The top holder owns 8.5% which is relatively high.`;
|
||||
} else {
|
||||
responseContent = `I can help you with crypto analysis! Try these commands:
|
||||
|
||||
**Epic 1: Basic Commands**
|
||||
• **"Add BULLA to my watchlist"** - Track tokens
|
||||
• **"Show my watchlist"** - View tracked tokens
|
||||
• **"Set alert if BULLA drops 20%"** - Price alerts
|
||||
• **"Analyze BULLA"** - Full token analysis
|
||||
• **"Is BULLA safe?"** - Safety check
|
||||
|
||||
**Epic 2: Smart Monitoring**
|
||||
• **"Show whale activity"** - Large transactions (>$10K)
|
||||
|
||||
**Epic 3: Trading Intelligence**
|
||||
• **"Suggest entry for BULLA"** - Entry/exit suggestions
|
||||
• **"Show my portfolio"** - Portfolio tracker with P&L
|
||||
|
||||
**Epic 4: Content Creation**
|
||||
• **"Capture chart"** - Screenshot with metadata
|
||||
• **"Generate thread"** - AI Twitter thread generator
|
||||
|
||||
What would you like to know?`;
|
||||
}
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: `msg-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: responseContent,
|
||||
timestamp: new Date(),
|
||||
widget,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
setIsStreaming(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (text: string) => {
|
||||
handleSendMessage(text);
|
||||
};
|
||||
|
||||
const handleSpaceChange = (space: SearchSpace) => {
|
||||
setSelectedSpace(space);
|
||||
};
|
||||
|
||||
const handleSettingsClick = (item: string) => {
|
||||
console.log("Settings item clicked:", item);
|
||||
if (item === "watchlist") {
|
||||
setViewMode("watchlist");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
console.log("Logout clicked");
|
||||
};
|
||||
|
||||
const handleSafetyCheck = () => {
|
||||
setViewMode("safety");
|
||||
};
|
||||
|
||||
const handleAddToWatchlist = () => {
|
||||
setIsInWatchlist(!isInWatchlist);
|
||||
if (!isInWatchlist && context?.tokenData) {
|
||||
const newToken = {
|
||||
id: `token-${Date.now()}`,
|
||||
symbol: context.tokenData.tokenSymbol || "TOKEN",
|
||||
name: context.tokenData.tokenName || "Unknown Token",
|
||||
chain: context.tokenData.chain,
|
||||
contractAddress: context.tokenData.pairAddress,
|
||||
price: context.tokenData.price || "$0",
|
||||
priceChange24h: context.tokenData.priceChange24h || 0,
|
||||
hasAlerts: false,
|
||||
};
|
||||
setWatchlistTokens(prev => [...prev, newToken]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigureAlerts = (tokenSymbol: string) => {
|
||||
setSelectedTokenForAlert(tokenSymbol);
|
||||
setShowAlertModal(true);
|
||||
};
|
||||
|
||||
const handleRugCheck = () => {
|
||||
handleSendMessage("Check this token for rug pull risks");
|
||||
};
|
||||
|
||||
// Handle widget actions from embedded widgets in chat
|
||||
const handleWidgetAction = (action: string, data?: unknown) => {
|
||||
console.log("Widget action:", action, data);
|
||||
switch (action) {
|
||||
case "view_watchlist":
|
||||
handleSendMessage("Show my watchlist");
|
||||
break;
|
||||
case "edit_alerts":
|
||||
if (typeof data === "string") {
|
||||
handleConfigureAlerts(data);
|
||||
}
|
||||
break;
|
||||
case "analyze_token":
|
||||
if (data && typeof data === "object" && "symbol" in data) {
|
||||
handleSendMessage(`Analyze ${(data as { symbol: string }).symbol}`);
|
||||
}
|
||||
break;
|
||||
case "remove_from_watchlist":
|
||||
if (typeof data === "string") {
|
||||
setWatchlistTokens(prev => prev.filter(t => t.id !== data));
|
||||
}
|
||||
break;
|
||||
case "add_to_watchlist":
|
||||
if (data && typeof data === "object" && "symbol" in data) {
|
||||
handleSendMessage(`Add ${(data as { symbol: string }).symbol} to my watchlist`);
|
||||
}
|
||||
break;
|
||||
case "set_alert":
|
||||
if (typeof data === "string") {
|
||||
handleConfigureAlerts(data);
|
||||
}
|
||||
break;
|
||||
case "analyze_further":
|
||||
if (data && typeof data === "object" && "symbol" in data) {
|
||||
handleSendMessage(`Tell me more about ${(data as { symbol: string }).symbol} holders and whale activity`);
|
||||
}
|
||||
break;
|
||||
case "tell_more":
|
||||
if (data && typeof data === "object" && "tokenSymbol" in data) {
|
||||
handleSendMessage(`Tell me more about ${(data as { tokenSymbol: string }).tokenSymbol}`);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.log("Unhandled widget action:", action);
|
||||
}
|
||||
};
|
||||
|
||||
// Quick suggestions based on context
|
||||
const quickSuggestions = context?.pageType === "dexscreener"
|
||||
? ["Add to watchlist", "Is this safe?", "Set price alert"]
|
||||
: ["Show my watchlist", "What's trending?", "Analyze BULLA"];
|
||||
|
||||
// Handle token search from header
|
||||
const handleTokenSearch = async (query: string) => {
|
||||
// Add user message
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: "user",
|
||||
content: `Analyze ${query}`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
// Simulate AI response with token analysis
|
||||
setTimeout(() => {
|
||||
const aiMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: "assistant",
|
||||
content: `Searching for token: ${query}...`,
|
||||
timestamp: new Date(),
|
||||
widget: {
|
||||
type: "token_analysis",
|
||||
data: {
|
||||
symbol: query.toUpperCase(),
|
||||
name: `${query} Token`,
|
||||
chain: "solana",
|
||||
price: "$0.00001234",
|
||||
priceChange24h: 156.7,
|
||||
marketCap: "$2.1M",
|
||||
volume24h: "$1.2M",
|
||||
liquidity: "$450K",
|
||||
safetyScore: MOCK_SAFETY_SCORE,
|
||||
holderCount: 12456,
|
||||
top10HolderPercent: 35,
|
||||
},
|
||||
isInWatchlist: false,
|
||||
},
|
||||
};
|
||||
setMessages((prev) => [...prev, aiMessage]);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle detected token click
|
||||
*/
|
||||
const handleDetectedTokenClick = (token: TokenData) => {
|
||||
const query = token.tokenSymbol || token.pairAddress;
|
||||
handleTokenSearch(query);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header with space selector and settings */}
|
||||
<ChatHeader
|
||||
searchSpaces={MOCK_SEARCH_SPACES}
|
||||
selectedSpace={selectedSpace}
|
||||
onSpaceChange={handleSpaceChange}
|
||||
userName={userName}
|
||||
onSettingsClick={handleSettingsClick}
|
||||
onLogout={handleLogout}
|
||||
onTokenSearch={handleTokenSearch}
|
||||
/>
|
||||
|
||||
{/* Token info card (only on DexScreener) */}
|
||||
{context?.pageType === "dexscreener" && context.tokenData && viewMode === "chat" && (
|
||||
<TokenInfoCard
|
||||
tokenData={context.tokenData}
|
||||
isInWatchlist={isInWatchlist}
|
||||
onAddToWatchlist={handleAddToWatchlist}
|
||||
onSafetyCheck={handleSafetyCheck}
|
||||
onRugCheck={handleRugCheck}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Detected tokens list (on Twitter and other pages) */}
|
||||
{context?.detectedTokens && context.detectedTokens.length > 0 && viewMode === "chat" && (
|
||||
<DetectedTokensList
|
||||
tokens={context.detectedTokens}
|
||||
onTokenClick={handleDetectedTokenClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{viewMode === "chat" && (
|
||||
<>
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
userName={userName}
|
||||
onWidgetAction={handleWidgetAction}
|
||||
/>
|
||||
|
||||
{/* Thinking steps (shown during streaming) */}
|
||||
{isStreaming && thinkingSteps.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<ThinkingStepsDisplay
|
||||
steps={thinkingSteps}
|
||||
isThinking={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{viewMode === "watchlist" && (
|
||||
<WatchlistPanel
|
||||
tokens={watchlistTokens}
|
||||
recentAlerts={MOCK_WATCHLIST_ALERTS}
|
||||
onTokenClick={(token) => console.log("Token clicked:", token)}
|
||||
onRemoveToken={(id) => setWatchlistTokens(prev => prev.filter(t => t.id !== id))}
|
||||
onAddToken={() => console.log("Add token clicked")}
|
||||
onConfigureAlerts={(token) => handleConfigureAlerts(token.symbol)}
|
||||
onAlertClick={(alert) => console.log("Alert clicked:", alert)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === "safety" && (
|
||||
<div className="p-4">
|
||||
<SafetyScoreDisplay
|
||||
score={MOCK_SAFETY_SCORE}
|
||||
factors={MOCK_SAFETY_FACTORS}
|
||||
sources={MOCK_SAFETY_SOURCES}
|
||||
timestamp={new Date()}
|
||||
tokenSymbol={context?.tokenData?.tokenSymbol}
|
||||
isInWatchlist={isInWatchlist}
|
||||
onAddToWatchlist={handleAddToWatchlist}
|
||||
onSetAlert={() => handleConfigureAlerts(context?.tokenData?.tokenSymbol || "TOKEN")}
|
||||
/>
|
||||
<button
|
||||
className="mt-4 text-sm text-primary hover:underline"
|
||||
onClick={() => setViewMode("chat")}
|
||||
>
|
||||
← Back to chat
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat input and quick capture (only in chat mode) */}
|
||||
{viewMode === "chat" && (
|
||||
<div className="flex-shrink-0">
|
||||
<ChatInput
|
||||
onSend={handleSendMessage}
|
||||
disabled={isStreaming}
|
||||
placeholder={
|
||||
context?.pageType === "dexscreener"
|
||||
? `Ask about ${context.tokenData?.tokenSymbol || "this token"}...`
|
||||
: "Ask me anything..."
|
||||
}
|
||||
suggestions={messages.length === 0 ? [] : quickSuggestions}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
/>
|
||||
<QuickCapture />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back to chat button for other views */}
|
||||
{viewMode !== "chat" && (
|
||||
<div className="flex-shrink-0 border-t p-3">
|
||||
<button
|
||||
className="w-full py-2 text-sm text-center text-primary hover:bg-primary/5 rounded-md transition-colors"
|
||||
onClick={() => setViewMode("chat")}
|
||||
>
|
||||
← Back to Chat
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alert configuration modal */}
|
||||
<AlertConfigModal
|
||||
open={showAlertModal}
|
||||
onOpenChange={setShowAlertModal}
|
||||
tokenSymbol={selectedTokenForAlert || "TOKEN"}
|
||||
currentPrice={context?.tokenData?.price}
|
||||
onSave={(alerts) => {
|
||||
console.log("Alerts saved:", alerts);
|
||||
setShowAlertModal(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
surfsense_browser_extension/sidepanel/chat/ChatMessages.tsx
Normal file
235
surfsense_browser_extension/sidepanel/chat/ChatMessages.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { WelcomeScreen } from "./WelcomeScreen";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
ActionConfirmationWidget,
|
||||
ProactiveAlertCard,
|
||||
WatchlistWidget,
|
||||
AlertWidget,
|
||||
TokenAnalysisWidget,
|
||||
WhaleActivityWidget,
|
||||
TradingSuggestionWidget,
|
||||
PortfolioWidget,
|
||||
ChartCaptureWidget,
|
||||
ThreadGeneratorWidget,
|
||||
type ProactiveAlertData,
|
||||
type WatchlistItem,
|
||||
type AlertConfigData,
|
||||
type TokenAnalysisData,
|
||||
} from "../widgets";
|
||||
import type { WhaleTransaction } from "../whale/WhaleActivityFeed";
|
||||
import type { TradingSuggestion } from "../analysis/TradingSuggestionPanel";
|
||||
import type { PortfolioData } from "../portfolio/PortfolioPanel";
|
||||
import type { ChartCaptureMetadata } from "../capture/ChartCapturePanel";
|
||||
|
||||
// Widget types that can be embedded in messages
|
||||
export type MessageWidget =
|
||||
| { type: "action_confirmation"; actionType: "watchlist_add" | "watchlist_remove" | "alert_set" | "alert_delete"; tokenSymbol: string; details?: string[] }
|
||||
| { type: "proactive_alert"; alert: ProactiveAlertData; recommendation?: string }
|
||||
| { type: "watchlist"; tokens: WatchlistItem[] }
|
||||
| { type: "alert_config"; config: AlertConfigData; isNew?: boolean }
|
||||
| { type: "token_analysis"; data: TokenAnalysisData; isInWatchlist?: boolean }
|
||||
| { type: "whale_activity"; transactions: WhaleTransaction[] }
|
||||
| { type: "trading_suggestion"; suggestion: TradingSuggestion }
|
||||
| { type: "portfolio"; portfolio: PortfolioData }
|
||||
| { type: "chart_capture"; metadata?: ChartCaptureMetadata }
|
||||
| { type: "thread_generator"; tokenAddress?: string; tokenSymbol?: string; chain?: string };
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp?: Date;
|
||||
isStreaming?: boolean;
|
||||
/** Embedded widget to display with this message */
|
||||
widget?: MessageWidget;
|
||||
}
|
||||
|
||||
export interface ChatMessagesProps {
|
||||
messages: Message[];
|
||||
onSuggestionClick?: (text: string) => void;
|
||||
userName?: string;
|
||||
/** Callbacks for widget interactions */
|
||||
onWidgetAction?: (action: string, data?: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat messages display component with embedded widget support
|
||||
* Shows WelcomeScreen when no messages, otherwise displays conversation
|
||||
*
|
||||
* Supports embedded widgets for conversational UX:
|
||||
* - ActionConfirmationWidget: Shows action confirmations
|
||||
* - ProactiveAlertCard: AI-initiated alerts
|
||||
* - WatchlistWidget: Inline watchlist display
|
||||
* - AlertWidget: Alert configuration display
|
||||
* - TokenAnalysisWidget: Full token analysis
|
||||
* - WhaleActivityWidget: Whale transaction feed (Epic 2)
|
||||
* - TradingSuggestionWidget: Entry/exit suggestions (Epic 3)
|
||||
* - PortfolioWidget: Portfolio tracker (Epic 3)
|
||||
* - ChartCaptureWidget: Chart screenshot tool (Epic 4)
|
||||
* - ThreadGeneratorWidget: Twitter thread generator (Epic 4)
|
||||
*/
|
||||
export function ChatMessages({
|
||||
messages,
|
||||
onSuggestionClick,
|
||||
userName,
|
||||
onWidgetAction,
|
||||
}: ChatMessagesProps) {
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<WelcomeScreen
|
||||
userName={userName}
|
||||
onSuggestionClick={onSuggestionClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleWidgetAction = (action: string, data?: unknown) => {
|
||||
onWidgetAction?.(action, data);
|
||||
};
|
||||
|
||||
const renderWidget = (widget: MessageWidget) => {
|
||||
switch (widget.type) {
|
||||
case "action_confirmation":
|
||||
return (
|
||||
<ActionConfirmationWidget
|
||||
actionType={widget.actionType}
|
||||
tokenSymbol={widget.tokenSymbol}
|
||||
details={widget.details}
|
||||
onViewWatchlist={() => handleWidgetAction("view_watchlist")}
|
||||
onEditAlerts={() => handleWidgetAction("edit_alerts", widget.tokenSymbol)}
|
||||
/>
|
||||
);
|
||||
case "proactive_alert":
|
||||
return (
|
||||
<ProactiveAlertCard
|
||||
alert={widget.alert}
|
||||
recommendation={widget.recommendation}
|
||||
onViewDetails={() => handleWidgetAction("view_alert_details", widget.alert)}
|
||||
onDismiss={() => handleWidgetAction("dismiss_alert", widget.alert.id)}
|
||||
onSetAlert={() => handleWidgetAction("set_alert", widget.alert.tokenSymbol)}
|
||||
onTellMore={() => handleWidgetAction("tell_more", widget.alert)}
|
||||
/>
|
||||
);
|
||||
case "watchlist":
|
||||
return (
|
||||
<WatchlistWidget
|
||||
tokens={widget.tokens}
|
||||
onAnalyze={(token) => handleWidgetAction("analyze_token", token)}
|
||||
onRemove={(id) => handleWidgetAction("remove_from_watchlist", id)}
|
||||
onAddToken={() => handleWidgetAction("add_token")}
|
||||
onClearAll={() => handleWidgetAction("clear_watchlist")}
|
||||
/>
|
||||
);
|
||||
case "alert_config":
|
||||
return (
|
||||
<AlertWidget
|
||||
config={widget.config}
|
||||
isNew={widget.isNew}
|
||||
onEdit={() => handleWidgetAction("edit_alert", widget.config)}
|
||||
onDelete={() => handleWidgetAction("delete_alert", widget.config)}
|
||||
onAddAnother={() => handleWidgetAction("add_another_alert")}
|
||||
onViewAll={() => handleWidgetAction("view_all_alerts")}
|
||||
/>
|
||||
);
|
||||
case "token_analysis":
|
||||
return (
|
||||
<TokenAnalysisWidget
|
||||
data={widget.data}
|
||||
isInWatchlist={widget.isInWatchlist}
|
||||
onAddToWatchlist={() => handleWidgetAction("add_to_watchlist", widget.data)}
|
||||
onSetAlert={() => handleWidgetAction("set_alert", widget.data.symbol)}
|
||||
onAnalyzeFurther={() => handleWidgetAction("analyze_further", widget.data)}
|
||||
/>
|
||||
);
|
||||
case "whale_activity":
|
||||
return (
|
||||
<WhaleActivityWidget
|
||||
transactions={widget.transactions}
|
||||
onTrackWallet={(address) => handleWidgetAction("track_wallet", address)}
|
||||
onViewTransaction={(txHash) => handleWidgetAction("view_transaction", txHash)}
|
||||
/>
|
||||
);
|
||||
case "trading_suggestion":
|
||||
return (
|
||||
<TradingSuggestionWidget
|
||||
suggestion={widget.suggestion}
|
||||
onSetAlerts={() => handleWidgetAction("set_alerts", widget.suggestion)}
|
||||
onViewChart={() => handleWidgetAction("view_chart", widget.suggestion)}
|
||||
/>
|
||||
);
|
||||
case "portfolio":
|
||||
return (
|
||||
<PortfolioWidget
|
||||
portfolio={widget.portfolio}
|
||||
onRefresh={() => handleWidgetAction("refresh_portfolio")}
|
||||
onAnalyzeToken={(holding) => handleWidgetAction("analyze_token", holding)}
|
||||
onSetAlert={(holding) => handleWidgetAction("set_alert", holding)}
|
||||
onViewToken={(holding) => handleWidgetAction("view_token", holding)}
|
||||
onAddPosition={() => handleWidgetAction("add_position")}
|
||||
/>
|
||||
);
|
||||
case "chart_capture":
|
||||
return (
|
||||
<ChartCaptureWidget
|
||||
metadata={widget.metadata}
|
||||
onCapture={() => handleWidgetAction("capture_chart")}
|
||||
onExport={(format) => handleWidgetAction("export_chart", format)}
|
||||
/>
|
||||
);
|
||||
case "thread_generator":
|
||||
return (
|
||||
<ThreadGeneratorWidget
|
||||
tokenAddress={widget.tokenAddress}
|
||||
tokenSymbol={widget.tokenSymbol}
|
||||
chain={widget.chain}
|
||||
onGenerate={(request) => handleWidgetAction("generate_thread", request)}
|
||||
onExport={(format) => handleWidgetAction("export_thread", format)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
message.role === "user" ? "items-end" : "items-start"
|
||||
)}
|
||||
>
|
||||
{/* Message bubble */}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-lg p-3",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted",
|
||||
message.isStreaming && "animate-pulse"
|
||||
)}
|
||||
>
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
{message.timestamp && (
|
||||
<p className="text-xs opacity-60 mt-1">
|
||||
{message.timestamp.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Embedded widget (for assistant messages) */}
|
||||
{message.role === "assistant" && message.widget && (
|
||||
<div className="w-full max-w-[95%] mt-2">
|
||||
{renderWidget(message.widget)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
surfsense_browser_extension/sidepanel/chat/QuickCapture.tsx
Normal file
49
surfsense_browser_extension/sidepanel/chat/QuickCapture.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Camera } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { useToast } from "@/routes/ui/use-toast";
|
||||
|
||||
/**
|
||||
* Quick capture button (sticky at bottom)
|
||||
* Reuses existing capture functionality
|
||||
*/
|
||||
export function QuickCapture() {
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleCapture = async () => {
|
||||
try {
|
||||
// Get active tab
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
|
||||
if (!tab.id) {
|
||||
throw new Error("No active tab");
|
||||
}
|
||||
|
||||
// Send message to background to capture page
|
||||
chrome.runtime.sendMessage({
|
||||
type: "CAPTURE_PAGE",
|
||||
tabId: tab.id,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Page captured!",
|
||||
description: "Saved to your search space",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to capture page:", error);
|
||||
toast({
|
||||
title: "Capture failed",
|
||||
description: "Please try again",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t p-3 bg-background">
|
||||
<Button className="w-full" variant="outline" onClick={handleCapture}>
|
||||
<Camera className="mr-2 h-4 w-4" />
|
||||
Save Current Page
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Brain,
|
||||
Search,
|
||||
FileText,
|
||||
Lightbulb,
|
||||
CheckCircle,
|
||||
Loader2
|
||||
} from "lucide-react";
|
||||
|
||||
export type ThinkingStepType = "thinking" | "searching" | "reading" | "analyzing" | "complete";
|
||||
|
||||
export interface ThinkingStep {
|
||||
/** Step ID */
|
||||
id: string;
|
||||
/** Step type for icon selection */
|
||||
type: ThinkingStepType;
|
||||
/** Step title/label */
|
||||
title: string;
|
||||
/** Step description or content */
|
||||
content?: string;
|
||||
/** Whether step is currently active */
|
||||
isActive?: boolean;
|
||||
/** Whether step is complete */
|
||||
isComplete?: boolean;
|
||||
/** Timestamp */
|
||||
timestamp?: Date;
|
||||
}
|
||||
|
||||
export interface ThinkingStepsDisplayProps {
|
||||
/** List of thinking steps */
|
||||
steps: ThinkingStep[];
|
||||
/** Whether AI is currently thinking */
|
||||
isThinking?: boolean;
|
||||
/** Whether to show expanded by default */
|
||||
defaultExpanded?: boolean;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STEP_ICONS: Record<ThinkingStepType, typeof Brain> = {
|
||||
thinking: Brain,
|
||||
searching: Search,
|
||||
reading: FileText,
|
||||
analyzing: Lightbulb,
|
||||
complete: CheckCircle,
|
||||
};
|
||||
|
||||
const STEP_COLORS: Record<ThinkingStepType, string> = {
|
||||
thinking: "text-purple-500",
|
||||
searching: "text-blue-500",
|
||||
reading: "text-green-500",
|
||||
analyzing: "text-orange-500",
|
||||
complete: "text-green-600",
|
||||
};
|
||||
|
||||
/**
|
||||
* ThinkingStepsDisplay - Shows AI reasoning process
|
||||
*
|
||||
* Features:
|
||||
* - Collapsible thinking steps
|
||||
* - Step-specific icons and colors
|
||||
* - Active step animation
|
||||
* - Expandable step details
|
||||
*/
|
||||
export function ThinkingStepsDisplay({
|
||||
steps,
|
||||
isThinking = false,
|
||||
defaultExpanded = true,
|
||||
className,
|
||||
}: ThinkingStepsDisplayProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
if (steps.length === 0 && !isThinking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeStep = steps.find(s => s.isActive);
|
||||
const completedSteps = steps.filter(s => s.isComplete).length;
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border bg-muted/30", className)}>
|
||||
{/* Header - clickable to expand/collapse */}
|
||||
<button
|
||||
className="w-full flex items-center gap-2 p-3 text-left hover:bg-muted/50 transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
<Brain className={cn(
|
||||
"h-4 w-4",
|
||||
isThinking ? "text-purple-500 animate-pulse" : "text-muted-foreground"
|
||||
)} />
|
||||
|
||||
<span className="flex-1 text-sm font-medium">
|
||||
{isThinking ? "Thinking..." : "Thought Process"}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completedSteps}/{steps.length} steps
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Steps list */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
{steps.map((step, index) => (
|
||||
<StepItem key={step.id} step={step} index={index} />
|
||||
))}
|
||||
|
||||
{/* Active thinking indicator */}
|
||||
{isThinking && !activeStep && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-purple-500/10">
|
||||
<Loader2 className="h-4 w-4 text-purple-500 animate-spin" />
|
||||
<span className="text-sm text-purple-600 dark:text-purple-400">
|
||||
Processing...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual step item
|
||||
*/
|
||||
function StepItem({ step, index }: { step: ThinkingStep; index: number }) {
|
||||
const [isDetailExpanded, setIsDetailExpanded] = useState(false);
|
||||
const Icon = STEP_ICONS[step.type];
|
||||
const colorClass = STEP_COLORS[step.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md transition-colors",
|
||||
step.isActive && "bg-primary/5 ring-1 ring-primary/20",
|
||||
step.isComplete && "opacity-80"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex items-start gap-2 p-2 cursor-pointer"
|
||||
onClick={() => step.content && setIsDetailExpanded(!isDetailExpanded)}
|
||||
>
|
||||
{/* Step number or icon */}
|
||||
<div className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0",
|
||||
step.isActive ? "bg-primary/10" : "bg-muted"
|
||||
)}>
|
||||
{step.isActive ? (
|
||||
<Loader2 className={cn("h-3 w-3 animate-spin", colorClass)} />
|
||||
) : step.isComplete ? (
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Icon className={cn("h-3 w-3", colorClass)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn(
|
||||
"text-sm",
|
||||
step.isActive && "font-medium"
|
||||
)}>
|
||||
{step.title}
|
||||
</p>
|
||||
|
||||
{/* Expandable detail */}
|
||||
{step.content && isDetailExpanded && (
|
||||
<p className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap">
|
||||
{step.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expand indicator */}
|
||||
{step.content && (
|
||||
<ChevronRight className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform",
|
||||
isDetailExpanded && "rotate-90"
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
surfsense_browser_extension/sidepanel/chat/WelcomeScreen.tsx
Normal file
113
surfsense_browser_extension/sidepanel/chat/WelcomeScreen.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { useMemo } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { SuggestionCard, DEFAULT_CRYPTO_SUGGESTIONS } from "../components/shared";
|
||||
|
||||
export interface WelcomeScreenProps {
|
||||
/** User's display name for personalized greeting */
|
||||
userName?: string;
|
||||
/** Callback when a suggestion is clicked */
|
||||
onSuggestionClick?: (text: string) => void;
|
||||
/** Custom suggestions (overrides defaults) */
|
||||
suggestions?: Array<{ text: string; type: "general" | "safety" | "trending" | "wallet" | "custom" }>;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time-based greeting message
|
||||
*/
|
||||
function getTimeBasedGreeting(userName?: string): string {
|
||||
const hour = new Date().getHours();
|
||||
|
||||
// Greeting variations for each time period
|
||||
const morningGreetings = ["Good morning", "Fresh start today", "Morning"];
|
||||
const afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there"];
|
||||
const eveningGreetings = ["Good evening", "Evening", "Hey there"];
|
||||
const nightGreetings = ["Good night", "Evening", "Winding down"];
|
||||
const lateNightGreetings = ["Still up?", "Night owl mode", "Burning the midnight oil"];
|
||||
|
||||
let greeting: string;
|
||||
if (hour < 5) {
|
||||
greeting = lateNightGreetings[Math.floor(Math.random() * lateNightGreetings.length)];
|
||||
} else if (hour < 12) {
|
||||
greeting = morningGreetings[Math.floor(Math.random() * morningGreetings.length)];
|
||||
} else if (hour < 18) {
|
||||
greeting = afternoonGreetings[Math.floor(Math.random() * afternoonGreetings.length)];
|
||||
} else if (hour < 22) {
|
||||
greeting = eveningGreetings[Math.floor(Math.random() * eveningGreetings.length)];
|
||||
} else {
|
||||
greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)];
|
||||
}
|
||||
|
||||
// Add personalization with name if available
|
||||
if (userName) {
|
||||
const firstName = userName.split(/\s+/)[0];
|
||||
return `${greeting}, ${firstName}!`;
|
||||
}
|
||||
|
||||
return `${greeting}!`;
|
||||
}
|
||||
|
||||
/**
|
||||
* WelcomeScreen - Displays greeting and suggestion cards for new conversations
|
||||
*
|
||||
* Features:
|
||||
* - Time-based personalized greeting
|
||||
* - Crypto-specific suggestion cards
|
||||
* - Animated entrance
|
||||
* - Accessible keyboard navigation
|
||||
*/
|
||||
export function WelcomeScreen({
|
||||
userName,
|
||||
onSuggestionClick,
|
||||
suggestions = DEFAULT_CRYPTO_SUGGESTIONS,
|
||||
className,
|
||||
}: WelcomeScreenProps) {
|
||||
// Memoize greeting so it doesn't change on re-renders
|
||||
const greeting = useMemo(() => getTimeBasedGreeting(userName), [userName]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center h-full p-4",
|
||||
"animate-in fade-in slide-in-from-bottom-4 duration-500",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Logo and Greeting */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-5xl mb-4">🌊</div>
|
||||
<h1 className="text-2xl font-semibold mb-2 animate-in fade-in slide-in-from-bottom-2 duration-500 delay-100">
|
||||
{greeting}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm animate-in fade-in slide-in-from-bottom-2 duration-500 delay-200">
|
||||
Your AI co-pilot for crypto research and analysis
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Suggestion Cards */}
|
||||
<div className="w-full max-w-sm space-y-2 animate-in fade-in slide-in-from-bottom-3 duration-500 delay-300">
|
||||
<p className="text-xs text-muted-foreground mb-3 flex items-center gap-1">
|
||||
<span>💡</span>
|
||||
<span>Try asking:</span>
|
||||
</p>
|
||||
{suggestions.slice(0, 4).map((suggestion, index) => (
|
||||
<SuggestionCard
|
||||
key={index}
|
||||
text={suggestion.text}
|
||||
type={suggestion.type}
|
||||
onClick={onSuggestionClick}
|
||||
className="animate-in fade-in slide-in-from-bottom-2 duration-300"
|
||||
style={{ animationDelay: `${400 + index * 100}ms` } as React.CSSProperties}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<p className="text-xs text-muted-foreground mt-6 animate-in fade-in duration-500 delay-700">
|
||||
Press <kbd className="px-1.5 py-0.5 rounded bg-muted text-xs">⌘K</kbd> for quick actions
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
5
surfsense_browser_extension/sidepanel/chat/index.ts
Normal file
5
surfsense_browser_extension/sidepanel/chat/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { ChatHeader } from "./ChatHeader";
|
||||
export { ChatMessages } from "./ChatMessages";
|
||||
export { ChatInput } from "./ChatInput";
|
||||
export { QuickCapture } from "./QuickCapture";
|
||||
export { ChatInterface } from "./ChatInterface";
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { Coins, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type { TokenData } from "../context/PageContextProvider";
|
||||
import { ChainIcon } from "./shared/ChainIcon";
|
||||
|
||||
export interface DetectedTokensListProps {
|
||||
/** List of detected tokens */
|
||||
tokens: TokenData[];
|
||||
/** Callback when a token is clicked */
|
||||
onTokenClick?: (token: TokenData) => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DetectedTokensList - Display list of tokens detected from page content
|
||||
*
|
||||
* Features:
|
||||
* - Shows tokens detected from Twitter mentions, contract addresses, trading pairs
|
||||
* - Click to analyze token
|
||||
* - Shows chain icon for each token
|
||||
*/
|
||||
export function DetectedTokensList({
|
||||
tokens,
|
||||
onTokenClick,
|
||||
className,
|
||||
}: DetectedTokensListProps) {
|
||||
if (tokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("border-b bg-muted/30", className)}>
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Coins className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">Detected Tokens</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({tokens.length} found)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{tokens.slice(0, 5).map((token, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onTokenClick?.(token)}
|
||||
className="w-full flex items-center justify-between p-2 rounded-md hover:bg-background transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<ChainIcon chain={token.chain} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
{token.tokenSymbol ? (
|
||||
<div className="font-medium text-sm truncate">
|
||||
{token.tokenSymbol}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground font-mono truncate">
|
||||
{token.pairAddress.slice(0, 8)}...{token.pairAddress.slice(-6)}
|
||||
</div>
|
||||
)}
|
||||
{token.tokenName && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{token.tokenName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tokens.length > 5 && (
|
||||
<div className="text-xs text-muted-foreground text-center mt-2">
|
||||
+{tokens.length - 5} more tokens detected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
|
||||
export type ChainType = "solana" | "ethereum" | "base" | "arbitrum" | "polygon" | "bsc" | "avalanche" | "unknown";
|
||||
|
||||
export interface ChainIconProps {
|
||||
/** Blockchain chain identifier */
|
||||
chain: ChainType | string;
|
||||
/** Size of the icon */
|
||||
size?: "sm" | "md" | "lg";
|
||||
/** Show chain name label */
|
||||
showLabel?: boolean;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Chain configuration with colors and display names
|
||||
const CHAIN_CONFIG: Record<string, { color: string; bgColor: string; label: string; emoji: string }> = {
|
||||
solana: {
|
||||
color: "#9945FF",
|
||||
bgColor: "bg-purple-500/10",
|
||||
label: "Solana",
|
||||
emoji: "◎",
|
||||
},
|
||||
ethereum: {
|
||||
color: "#627EEA",
|
||||
bgColor: "bg-blue-500/10",
|
||||
label: "Ethereum",
|
||||
emoji: "Ξ",
|
||||
},
|
||||
base: {
|
||||
color: "#0052FF",
|
||||
bgColor: "bg-blue-600/10",
|
||||
label: "Base",
|
||||
emoji: "🔵",
|
||||
},
|
||||
arbitrum: {
|
||||
color: "#28A0F0",
|
||||
bgColor: "bg-sky-500/10",
|
||||
label: "Arbitrum",
|
||||
emoji: "🔷",
|
||||
},
|
||||
polygon: {
|
||||
color: "#8247E5",
|
||||
bgColor: "bg-violet-500/10",
|
||||
label: "Polygon",
|
||||
emoji: "⬡",
|
||||
},
|
||||
bsc: {
|
||||
color: "#F0B90B",
|
||||
bgColor: "bg-yellow-500/10",
|
||||
label: "BSC",
|
||||
emoji: "🟡",
|
||||
},
|
||||
avalanche: {
|
||||
color: "#E84142",
|
||||
bgColor: "bg-red-500/10",
|
||||
label: "Avalanche",
|
||||
emoji: "🔺",
|
||||
},
|
||||
unknown: {
|
||||
color: "#6B7280",
|
||||
bgColor: "bg-gray-500/10",
|
||||
label: "Unknown",
|
||||
emoji: "🔗",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* ChainIcon - Displays blockchain chain icon with optional label
|
||||
*
|
||||
* Features:
|
||||
* - Chain-specific colors and icons
|
||||
* - Multiple size variants
|
||||
* - Optional chain name label
|
||||
*/
|
||||
export function ChainIcon({
|
||||
chain,
|
||||
size = "md",
|
||||
showLabel = false,
|
||||
className,
|
||||
}: ChainIconProps) {
|
||||
const normalizedChain = chain.toLowerCase();
|
||||
const config = CHAIN_CONFIG[normalizedChain] || CHAIN_CONFIG.unknown;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "w-4 h-4 text-xs",
|
||||
md: "w-5 h-5 text-sm",
|
||||
lg: "w-6 h-6 text-base",
|
||||
};
|
||||
|
||||
const labelSizes = {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1.5", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full flex items-center justify-center",
|
||||
config.bgColor,
|
||||
sizeClasses[size]
|
||||
)}
|
||||
style={{ color: config.color }}
|
||||
title={config.label}
|
||||
>
|
||||
<span>{config.emoji}</span>
|
||||
</div>
|
||||
{showLabel && (
|
||||
<span
|
||||
className={cn("font-medium", labelSizes[size])}
|
||||
style={{ color: config.color }}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chain color for custom styling
|
||||
*/
|
||||
export function getChainColor(chain: string): string {
|
||||
const normalizedChain = chain.toLowerCase();
|
||||
return CHAIN_CONFIG[normalizedChain]?.color || CHAIN_CONFIG.unknown.color;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
|
||||
export interface PriceDisplayProps {
|
||||
/** Current price value */
|
||||
price: string | number;
|
||||
/** Price change percentage (positive = up, negative = down) */
|
||||
priceChange?: number;
|
||||
/** Show the change indicator arrow */
|
||||
showChangeIndicator?: boolean;
|
||||
/** Size variant */
|
||||
size?: "sm" | "md" | "lg";
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PriceDisplay - Shows price with optional change indicator
|
||||
*
|
||||
* Features:
|
||||
* - Color-coded price changes (green for up, red for down)
|
||||
* - Animated arrow indicators
|
||||
* - Multiple size variants
|
||||
*/
|
||||
export function PriceDisplay({
|
||||
price,
|
||||
priceChange,
|
||||
showChangeIndicator = true,
|
||||
size = "md",
|
||||
className,
|
||||
}: PriceDisplayProps) {
|
||||
const isPositive = priceChange !== undefined && priceChange > 0;
|
||||
const isNegative = priceChange !== undefined && priceChange < 0;
|
||||
const isNeutral = priceChange === undefined || priceChange === 0;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "text-sm",
|
||||
md: "text-base",
|
||||
lg: "text-xl font-semibold",
|
||||
};
|
||||
|
||||
const changeClasses = {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: "h-3 w-3",
|
||||
md: "h-4 w-4",
|
||||
lg: "h-5 w-5",
|
||||
};
|
||||
|
||||
const formatPrice = (value: string | number): string => {
|
||||
if (typeof value === "string") return value;
|
||||
if (value < 0.00001) return `$${value.toExponential(2)}`;
|
||||
if (value < 1) return `$${value.toFixed(6)}`;
|
||||
if (value < 1000) return `$${value.toFixed(2)}`;
|
||||
return `$${value.toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const formatChange = (change: number): string => {
|
||||
const sign = change > 0 ? "+" : "";
|
||||
return `${sign}${change.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
{/* Price */}
|
||||
<span className={cn("font-medium", sizeClasses[size])}>
|
||||
{formatPrice(price)}
|
||||
</span>
|
||||
|
||||
{/* Change indicator */}
|
||||
{showChangeIndicator && priceChange !== undefined && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-0.5",
|
||||
changeClasses[size],
|
||||
isPositive && "text-green-500",
|
||||
isNegative && "text-red-500",
|
||||
isNeutral && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isPositive && <TrendingUp className={iconSizes[size]} />}
|
||||
{isNegative && <TrendingDown className={iconSizes[size]} />}
|
||||
{isNeutral && <Minus className={iconSizes[size]} />}
|
||||
<span>{formatChange(priceChange)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { Shield, AlertTriangle, XCircle, CheckCircle } from "lucide-react";
|
||||
|
||||
export type RiskLevel = "safe" | "low" | "medium" | "high" | "critical";
|
||||
|
||||
export interface RiskBadgeProps {
|
||||
/** Risk level */
|
||||
level: RiskLevel;
|
||||
/** Optional score (0-100) */
|
||||
score?: number;
|
||||
/** Show score value */
|
||||
showScore?: boolean;
|
||||
/** Size variant */
|
||||
size?: "sm" | "md" | "lg";
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Risk level configuration
|
||||
const RISK_CONFIG: Record<RiskLevel, {
|
||||
label: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
Icon: typeof Shield;
|
||||
}> = {
|
||||
safe: {
|
||||
label: "Safe",
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
bgColor: "bg-green-500/10",
|
||||
borderColor: "border-green-500/30",
|
||||
Icon: CheckCircle,
|
||||
},
|
||||
low: {
|
||||
label: "Low Risk",
|
||||
color: "text-green-500 dark:text-green-400",
|
||||
bgColor: "bg-green-500/10",
|
||||
borderColor: "border-green-500/30",
|
||||
Icon: Shield,
|
||||
},
|
||||
medium: {
|
||||
label: "Medium",
|
||||
color: "text-yellow-600 dark:text-yellow-400",
|
||||
bgColor: "bg-yellow-500/10",
|
||||
borderColor: "border-yellow-500/30",
|
||||
Icon: AlertTriangle,
|
||||
},
|
||||
high: {
|
||||
label: "High Risk",
|
||||
color: "text-orange-600 dark:text-orange-400",
|
||||
bgColor: "bg-orange-500/10",
|
||||
borderColor: "border-orange-500/30",
|
||||
Icon: AlertTriangle,
|
||||
},
|
||||
critical: {
|
||||
label: "Critical",
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
bgColor: "bg-red-500/10",
|
||||
borderColor: "border-red-500/30",
|
||||
Icon: XCircle,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* RiskBadge - Displays risk level with color-coded badge
|
||||
*
|
||||
* Features:
|
||||
* - Color-coded risk levels (safe to critical)
|
||||
* - Optional score display
|
||||
* - Multiple size variants
|
||||
* - Accessible with proper ARIA labels
|
||||
*/
|
||||
export function RiskBadge({
|
||||
level,
|
||||
score,
|
||||
showScore = false,
|
||||
size = "md",
|
||||
className,
|
||||
}: RiskBadgeProps) {
|
||||
const config = RISK_CONFIG[level];
|
||||
const { Icon } = config;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "px-1.5 py-0.5 text-xs gap-1",
|
||||
md: "px-2 py-1 text-sm gap-1.5",
|
||||
lg: "px-3 py-1.5 text-base gap-2",
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: "h-3 w-3",
|
||||
md: "h-4 w-4",
|
||||
lg: "h-5 w-5",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border font-medium",
|
||||
config.bgColor,
|
||||
config.borderColor,
|
||||
config.color,
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
role="status"
|
||||
aria-label={`Risk level: ${config.label}${score !== undefined ? `, Score: ${score}` : ""}`}
|
||||
>
|
||||
<Icon className={iconSizes[size]} />
|
||||
<span>{config.label}</span>
|
||||
{showScore && score !== undefined && (
|
||||
<span className="font-bold">({score})</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get risk level from score (0-100)
|
||||
*/
|
||||
export function getRiskLevelFromScore(score: number): RiskLevel {
|
||||
if (score >= 80) return "safe";
|
||||
if (score >= 60) return "low";
|
||||
if (score >= 40) return "medium";
|
||||
if (score >= 20) return "high";
|
||||
return "critical";
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { ArrowRight, Sparkles, TrendingUp, Shield, Wallet } from "lucide-react";
|
||||
|
||||
export type SuggestionType = "general" | "safety" | "trending" | "wallet" | "custom";
|
||||
|
||||
export interface SuggestionCardProps {
|
||||
/** Suggestion text to display */
|
||||
text: string;
|
||||
/** Type of suggestion for icon selection */
|
||||
type?: SuggestionType;
|
||||
/** Custom icon (overrides type icon) */
|
||||
icon?: React.ReactNode;
|
||||
/** Click handler */
|
||||
onClick?: (text: string) => void;
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Suggestion type icons
|
||||
const TYPE_ICONS: Record<SuggestionType, typeof Sparkles> = {
|
||||
general: Sparkles,
|
||||
safety: Shield,
|
||||
trending: TrendingUp,
|
||||
wallet: Wallet,
|
||||
custom: Sparkles,
|
||||
};
|
||||
|
||||
/**
|
||||
* SuggestionCard - Clickable suggestion card for chat prompts
|
||||
*
|
||||
* Features:
|
||||
* - Type-specific icons
|
||||
* - Hover animations
|
||||
* - Click to send suggestion
|
||||
* - Accessible keyboard navigation
|
||||
*/
|
||||
export function SuggestionCard({
|
||||
text,
|
||||
type = "general",
|
||||
icon,
|
||||
onClick,
|
||||
disabled = false,
|
||||
className,
|
||||
}: SuggestionCardProps) {
|
||||
const Icon = TYPE_ICONS[type];
|
||||
|
||||
const handleClick = () => {
|
||||
if (!disabled && onClick) {
|
||||
onClick(text);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 p-3 rounded-lg border",
|
||||
"bg-card hover:bg-accent/50 transition-all duration-200",
|
||||
"cursor-pointer select-none",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
{icon || <Icon className="h-4 w-4" />}
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<span className="flex-1 text-sm text-foreground line-clamp-2">
|
||||
{text}
|
||||
</span>
|
||||
|
||||
{/* Arrow indicator */}
|
||||
<ArrowRight
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground",
|
||||
"opacity-0 -translate-x-2 transition-all duration-200",
|
||||
"group-hover:opacity-100 group-hover:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default crypto-related suggestions
|
||||
*/
|
||||
export const DEFAULT_CRYPTO_SUGGESTIONS: Array<{ text: string; type: SuggestionType }> = [
|
||||
{ text: "Is this token safe to invest in?", type: "safety" },
|
||||
{ text: "What are the top gainers on Solana today?", type: "trending" },
|
||||
{ text: "Analyze this wallet's trading history", type: "wallet" },
|
||||
{ text: "Check for rug pull indicators", type: "safety" },
|
||||
{ text: "What's the market sentiment for this token?", type: "general" },
|
||||
];
|
||||
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// Shared components for SurfSense Browser Extension
|
||||
// These components are reusable across the extension UI
|
||||
|
||||
export { PriceDisplay, type PriceDisplayProps } from "./PriceDisplay";
|
||||
export { ChainIcon, getChainColor, type ChainIconProps, type ChainType } from "./ChainIcon";
|
||||
export { RiskBadge, getRiskLevelFromScore, type RiskBadgeProps, type RiskLevel } from "./RiskBadge";
|
||||
export { SuggestionCard, DEFAULT_CRYPTO_SUGGESTIONS, type SuggestionCardProps, type SuggestionType } from "./SuggestionCard";
|
||||
|
||||
|
|
@ -0,0 +1,382 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
Copy,
|
||||
Twitter,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface Tweet {
|
||||
number: number;
|
||||
content: string;
|
||||
type: "hook" | "analysis" | "implication" | "conclusion" | "disclaimer";
|
||||
includeChart?: boolean;
|
||||
}
|
||||
|
||||
export interface ThreadRequest {
|
||||
tokenAddress: string;
|
||||
tokenSymbol: string;
|
||||
chain: string;
|
||||
topic?: string;
|
||||
length: number;
|
||||
tone: "bullish" | "neutral" | "bearish";
|
||||
}
|
||||
|
||||
export interface GeneratedThread {
|
||||
tweets: Tweet[];
|
||||
metadata: {
|
||||
tokenSymbol: string;
|
||||
keyStats: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ThreadGeneratorPanelProps {
|
||||
/** Current token info */
|
||||
tokenAddress?: string;
|
||||
tokenSymbol?: string;
|
||||
chain?: string;
|
||||
/** Callback when thread is generated */
|
||||
onGenerate?: (request: ThreadRequest) => void;
|
||||
/** Callback when thread is exported */
|
||||
onExport?: (format: "copy" | "twitter") => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ThreadGeneratorPanel - AI-powered Twitter thread generator
|
||||
*
|
||||
* Features:
|
||||
* - Auto-fill token info from current page
|
||||
* - Customizable thread length (5-10 tweets)
|
||||
* - Tone selection (bullish/neutral/bearish)
|
||||
* - AI-generated thread structure (Hook → Analysis → Implications → Conclusion)
|
||||
* - Edit individual tweets
|
||||
* - Reorder tweets
|
||||
* - Export options (copy all, tweet directly)
|
||||
*/
|
||||
export function ThreadGeneratorPanel({
|
||||
tokenAddress,
|
||||
tokenSymbol,
|
||||
chain,
|
||||
onGenerate,
|
||||
onExport,
|
||||
className,
|
||||
}: ThreadGeneratorPanelProps) {
|
||||
const [request, setRequest] = useState<ThreadRequest>({
|
||||
tokenAddress: tokenAddress || "",
|
||||
tokenSymbol: tokenSymbol || "",
|
||||
chain: chain || "solana",
|
||||
topic: "",
|
||||
length: 7,
|
||||
tone: "bullish",
|
||||
});
|
||||
|
||||
const [generatedThread, setGeneratedThread] = useState<GeneratedThread | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [editingTweet, setEditingTweet] = useState<number | null>(null);
|
||||
|
||||
// Mock generated thread
|
||||
const mockThread: GeneratedThread = {
|
||||
tweets: [
|
||||
{
|
||||
number: 1,
|
||||
content: `🧵 ${request.tokenSymbol} is showing massive volume spike (+200%) in the last 24h. Here's what you need to know 👇`,
|
||||
type: "hook",
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
content: `Contract analysis:\n✅ Verified on-chain\n✅ Ownership renounced\n✅ LP locked for 90 days\n✅ No proxy contracts\n\nSolid fundamentals from a security perspective.`,
|
||||
type: "analysis",
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
content: `Holder distribution looks healthy:\n• 1,234 holders\n• Top 10 hold only 35%\n• No single whale dominance\n\nThis suggests organic growth and reduced rug pull risk.`,
|
||||
type: "analysis",
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
content: `Liquidity: $50K\nVolume/Liquidity ratio: 2.0x\n\nStrong trading activity relative to liquidity. This is a bullish signal for price discovery.`,
|
||||
type: "analysis",
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
content: `Social sentiment is turning positive:\n• 500 Twitter mentions (24h)\n• 1,200 Telegram messages\n• Growing community engagement\n\nMomentum is building.`,
|
||||
type: "implication",
|
||||
},
|
||||
{
|
||||
number: 6,
|
||||
content: `What this means:\n\nWe're seeing early signs of a potential breakout. Volume precedes price, and the fundamentals support sustained growth.`,
|
||||
type: "implication",
|
||||
},
|
||||
{
|
||||
number: 7,
|
||||
content: `TL;DR:\n✅ Verified & safe contract\n✅ Healthy holder distribution\n✅ Strong volume growth\n✅ Positive social sentiment\n\nDYOR, but this one's worth watching closely. 👀`,
|
||||
type: "conclusion",
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
tokenSymbol: request.tokenSymbol,
|
||||
keyStats: {
|
||||
price: 0.0001234,
|
||||
change24h: 15.5,
|
||||
volume: 100000,
|
||||
liquidity: 50000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setIsGenerating(true);
|
||||
await onGenerate?.(request);
|
||||
// Mock: simulate generation
|
||||
setTimeout(() => {
|
||||
setGeneratedThread(mockThread);
|
||||
setIsGenerating(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleEditTweet = (number: number, newContent: string) => {
|
||||
if (!generatedThread) return;
|
||||
const updatedTweets = generatedThread.tweets.map((tweet) =>
|
||||
tweet.number === number ? { ...tweet, content: newContent } : tweet
|
||||
);
|
||||
setGeneratedThread({ ...generatedThread, tweets: updatedTweets });
|
||||
setEditingTweet(null);
|
||||
};
|
||||
|
||||
const handleDeleteTweet = (number: number) => {
|
||||
if (!generatedThread) return;
|
||||
const updatedTweets = generatedThread.tweets
|
||||
.filter((tweet) => tweet.number !== number)
|
||||
.map((tweet, index) => ({ ...tweet, number: index + 1 }));
|
||||
setGeneratedThread({ ...generatedThread, tweets: updatedTweets });
|
||||
};
|
||||
|
||||
const handleAddTweet = () => {
|
||||
if (!generatedThread) return;
|
||||
const newTweet: Tweet = {
|
||||
number: generatedThread.tweets.length + 1,
|
||||
content: "New tweet content...",
|
||||
type: "analysis",
|
||||
};
|
||||
setGeneratedThread({
|
||||
...generatedThread,
|
||||
tweets: [...generatedThread.tweets, newTweet],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="font-semibold">AI Thread Generator</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create Twitter threads with AI
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{!generatedThread ? (
|
||||
<>
|
||||
{/* Input Form */}
|
||||
<div className="space-y-3">
|
||||
{/* Token Info */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Token</label>
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded">
|
||||
<span className="font-semibold">{request.tokenSymbol || "Not selected"}</span>
|
||||
{request.chain && <ChainIcon chain={request.chain} size="xs" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Topic */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Topic (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Auto-generate from token data"
|
||||
value={request.topic}
|
||||
onChange={(e) => setRequest({ ...request, topic: e.target.value })}
|
||||
className="w-full p-2 text-sm border rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Length */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Length</label>
|
||||
<select
|
||||
value={request.length}
|
||||
onChange={(e) => setRequest({ ...request, length: parseInt(e.target.value) })}
|
||||
className="w-full p-2 text-sm border rounded"
|
||||
>
|
||||
{[5, 6, 7, 8, 9, 10].map((len) => (
|
||||
<option key={len} value={len}>
|
||||
{len} tweets
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Tone */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tone</label>
|
||||
<div className="flex gap-2">
|
||||
{(["bullish", "neutral", "bearish"] as const).map((tone) => (
|
||||
<button
|
||||
key={tone}
|
||||
className={cn(
|
||||
"flex-1 p-2 rounded border text-xs font-medium transition-colors",
|
||||
request.tone === tone
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-muted hover:bg-muted/80"
|
||||
)}
|
||||
onClick={() => setRequest({ ...request, tone })}
|
||||
>
|
||||
{tone.charAt(0).toUpperCase() + tone.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || !request.tokenSymbol}
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
{isGenerating ? "Generating..." : "Generate Thread"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Generated Thread Preview */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-sm">Preview</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setGeneratedThread(null)}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Regenerate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tweets */}
|
||||
<div className="space-y-2">
|
||||
{generatedThread.tweets.map((tweet) => (
|
||||
<div
|
||||
key={tweet.number}
|
||||
className="p-3 border rounded-lg bg-background"
|
||||
>
|
||||
{editingTweet === tweet.number ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={tweet.content}
|
||||
onChange={(e) =>
|
||||
handleEditTweet(tweet.number, e.target.value)
|
||||
}
|
||||
className="w-full p-2 text-sm border rounded min-h-[80px]"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setEditingTweet(null)}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingTweet(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground">
|
||||
{tweet.number}/{generatedThread.tweets.length}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
onClick={() => setEditingTweet(tweet.number)}
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
onClick={() => handleDeleteTweet(tweet.number)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{tweet.content}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Tweet Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleAddTweet}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Tweet
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - Export Options */}
|
||||
{generatedThread && (
|
||||
<div className="border-t p-3 space-y-2">
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={() => onExport?.("copy")}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy All Tweets
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => onExport?.("twitter")}
|
||||
>
|
||||
<Twitter className="h-4 w-4 mr-2" />
|
||||
Tweet Now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { MOCK_TOKEN_DATA, MOCK_MODE } from "../mock/mockData";
|
||||
|
||||
/**
|
||||
* Page context types
|
||||
*/
|
||||
export type PageType = "dexscreener" | "coingecko" | "twitter" | "generic";
|
||||
|
||||
export interface TokenData {
|
||||
chain: string;
|
||||
pairAddress: string;
|
||||
tokenSymbol?: string;
|
||||
tokenName?: string;
|
||||
price?: string;
|
||||
priceChange24h?: number;
|
||||
volume24h?: string;
|
||||
liquidity?: string;
|
||||
marketCap?: string;
|
||||
}
|
||||
|
||||
export interface PageContext {
|
||||
url: string;
|
||||
title: string;
|
||||
pageType: PageType;
|
||||
tokenData?: TokenData;
|
||||
/** Detected tokens from page content (Twitter mentions, addresses, pairs) */
|
||||
detectedTokens?: TokenData[];
|
||||
}
|
||||
|
||||
interface PageContextValue {
|
||||
context: PageContext | null;
|
||||
updateContext: (context: PageContext) => void;
|
||||
/** Whether we're using mock data */
|
||||
isMockMode: boolean;
|
||||
}
|
||||
|
||||
const PageContextContext = createContext<PageContextValue>({
|
||||
context: null,
|
||||
updateContext: () => { },
|
||||
isMockMode: false,
|
||||
});
|
||||
|
||||
export function usePageContext() {
|
||||
return useContext(PageContextContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for page context detection
|
||||
* Listens to messages from content scripts
|
||||
* Uses mock data in development mode
|
||||
*/
|
||||
export function PageContextProvider({ children }: { children: ReactNode }) {
|
||||
const [context, setContext] = useState<PageContext | null>(null);
|
||||
const isMockMode = MOCK_MODE.enabled;
|
||||
|
||||
useEffect(() => {
|
||||
// Use mock data in development mode
|
||||
if (MOCK_MODE.enabled && MOCK_MODE.simulateDexScreener) {
|
||||
setContext({
|
||||
url: "https://dexscreener.com/solana/7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
|
||||
title: "BULLA / SOL | DEX Screener",
|
||||
pageType: "dexscreener",
|
||||
tokenData: MOCK_TOKEN_DATA,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for page context updates from content script
|
||||
const handleMessage = (message: any) => {
|
||||
if (message.type === "PAGE_CONTEXT_UPDATE") {
|
||||
setContext(message.data);
|
||||
}
|
||||
};
|
||||
|
||||
chrome.runtime.onMessage.addListener(handleMessage);
|
||||
|
||||
// Request initial context
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (tabs[0]?.id) {
|
||||
chrome.tabs.sendMessage(tabs[0].id, { type: "GET_PAGE_CONTEXT" });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
chrome.runtime.onMessage.removeListener(handleMessage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageContextContext.Provider value={{ context, updateContext: setContext, isMockMode }}>
|
||||
{children}
|
||||
</PageContextContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
Bell,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Droplets,
|
||||
Users,
|
||||
Wallet,
|
||||
X,
|
||||
Check
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/routes/ui/dialog";
|
||||
|
||||
export type AlertType = "price_above" | "price_below" | "price_change" | "volume" | "whale" | "liquidity" | "holder_concentration";
|
||||
|
||||
export interface AlertConfig {
|
||||
/** Alert type */
|
||||
type: AlertType;
|
||||
/** Threshold value */
|
||||
threshold: number;
|
||||
/** Whether alert is enabled */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AlertConfigModalProps {
|
||||
/** Whether modal is open */
|
||||
open: boolean;
|
||||
/** Callback when modal is closed */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Token symbol */
|
||||
tokenSymbol: string;
|
||||
/** Current price for reference */
|
||||
currentPrice?: string;
|
||||
/** Existing alert configurations */
|
||||
existingAlerts?: AlertConfig[];
|
||||
/** Callback when alerts are saved */
|
||||
onSave: (alerts: AlertConfig[]) => void;
|
||||
}
|
||||
|
||||
const ALERT_TYPES: Array<{
|
||||
type: AlertType;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof Bell;
|
||||
unit: string;
|
||||
defaultThreshold: number;
|
||||
}> = [
|
||||
{
|
||||
type: "price_above",
|
||||
label: "Price Above",
|
||||
description: "Alert when price rises above threshold",
|
||||
icon: TrendingUp,
|
||||
unit: "$",
|
||||
defaultThreshold: 0,
|
||||
},
|
||||
{
|
||||
type: "price_below",
|
||||
label: "Price Below",
|
||||
description: "Alert when price drops below threshold",
|
||||
icon: TrendingDown,
|
||||
unit: "$",
|
||||
defaultThreshold: 0,
|
||||
},
|
||||
{
|
||||
type: "price_change",
|
||||
label: "Price Change",
|
||||
description: "Alert on significant price movement",
|
||||
icon: TrendingUp,
|
||||
unit: "%",
|
||||
defaultThreshold: 10,
|
||||
},
|
||||
{
|
||||
type: "volume",
|
||||
label: "Volume Spike",
|
||||
description: "Alert on unusual trading volume",
|
||||
icon: TrendingUp,
|
||||
unit: "x",
|
||||
defaultThreshold: 3,
|
||||
},
|
||||
{
|
||||
type: "whale",
|
||||
label: "Whale Activity",
|
||||
description: "Alert on large transactions",
|
||||
icon: Wallet,
|
||||
unit: "$",
|
||||
defaultThreshold: 10000,
|
||||
},
|
||||
{
|
||||
type: "liquidity",
|
||||
label: "Liquidity Change",
|
||||
description: "Alert on liquidity pool changes",
|
||||
icon: Droplets,
|
||||
unit: "%",
|
||||
defaultThreshold: 20,
|
||||
},
|
||||
{
|
||||
type: "holder_concentration",
|
||||
label: "Holder Concentration",
|
||||
description: "Alert if top holders exceed threshold",
|
||||
icon: Users,
|
||||
unit: "%",
|
||||
defaultThreshold: 50,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* AlertConfigModal - Configure alerts for a token
|
||||
*
|
||||
* Features:
|
||||
* - Multiple alert types (price, volume, whale, liquidity, holders)
|
||||
* - Threshold configuration per alert type
|
||||
* - Enable/disable individual alerts
|
||||
* - Save all configurations at once
|
||||
*/
|
||||
export function AlertConfigModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
tokenSymbol,
|
||||
currentPrice,
|
||||
existingAlerts = [],
|
||||
onSave,
|
||||
}: AlertConfigModalProps) {
|
||||
// Initialize alerts state from existing or defaults
|
||||
const [alerts, setAlerts] = useState<AlertConfig[]>(() => {
|
||||
return ALERT_TYPES.map(alertType => {
|
||||
const existing = existingAlerts.find(a => a.type === alertType.type);
|
||||
return existing || {
|
||||
type: alertType.type,
|
||||
threshold: alertType.defaultThreshold,
|
||||
enabled: false,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const handleToggleAlert = (type: AlertType) => {
|
||||
setAlerts(prev => prev.map(alert =>
|
||||
alert.type === type
|
||||
? { ...alert, enabled: !alert.enabled }
|
||||
: alert
|
||||
));
|
||||
};
|
||||
|
||||
const handleThresholdChange = (type: AlertType, value: number) => {
|
||||
setAlerts(prev => prev.map(alert =>
|
||||
alert.type === type
|
||||
? { ...alert, threshold: value }
|
||||
: alert
|
||||
));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(alerts.filter(a => a.enabled));
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const enabledCount = alerts.filter(a => a.enabled).length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Configure Alerts for {tokenSymbol}
|
||||
</DialogTitle>
|
||||
{currentPrice && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Current price: {currentPrice}
|
||||
</p>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{/* Alert types list */}
|
||||
<div className="flex-1 overflow-y-auto py-4 space-y-3">
|
||||
{ALERT_TYPES.map((alertType) => {
|
||||
const alert = alerts.find(a => a.type === alertType.type)!;
|
||||
const Icon = alertType.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alertType.type}
|
||||
className={cn(
|
||||
"rounded-lg border p-3 transition-colors",
|
||||
alert.enabled ? "border-primary bg-primary/5" : "border-border"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Toggle button */}
|
||||
<button
|
||||
onClick={() => handleToggleAlert(alertType.type)}
|
||||
className={cn(
|
||||
"w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 mt-0.5 transition-colors",
|
||||
alert.enabled
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "border-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{alert.enabled && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
|
||||
{/* Alert info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{alertType.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{alertType.description}
|
||||
</p>
|
||||
|
||||
{/* Threshold input (only when enabled) */}
|
||||
{alert.enabled && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-xs text-muted-foreground">Threshold:</span>
|
||||
<div className="flex items-center">
|
||||
{alertType.unit === "$" && (
|
||||
<span className="text-sm text-muted-foreground">$</span>
|
||||
)}
|
||||
<input
|
||||
type="number"
|
||||
value={alert.threshold}
|
||||
onChange={(e) => handleThresholdChange(
|
||||
alertType.type,
|
||||
parseFloat(e.target.value) || 0
|
||||
)}
|
||||
className="w-24 px-2 py-1 text-sm border rounded bg-background"
|
||||
min={0}
|
||||
step={alertType.unit === "%" ? 1 : 0.01}
|
||||
/>
|
||||
{alertType.unit !== "$" && (
|
||||
<span className="text-sm text-muted-foreground ml-1">
|
||||
{alertType.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer with save button */}
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{enabledCount} alert{enabledCount !== 1 ? "s" : ""} enabled
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
Save Alerts
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
Shield,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
ExternalLink,
|
||||
Clock,
|
||||
Star,
|
||||
Bell
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { RiskBadge, getRiskLevelFromScore, type RiskLevel } from "../components/shared";
|
||||
|
||||
export interface SafetyFactor {
|
||||
/** Category name (e.g., "Liquidity", "Contract", "Holders") */
|
||||
category: string;
|
||||
/** Status of this factor */
|
||||
status: "positive" | "warning" | "danger";
|
||||
/** Description of the finding */
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface SafetyScoreProps {
|
||||
/** Safety score from 0-100 */
|
||||
score: number;
|
||||
/** Risk level (can be auto-calculated from score) */
|
||||
level?: RiskLevel;
|
||||
/** Individual safety factors */
|
||||
factors: SafetyFactor[];
|
||||
/** Data sources used for analysis */
|
||||
sources?: string[];
|
||||
/** When the analysis was performed */
|
||||
timestamp?: Date;
|
||||
/** Token symbol for display */
|
||||
tokenSymbol?: string;
|
||||
/** Callback when "Add to Watchlist" is clicked */
|
||||
onAddToWatchlist?: () => void;
|
||||
/** Callback when "Set Alert" is clicked */
|
||||
onSetAlert?: () => void;
|
||||
/** Whether token is already in watchlist */
|
||||
isInWatchlist?: boolean;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
positive: {
|
||||
icon: CheckCircle,
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
bgColor: "bg-green-500/10",
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-yellow-600 dark:text-yellow-400",
|
||||
bgColor: "bg-yellow-500/10",
|
||||
},
|
||||
danger: {
|
||||
icon: XCircle,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
bgColor: "bg-red-500/10",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* SafetyScoreDisplay - Comprehensive safety analysis visualization
|
||||
*
|
||||
* Features:
|
||||
* - Visual score indicator (0-100)
|
||||
* - Risk level badge
|
||||
* - Categorized safety factors with status icons
|
||||
* - Data sources attribution
|
||||
* - Quick actions (Add to Watchlist, Set Alert)
|
||||
*/
|
||||
export function SafetyScoreDisplay({
|
||||
score,
|
||||
level,
|
||||
factors,
|
||||
sources = [],
|
||||
timestamp,
|
||||
tokenSymbol,
|
||||
onAddToWatchlist,
|
||||
onSetAlert,
|
||||
isInWatchlist = false,
|
||||
className,
|
||||
}: SafetyScoreProps) {
|
||||
const riskLevel = level || getRiskLevelFromScore(score);
|
||||
|
||||
// Group factors by status
|
||||
const positiveFactors = factors.filter(f => f.status === "positive");
|
||||
const warningFactors = factors.filter(f => f.status === "warning");
|
||||
const dangerFactors = factors.filter(f => f.status === "danger");
|
||||
|
||||
// Calculate score color
|
||||
const getScoreColor = () => {
|
||||
if (score >= 80) return "text-green-500";
|
||||
if (score >= 60) return "text-green-400";
|
||||
if (score >= 40) return "text-yellow-500";
|
||||
if (score >= 20) return "text-orange-500";
|
||||
return "text-red-500";
|
||||
};
|
||||
|
||||
// Calculate progress bar color
|
||||
const getProgressColor = () => {
|
||||
if (score >= 80) return "bg-green-500";
|
||||
if (score >= 60) return "bg-green-400";
|
||||
if (score >= 40) return "bg-yellow-500";
|
||||
if (score >= 20) return "bg-orange-500";
|
||||
return "bg-red-500";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border bg-card p-4", className)}>
|
||||
{/* Header with score */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Shield className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
Safety Analysis
|
||||
{tokenSymbol && <span className="text-muted-foreground ml-1">({tokenSymbol})</span>}
|
||||
</h3>
|
||||
<RiskBadge level={riskLevel} score={score} showScore size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Large score display */}
|
||||
<div className="text-right">
|
||||
<div className={cn("text-3xl font-bold", getScoreColor())}>
|
||||
{score}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">/ 100</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score progress bar */}
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden mb-4">
|
||||
<div
|
||||
className={cn("h-full transition-all duration-500", getProgressColor())}
|
||||
style={{ width: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Safety factors */}
|
||||
<div className="space-y-3 mb-4">
|
||||
{/* Danger factors first */}
|
||||
{dangerFactors.length > 0 && (
|
||||
<FactorSection title="🚨 Red Flags" factors={dangerFactors} status="danger" />
|
||||
)}
|
||||
|
||||
{/* Warning factors */}
|
||||
{warningFactors.length > 0 && (
|
||||
<FactorSection title="⚠️ Warnings" factors={warningFactors} status="warning" />
|
||||
)}
|
||||
|
||||
{/* Positive factors */}
|
||||
{positiveFactors.length > 0 && (
|
||||
<FactorSection title="✅ Positive Signals" factors={positiveFactors} status="positive" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={onAddToWatchlist}
|
||||
>
|
||||
<Star className={cn("mr-1 h-4 w-4", isInWatchlist && "fill-yellow-500 text-yellow-500")} />
|
||||
{isInWatchlist ? "In Watchlist" : "Add to Watchlist"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={onSetAlert}
|
||||
>
|
||||
<Bell className="mr-1 h-4 w-4" />
|
||||
Set Alert
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Footer with sources and timestamp */}
|
||||
<div className="pt-3 border-t text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
{timestamp
|
||||
? `Analyzed ${timestamp.toLocaleTimeString()}`
|
||||
: "Just now"
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
{sources.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
<span>{sources.length} sources</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{sources.length > 0 && (
|
||||
<div className="mt-1 text-xs opacity-70">
|
||||
Sources: {sources.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FactorSection - Grouped display of safety factors
|
||||
*/
|
||||
function FactorSection({
|
||||
title,
|
||||
factors,
|
||||
status
|
||||
}: {
|
||||
title: string;
|
||||
factors: SafetyFactor[];
|
||||
status: "positive" | "warning" | "danger";
|
||||
}) {
|
||||
const config = STATUS_CONFIG[status];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">{title}</h4>
|
||||
<div className="space-y-1.5">
|
||||
{factors.map((factor, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-start gap-2 p-2 rounded-md text-sm",
|
||||
config.bgColor
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-4 w-4 mt-0.5 flex-shrink-0", config.color)} />
|
||||
<div>
|
||||
<span className="font-medium">{factor.category}:</span>{" "}
|
||||
<span className="text-muted-foreground">{factor.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export types for use in other components
|
||||
export type { SafetyFactor };
|
||||
|
||||
322
surfsense_browser_extension/sidepanel/crypto/WatchlistPanel.tsx
Normal file
322
surfsense_browser_extension/sidepanel/crypto/WatchlistPanel.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
Star,
|
||||
Bell,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Plus,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
AlertCircle
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface WatchlistToken {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** Token symbol */
|
||||
symbol: string;
|
||||
/** Token name */
|
||||
name?: string;
|
||||
/** Blockchain chain */
|
||||
chain: string;
|
||||
/** Contract address */
|
||||
contractAddress: string;
|
||||
/** Current price */
|
||||
price: string;
|
||||
/** 24h price change percentage */
|
||||
priceChange24h: number;
|
||||
/** Whether alerts are enabled for this token */
|
||||
hasAlerts?: boolean;
|
||||
/** Number of active alerts */
|
||||
alertCount?: number;
|
||||
}
|
||||
|
||||
export interface WatchlistAlert {
|
||||
/** Alert ID */
|
||||
id: string;
|
||||
/** Token symbol */
|
||||
tokenSymbol: string;
|
||||
/** Alert type */
|
||||
type: "price" | "volume" | "whale" | "liquidity";
|
||||
/** Alert message */
|
||||
message: string;
|
||||
/** When the alert was triggered */
|
||||
timestamp: Date;
|
||||
/** Whether alert has been read */
|
||||
isRead?: boolean;
|
||||
}
|
||||
|
||||
export interface WatchlistPanelProps {
|
||||
/** List of watched tokens */
|
||||
tokens: WatchlistToken[];
|
||||
/** Recent alerts */
|
||||
recentAlerts?: WatchlistAlert[];
|
||||
/** Callback when token is clicked */
|
||||
onTokenClick?: (token: WatchlistToken) => void;
|
||||
/** Callback when remove token is clicked */
|
||||
onRemoveToken?: (tokenId: string) => void;
|
||||
/** Callback when add token is clicked */
|
||||
onAddToken?: () => void;
|
||||
/** Callback when configure alerts is clicked */
|
||||
onConfigureAlerts?: (token: WatchlistToken) => void;
|
||||
/** Callback when alert is clicked */
|
||||
onAlertClick?: (alert: WatchlistAlert) => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WatchlistPanel - Token watchlist with alerts
|
||||
*
|
||||
* Features:
|
||||
* - List of watched tokens with price changes
|
||||
* - Alert indicators per token
|
||||
* - Recent alerts section
|
||||
* - Add/remove tokens
|
||||
* - Quick access to alert configuration
|
||||
*/
|
||||
export function WatchlistPanel({
|
||||
tokens,
|
||||
recentAlerts = [],
|
||||
onTokenClick,
|
||||
onRemoveToken,
|
||||
onAddToken,
|
||||
onConfigureAlerts,
|
||||
onAlertClick,
|
||||
className,
|
||||
}: WatchlistPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<"tokens" | "alerts">("tokens");
|
||||
const unreadAlerts = recentAlerts.filter(a => !a.isRead).length;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-yellow-500 fill-yellow-500" />
|
||||
<h2 className="font-semibold">Watchlist</h2>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={onAddToken}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b">
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 py-2 text-sm font-medium transition-colors",
|
||||
activeTab === "tokens"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab("tokens")}
|
||||
>
|
||||
Tokens ({tokens.length})
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 py-2 text-sm font-medium transition-colors relative",
|
||||
activeTab === "alerts"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab("alerts")}
|
||||
>
|
||||
Alerts
|
||||
{unreadAlerts > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center">
|
||||
{unreadAlerts}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{activeTab === "tokens" ? (
|
||||
<TokenList
|
||||
tokens={tokens}
|
||||
onTokenClick={onTokenClick}
|
||||
onRemoveToken={onRemoveToken}
|
||||
onConfigureAlerts={onConfigureAlerts}
|
||||
/>
|
||||
) : (
|
||||
<AlertList
|
||||
alerts={recentAlerts}
|
||||
onAlertClick={onAlertClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TokenList - List of watched tokens
|
||||
*/
|
||||
function TokenList({
|
||||
tokens,
|
||||
onTokenClick,
|
||||
onRemoveToken,
|
||||
onConfigureAlerts,
|
||||
}: {
|
||||
tokens: WatchlistToken[];
|
||||
onTokenClick?: (token: WatchlistToken) => void;
|
||||
onRemoveToken?: (tokenId: string) => void;
|
||||
onConfigureAlerts?: (token: WatchlistToken) => void;
|
||||
}) {
|
||||
if (tokens.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<Star className="h-12 w-12 text-muted-foreground/30 mb-4" />
|
||||
<p className="text-muted-foreground text-sm">No tokens in watchlist</p>
|
||||
<p className="text-muted-foreground text-xs mt-1">
|
||||
Add tokens to track their price and set alerts
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y">
|
||||
{tokens.map((token) => (
|
||||
<div
|
||||
key={token.id}
|
||||
className="flex items-center gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer group"
|
||||
onClick={() => onTokenClick?.(token)}
|
||||
>
|
||||
{/* Token info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{token.symbol}</span>
|
||||
<ChainIcon chain={token.chain} size="sm" />
|
||||
{token.hasAlerts && (
|
||||
<Bell className="h-3 w-3 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{token.name || token.contractAddress.slice(0, 10) + "..."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Price and change */}
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-sm">{token.price}</p>
|
||||
<p className={cn(
|
||||
"text-xs flex items-center justify-end gap-0.5",
|
||||
token.priceChange24h > 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{token.priceChange24h > 0 ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{token.priceChange24h > 0 ? "+" : ""}{token.priceChange24h.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions (visible on hover) */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfigureAlerts?.(token);
|
||||
}}
|
||||
title="Configure alerts"
|
||||
>
|
||||
<Bell className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 hover:bg-destructive/10 rounded"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveToken?.(token.id);
|
||||
}}
|
||||
title="Remove from watchlist"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AlertList - List of recent alerts
|
||||
*/
|
||||
function AlertList({
|
||||
alerts,
|
||||
onAlertClick,
|
||||
}: {
|
||||
alerts: WatchlistAlert[];
|
||||
onAlertClick?: (alert: WatchlistAlert) => void;
|
||||
}) {
|
||||
if (alerts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<Bell className="h-12 w-12 text-muted-foreground/30 mb-4" />
|
||||
<p className="text-muted-foreground text-sm">No alerts yet</p>
|
||||
<p className="text-muted-foreground text-xs mt-1">
|
||||
Configure alerts on your watched tokens
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getAlertIcon = (type: WatchlistAlert["type"]) => {
|
||||
switch (type) {
|
||||
case "price": return TrendingUp;
|
||||
case "volume": return TrendingUp;
|
||||
case "whale": return AlertCircle;
|
||||
case "liquidity": return AlertCircle;
|
||||
default: return Bell;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="divide-y">
|
||||
{alerts.map((alert) => {
|
||||
const Icon = getAlertIcon(alert.type);
|
||||
return (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer",
|
||||
!alert.isRead && "bg-primary/5"
|
||||
)}
|
||||
onClick={() => onAlertClick?.(alert)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0",
|
||||
!alert.isRead ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{alert.tokenSymbol}</span>
|
||||
<span className="text-xs text-muted-foreground capitalize">{alert.type}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{alert.message}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{alert.timestamp.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
{!alert.isRead && (
|
||||
<div className="w-2 h-2 rounded-full bg-primary flex-shrink-0 mt-2" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
6
surfsense_browser_extension/sidepanel/crypto/index.ts
Normal file
6
surfsense_browser_extension/sidepanel/crypto/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Crypto-specific components for SurfSense Browser Extension
|
||||
|
||||
export { SafetyScoreDisplay, type SafetyScoreProps, type SafetyFactor } from "./SafetyScoreDisplay";
|
||||
export { WatchlistPanel, type WatchlistPanelProps, type WatchlistToken, type WatchlistAlert } from "./WatchlistPanel";
|
||||
export { AlertConfigModal, type AlertConfigModalProps, type AlertConfig, type AlertType } from "./AlertConfigModal";
|
||||
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
import { useState } from "react";
|
||||
import type { TokenData } from "../context/PageContextProvider";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Shield,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
Star,
|
||||
Copy,
|
||||
Check
|
||||
} from "lucide-react";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export type QuickActionType = "safety" | "holders" | "predict" | "rug";
|
||||
|
||||
export interface EnhancedTokenData extends TokenData {
|
||||
priceChange24h?: number;
|
||||
marketCap?: string;
|
||||
}
|
||||
|
||||
interface TokenInfoCardProps {
|
||||
tokenData: EnhancedTokenData;
|
||||
/** Whether token is in user's watchlist */
|
||||
isInWatchlist?: boolean;
|
||||
/** Callback when quick action button is clicked (generic handler) */
|
||||
onQuickAction?: (action: QuickActionType, tokenData: EnhancedTokenData) => void;
|
||||
/** Callback when watchlist button is clicked */
|
||||
onToggleWatchlist?: (tokenData: EnhancedTokenData) => void;
|
||||
/** Alternative: Direct callback for add to watchlist */
|
||||
onAddToWatchlist?: () => void;
|
||||
/** Alternative: Direct callback for safety check */
|
||||
onSafetyCheck?: () => void;
|
||||
/** Alternative: Direct callback for rug check */
|
||||
onRugCheck?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* TokenInfoCard - Enhanced token info card for DexScreener pages
|
||||
*
|
||||
* Features:
|
||||
* - Price with 24h change indicator (▲/▼)
|
||||
* - Market cap display
|
||||
* - Add to watchlist button
|
||||
* - 4 quick actions: Safety, Holders, Predict, Rug Check
|
||||
* - Copy contract address
|
||||
* - Chain-specific icon
|
||||
*/
|
||||
export function TokenInfoCard({
|
||||
tokenData,
|
||||
isInWatchlist = false,
|
||||
onQuickAction,
|
||||
onToggleWatchlist,
|
||||
onAddToWatchlist,
|
||||
onSafetyCheck,
|
||||
onRugCheck,
|
||||
}: TokenInfoCardProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleQuickAction = (action: QuickActionType) => {
|
||||
// Use specific callbacks if provided, otherwise fall back to generic handler
|
||||
if (action === "safety" && onSafetyCheck) {
|
||||
onSafetyCheck();
|
||||
} else if (action === "rug" && onRugCheck) {
|
||||
onRugCheck();
|
||||
} else if (onQuickAction) {
|
||||
onQuickAction(action, tokenData);
|
||||
} else {
|
||||
console.log("Quick action:", action, tokenData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyAddress = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(tokenData.pairAddress);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy address:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleWatchlist = () => {
|
||||
// Use specific callback if provided, otherwise fall back to generic handler
|
||||
if (onAddToWatchlist) {
|
||||
onAddToWatchlist();
|
||||
} else if (onToggleWatchlist) {
|
||||
onToggleWatchlist(tokenData);
|
||||
}
|
||||
};
|
||||
|
||||
const priceChange = tokenData.priceChange24h;
|
||||
const isPositive = priceChange !== undefined && priceChange > 0;
|
||||
const isNegative = priceChange !== undefined && priceChange < 0;
|
||||
|
||||
return (
|
||||
<div className="border-b p-4 bg-muted/50">
|
||||
{/* Header with token info and watchlist */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-lg">🪙</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold truncate">
|
||||
{tokenData.tokenSymbol || "Unknown Token"}
|
||||
</h3>
|
||||
{/* Watchlist button */}
|
||||
<button
|
||||
onClick={handleToggleWatchlist}
|
||||
className={cn(
|
||||
"p-1 rounded-full transition-colors",
|
||||
isInWatchlist
|
||||
? "text-yellow-500 hover:text-yellow-600"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
title={isInWatchlist ? "Remove from watchlist" : "Add to watchlist"}
|
||||
>
|
||||
<Star className={cn("h-4 w-4", isInWatchlist && "fill-current")} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ChainIcon chain={tokenData.chain} size="sm" />
|
||||
<span>•</span>
|
||||
<button
|
||||
onClick={handleCopyAddress}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
title="Copy contract address"
|
||||
>
|
||||
<span>{tokenData.pairAddress.slice(0, 6)}...{tokenData.pairAddress.slice(-4)}</span>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price with change indicator */}
|
||||
<div className="mt-3 flex items-baseline gap-2">
|
||||
<span className="text-xl font-bold">{tokenData.price || "—"}</span>
|
||||
{priceChange !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 text-sm font-medium",
|
||||
isPositive && "text-green-500",
|
||||
isNegative && "text-red-500",
|
||||
!isPositive && !isNegative && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isPositive && <TrendingUp className="h-3 w-3" />}
|
||||
{isNegative && <TrendingDown className="h-3 w-3" />}
|
||||
{isPositive ? "+" : ""}{priceChange.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Token stats grid - now with 4 columns including market cap */}
|
||||
<div className="grid grid-cols-4 gap-2 mt-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs">24h Vol</span>
|
||||
<p className="font-medium truncate">{tokenData.volume24h || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs">Liquidity</span>
|
||||
<p className="font-medium truncate">{tokenData.liquidity || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs">MCap</span>
|
||||
<p className="font-medium truncate">{tokenData.marketCap || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs">Chain</span>
|
||||
<p className="font-medium capitalize truncate">{tokenData.chain}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick actions - 4 buttons in grid, responsive */}
|
||||
<div className="grid grid-cols-4 gap-1 mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-1.5 text-xs flex items-center justify-center"
|
||||
onClick={() => handleQuickAction("safety")}
|
||||
>
|
||||
<Shield className="h-3 w-3 mr-0.5 flex-shrink-0" />
|
||||
<span className="truncate">Safety</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-1.5 text-xs flex items-center justify-center"
|
||||
onClick={() => handleQuickAction("holders")}
|
||||
>
|
||||
<Users className="h-3 w-3 mr-0.5 flex-shrink-0" />
|
||||
<span className="truncate">Holders</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-1.5 text-xs flex items-center justify-center"
|
||||
onClick={() => handleQuickAction("predict")}
|
||||
>
|
||||
<TrendingUp className="h-3 w-3 mr-0.5 flex-shrink-0" />
|
||||
<span className="truncate">Predict</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-1.5 text-xs flex items-center justify-center text-orange-600 hover:text-orange-700 hover:bg-orange-50 dark:text-orange-400 dark:hover:bg-orange-950"
|
||||
onClick={() => handleQuickAction("rug")}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3 mr-0.5 flex-shrink-0" />
|
||||
<span className="truncate">Rug</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
surfsense_browser_extension/sidepanel/hooks/index.ts
Normal file
4
surfsense_browser_extension/sidepanel/hooks/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Hooks for SurfSense Browser Extension
|
||||
|
||||
export { useContextAction, getMessageForAction, type ContextAction } from "./useContextAction";
|
||||
export { useKeyboardShortcuts, getMessageForKeyboardAction, type KeyboardAction } from "./useKeyboardShortcuts";
|
||||
104
surfsense_browser_extension/sidepanel/hooks/useContextAction.ts
Normal file
104
surfsense_browser_extension/sidepanel/hooks/useContextAction.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Storage } from "@plasmohq/storage";
|
||||
|
||||
export interface ContextAction {
|
||||
action: string;
|
||||
text: string;
|
||||
pageUrl?: string;
|
||||
linkUrl?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to handle context menu actions from background script
|
||||
* Returns pending action and a function to clear it
|
||||
*/
|
||||
export function useContextAction() {
|
||||
const [pendingAction, setPendingAction] = useState<ContextAction | null>(null);
|
||||
|
||||
// Check for pending context action on mount and when sidepanel gains focus
|
||||
const checkPendingAction = useCallback(async () => {
|
||||
const storage = new Storage({ area: "local" });
|
||||
const action = await storage.get<ContextAction>("pendingContextAction");
|
||||
|
||||
if (action && action.timestamp) {
|
||||
// Only process actions from last 30 seconds
|
||||
const isRecent = Date.now() - action.timestamp < 30000;
|
||||
if (isRecent) {
|
||||
setPendingAction(action);
|
||||
// Clear the pending action
|
||||
await storage.remove("pendingContextAction");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Check on mount
|
||||
checkPendingAction();
|
||||
|
||||
// Check when window gains focus (sidepanel opened)
|
||||
const handleFocus = () => {
|
||||
checkPendingAction();
|
||||
};
|
||||
|
||||
window.addEventListener("focus", handleFocus);
|
||||
|
||||
// Also listen for visibility change
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
checkPendingAction();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [checkPendingAction]);
|
||||
|
||||
const clearAction = useCallback(() => {
|
||||
setPendingAction(null);
|
||||
}, []);
|
||||
|
||||
return { pendingAction, clearAction, checkPendingAction };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate chat message based on context action
|
||||
*/
|
||||
export function getMessageForAction(action: ContextAction): string | null {
|
||||
const text = action.text;
|
||||
|
||||
switch (action.action) {
|
||||
case "analyze-token":
|
||||
return `Analyze token: ${text}`;
|
||||
case "check-safety":
|
||||
return `Is ${text} safe? Check for rug pull risks.`;
|
||||
case "add-watchlist":
|
||||
return `Add ${text} to my watchlist`;
|
||||
case "copy-address":
|
||||
// This is handled differently - just copy to clipboard
|
||||
if (text) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
return null;
|
||||
case "view-explorer":
|
||||
// Detect chain and open explorer
|
||||
if (text.startsWith("0x") && text.length === 42) {
|
||||
// Ethereum address
|
||||
window.open(`https://etherscan.io/address/${text}`, "_blank");
|
||||
} else if (text.length >= 32 && text.length <= 44) {
|
||||
// Solana address
|
||||
window.open(`https://solscan.io/account/${text}`, "_blank");
|
||||
}
|
||||
return null;
|
||||
case "capture-page":
|
||||
return "Capture this page to my knowledge base";
|
||||
case "ask-ai-page":
|
||||
return "What is this page about? Summarize the key information.";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Storage } from "@plasmohq/storage";
|
||||
|
||||
export interface KeyboardAction {
|
||||
action: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to handle keyboard shortcut actions from background script
|
||||
* Returns pending action and a function to clear it
|
||||
*
|
||||
* Keyboard shortcuts defined in manifest:
|
||||
* - open-sidepanel: Ctrl+Shift+S (just opens panel, no message)
|
||||
* - analyze-token: Ctrl+Shift+A
|
||||
* - add-watchlist: Ctrl+Shift+W
|
||||
* - capture-page: Ctrl+Shift+C
|
||||
* - show-portfolio: Ctrl+Shift+P
|
||||
*/
|
||||
export function useKeyboardShortcuts() {
|
||||
const [pendingAction, setPendingAction] = useState<KeyboardAction | null>(null);
|
||||
|
||||
// Check for pending keyboard action on mount and when sidepanel gains focus
|
||||
const checkPendingAction = useCallback(async () => {
|
||||
const storage = new Storage({ area: "local" });
|
||||
const action = await storage.get<KeyboardAction>("pendingKeyboardAction");
|
||||
|
||||
if (action && action.timestamp) {
|
||||
// Only process actions from last 30 seconds
|
||||
const isRecent = Date.now() - action.timestamp < 30000;
|
||||
if (isRecent) {
|
||||
setPendingAction(action);
|
||||
// Clear the pending action
|
||||
await storage.remove("pendingKeyboardAction");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Check on mount
|
||||
checkPendingAction();
|
||||
|
||||
// Check when window gains focus (sidepanel opened)
|
||||
const handleFocus = () => {
|
||||
checkPendingAction();
|
||||
};
|
||||
|
||||
window.addEventListener("focus", handleFocus);
|
||||
|
||||
// Also listen for visibility change
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
checkPendingAction();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [checkPendingAction]);
|
||||
|
||||
const clearAction = useCallback(() => {
|
||||
setPendingAction(null);
|
||||
}, []);
|
||||
|
||||
return { pendingAction, clearAction, checkPendingAction };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate chat message based on keyboard shortcut action
|
||||
* Returns null for actions that don't need a chat message (like open-sidepanel)
|
||||
*/
|
||||
export function getMessageForKeyboardAction(action: KeyboardAction): string | null {
|
||||
switch (action.action) {
|
||||
case "open-sidepanel":
|
||||
// Just opens the panel, no message needed
|
||||
return null;
|
||||
case "analyze-token":
|
||||
return "Analyze the current token on this page";
|
||||
case "add-watchlist":
|
||||
return "Add the current token to my watchlist";
|
||||
case "capture-page":
|
||||
return "Capture this page to my knowledge base";
|
||||
case "show-portfolio":
|
||||
return "Show my portfolio";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
24
surfsense_browser_extension/sidepanel/index.tsx
Normal file
24
surfsense_browser_extension/sidepanel/index.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Toaster } from "@/routes/ui/toaster";
|
||||
import { ChatInterface } from "./chat/ChatInterface";
|
||||
import { PageContextProvider } from "./context/PageContextProvider";
|
||||
|
||||
/**
|
||||
* Main Side Panel Application
|
||||
* Provides AI chat interface with page context awareness
|
||||
*/
|
||||
export function SidePanelApp() {
|
||||
return (
|
||||
<PageContextProvider>
|
||||
<MemoryRouter>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Main chat interface */}
|
||||
<ChatInterface />
|
||||
|
||||
{/* Toast notifications */}
|
||||
<Toaster />
|
||||
</div>
|
||||
</MemoryRouter>
|
||||
</PageContextProvider>
|
||||
);
|
||||
}
|
||||
631
surfsense_browser_extension/sidepanel/mock/mockData.ts
Normal file
631
surfsense_browser_extension/sidepanel/mock/mockData.ts
Normal file
|
|
@ -0,0 +1,631 @@
|
|||
/**
|
||||
* Mock data for testing SurfSense Extension UI
|
||||
* Remove or disable in production
|
||||
*/
|
||||
|
||||
import type { TokenData } from "../context/PageContextProvider";
|
||||
import type { WatchlistToken, WatchlistAlert } from "../crypto/WatchlistPanel";
|
||||
import type { SafetyFactor } from "../crypto/SafetyScoreDisplay";
|
||||
import type { AlertConfig } from "../crypto/AlertConfigModal";
|
||||
import type { WhaleTransaction } from "../whale/WhaleActivityFeed";
|
||||
import type { TokenAnalysisData } from "../analysis/TokenAnalysisPanel";
|
||||
import type { TradingSuggestion } from "../analysis/TradingSuggestionPanel";
|
||||
import type { PortfolioData } from "../portfolio/PortfolioPanel";
|
||||
|
||||
// ============================================
|
||||
// MOCK TOKEN DATA (DexScreener)
|
||||
// ============================================
|
||||
|
||||
export const MOCK_TOKEN_DATA: TokenData & {
|
||||
priceChange24h: number;
|
||||
marketCap: string;
|
||||
tokenName: string;
|
||||
} = {
|
||||
chain: "solana",
|
||||
pairAddress: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
|
||||
tokenSymbol: "BULLA",
|
||||
tokenName: "Bulla Token",
|
||||
price: "$0.00001234",
|
||||
priceChange24h: 156.7,
|
||||
volume24h: "$1.2M",
|
||||
liquidity: "$450K",
|
||||
marketCap: "$2.1M",
|
||||
};
|
||||
|
||||
export const MOCK_TOKEN_DATA_BEARISH: TokenData & {
|
||||
priceChange24h: number;
|
||||
marketCap: string;
|
||||
tokenName: string;
|
||||
} = {
|
||||
chain: "ethereum",
|
||||
pairAddress: "0x1234567890abcdef1234567890abcdef12345678",
|
||||
tokenSymbol: "REKT",
|
||||
tokenName: "Rekt Token",
|
||||
price: "$0.00000042",
|
||||
priceChange24h: -78.5,
|
||||
volume24h: "$89K",
|
||||
liquidity: "$12K",
|
||||
marketCap: "$156K",
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// MOCK SAFETY ANALYSIS
|
||||
// ============================================
|
||||
|
||||
export const MOCK_SAFETY_SCORE = 72;
|
||||
|
||||
export const MOCK_SAFETY_FACTORS: SafetyFactor[] = [
|
||||
// Positive factors
|
||||
{
|
||||
category: "Liquidity",
|
||||
status: "positive",
|
||||
description: "Liquidity pool is locked for 12 months",
|
||||
},
|
||||
{
|
||||
category: "Contract",
|
||||
status: "positive",
|
||||
description: "Contract is verified on Solscan",
|
||||
},
|
||||
{
|
||||
category: "Age",
|
||||
status: "positive",
|
||||
description: "Token has been active for 45 days",
|
||||
},
|
||||
// Warning factors
|
||||
{
|
||||
category: "Holders",
|
||||
status: "warning",
|
||||
description: "Top 10 holders own 35% of supply",
|
||||
},
|
||||
{
|
||||
category: "Volume",
|
||||
status: "warning",
|
||||
description: "Trading volume decreased 40% in last 24h",
|
||||
},
|
||||
// Danger factors
|
||||
{
|
||||
category: "Mint Authority",
|
||||
status: "danger",
|
||||
description: "Mint authority is NOT revoked - tokens can be minted",
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_SAFETY_SOURCES = [
|
||||
"RugCheck.xyz",
|
||||
"GoPlus Security",
|
||||
"Solscan",
|
||||
"DexScreener",
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// MOCK WATCHLIST
|
||||
// ============================================
|
||||
|
||||
export const MOCK_WATCHLIST_TOKENS: WatchlistToken[] = [
|
||||
{
|
||||
id: "1",
|
||||
symbol: "BULLA",
|
||||
name: "Bulla Token",
|
||||
chain: "solana",
|
||||
contractAddress: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
|
||||
price: "$0.00001234",
|
||||
priceChange24h: 156.7,
|
||||
hasAlerts: true,
|
||||
alertCount: 2,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
symbol: "BONK",
|
||||
name: "Bonk",
|
||||
chain: "solana",
|
||||
contractAddress: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
|
||||
price: "$0.00002156",
|
||||
priceChange24h: 12.3,
|
||||
hasAlerts: true,
|
||||
alertCount: 1,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
symbol: "WIF",
|
||||
name: "dogwifhat",
|
||||
chain: "solana",
|
||||
contractAddress: "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm",
|
||||
price: "$2.45",
|
||||
priceChange24h: -5.2,
|
||||
hasAlerts: false,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
symbol: "PEPE",
|
||||
name: "Pepe",
|
||||
chain: "ethereum",
|
||||
contractAddress: "0x6982508145454Ce325dDbE47a25d4ec3d2311933",
|
||||
price: "$0.00001089",
|
||||
priceChange24h: 8.7,
|
||||
hasAlerts: false,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
symbol: "DEGEN",
|
||||
name: "Degen",
|
||||
chain: "base",
|
||||
contractAddress: "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed",
|
||||
price: "$0.0156",
|
||||
priceChange24h: -15.3,
|
||||
hasAlerts: true,
|
||||
alertCount: 3,
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_WATCHLIST_ALERTS: WatchlistAlert[] = [
|
||||
{
|
||||
id: "alert-1",
|
||||
tokenSymbol: "BULLA",
|
||||
type: "price",
|
||||
message: "BULLA price increased above $0.00001 (+156%)",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 5), // 5 mins ago
|
||||
isRead: false,
|
||||
},
|
||||
{
|
||||
id: "alert-2",
|
||||
tokenSymbol: "BULLA",
|
||||
type: "whale",
|
||||
message: "Large transaction detected: 500M BULLA ($6,170) transferred",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 15), // 15 mins ago
|
||||
isRead: false,
|
||||
},
|
||||
{
|
||||
id: "alert-3",
|
||||
tokenSymbol: "DEGEN",
|
||||
type: "volume",
|
||||
message: "DEGEN volume spike: 5x average in last hour",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 30), // 30 mins ago
|
||||
isRead: true,
|
||||
},
|
||||
{
|
||||
id: "alert-4",
|
||||
tokenSymbol: "BONK",
|
||||
type: "liquidity",
|
||||
message: "BONK liquidity increased by 25% ($1.2M added)",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago
|
||||
isRead: true,
|
||||
},
|
||||
{
|
||||
id: "alert-5",
|
||||
tokenSymbol: "DEGEN",
|
||||
type: "price",
|
||||
message: "DEGEN dropped below $0.02 (-15%)",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 120), // 2 hours ago
|
||||
isRead: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// MOCK WHALE TRANSACTIONS
|
||||
// ============================================
|
||||
|
||||
export const MOCK_WHALE_TRANSACTIONS: WhaleTransaction[] = [
|
||||
{
|
||||
id: "whale-1",
|
||||
tokenSymbol: "BULLA",
|
||||
tokenName: "Bulla Token",
|
||||
chain: "solana",
|
||||
type: "buy",
|
||||
amountUSD: 100000,
|
||||
amountTokens: "8.1B",
|
||||
walletAddress: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
|
||||
txHash: "5Kn8WqXZKqYqKqYqKqYqKqYqKqYqKqYqKqYqKqYqKqYq",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 2), // 2 mins ago
|
||||
isSmartMoney: true,
|
||||
isInWatchlist: true,
|
||||
},
|
||||
{
|
||||
id: "whale-2",
|
||||
tokenSymbol: "BONK",
|
||||
tokenName: "Bonk",
|
||||
chain: "solana",
|
||||
type: "sell",
|
||||
amountUSD: 50000,
|
||||
amountTokens: "2.3B",
|
||||
walletAddress: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
|
||||
txHash: "3Hn7WpXZKpYpKpYpKpYpKpYpKpYpKpYpKpYpKpYpKpYp",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 5), // 5 mins ago
|
||||
isSmartMoney: false,
|
||||
isInWatchlist: true,
|
||||
},
|
||||
{
|
||||
id: "whale-3",
|
||||
tokenSymbol: "PEPE",
|
||||
tokenName: "Pepe",
|
||||
chain: "ethereum",
|
||||
type: "buy",
|
||||
amountUSD: 250000,
|
||||
amountTokens: "23B",
|
||||
walletAddress: "0x6982508145454Ce325dDbE47a25d4ec3d2311933",
|
||||
txHash: "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 10), // 10 mins ago
|
||||
isSmartMoney: true,
|
||||
isInWatchlist: true,
|
||||
},
|
||||
{
|
||||
id: "whale-4",
|
||||
tokenSymbol: "WIF",
|
||||
tokenName: "dogwifhat",
|
||||
chain: "solana",
|
||||
type: "buy",
|
||||
amountUSD: 75000,
|
||||
amountTokens: "30.6K",
|
||||
walletAddress: "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm",
|
||||
txHash: "4Jm6VoXYJoXoJoXoJoXoJoXoJoXoJoXoJoXoJoXoJoXo",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 15), // 15 mins ago
|
||||
isSmartMoney: false,
|
||||
isInWatchlist: true,
|
||||
},
|
||||
{
|
||||
id: "whale-5",
|
||||
tokenSymbol: "DEGEN",
|
||||
tokenName: "Degen",
|
||||
chain: "base",
|
||||
type: "sell",
|
||||
amountUSD: 35000,
|
||||
amountTokens: "2.2M",
|
||||
walletAddress: "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed",
|
||||
txHash: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 20), // 20 mins ago
|
||||
isSmartMoney: false,
|
||||
isInWatchlist: true,
|
||||
},
|
||||
{
|
||||
id: "whale-6",
|
||||
tokenSymbol: "SOL",
|
||||
tokenName: "Solana",
|
||||
chain: "solana",
|
||||
type: "buy",
|
||||
amountUSD: 500000,
|
||||
amountTokens: "5K",
|
||||
walletAddress: "So11111111111111111111111111111111111111112",
|
||||
txHash: "6Lp7XqYZLqZqLqZqLqZqLqZqLqZqLqZqLqZqLqZqLqZq",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 30), // 30 mins ago
|
||||
isSmartMoney: true,
|
||||
isInWatchlist: false,
|
||||
},
|
||||
{
|
||||
id: "whale-7",
|
||||
tokenSymbol: "MATIC",
|
||||
tokenName: "Polygon",
|
||||
chain: "ethereum",
|
||||
type: "buy",
|
||||
amountUSD: 150000,
|
||||
amountTokens: "200K",
|
||||
walletAddress: "0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0",
|
||||
txHash: "0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 45), // 45 mins ago
|
||||
isSmartMoney: false,
|
||||
isInWatchlist: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// MOCK TOKEN ANALYSIS
|
||||
// ============================================
|
||||
|
||||
export const MOCK_TOKEN_ANALYSIS: TokenAnalysisData = {
|
||||
tokenAddress: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
|
||||
tokenSymbol: "BULLA",
|
||||
tokenName: "Bulla Token",
|
||||
chain: "solana",
|
||||
timestamp: new Date(),
|
||||
|
||||
contract: {
|
||||
verified: true,
|
||||
renounced: true,
|
||||
isProxy: false,
|
||||
sourceCode: true,
|
||||
},
|
||||
|
||||
holders: {
|
||||
count: 1234,
|
||||
top10Percent: 35,
|
||||
distribution: [
|
||||
{ address: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", percent: 8.5 },
|
||||
{ address: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", percent: 6.2 },
|
||||
{ address: "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm", percent: 5.8 },
|
||||
],
|
||||
},
|
||||
|
||||
liquidity: {
|
||||
totalUSD: 50000,
|
||||
lpLocked: true,
|
||||
lpLockDuration: 90,
|
||||
liquidityMcapRatio: 0.15,
|
||||
},
|
||||
|
||||
volume: {
|
||||
volume24h: 100000,
|
||||
trend: "increasing",
|
||||
volumeLiquidityRatio: 2.0,
|
||||
},
|
||||
|
||||
price: {
|
||||
current: 0.0001234,
|
||||
ath: 0.0005,
|
||||
atl: 0.00001,
|
||||
change7d: 15.5,
|
||||
change30d: 45.2,
|
||||
volatility: 12.5,
|
||||
},
|
||||
|
||||
social: {
|
||||
twitterMentions: 500,
|
||||
telegramActivity: 1200,
|
||||
redditDiscussions: 45,
|
||||
sentimentScore: 0.75,
|
||||
sentiment: "positive",
|
||||
},
|
||||
|
||||
aiSummary: "BULLA shows strong holder distribution with verified contract and renounced ownership. Volume increasing 200% in 24h with locked liquidity for 90 days. Social sentiment is highly positive with growing community engagement. Moderate risk profile with good upside potential.",
|
||||
recommendation: "buy",
|
||||
confidence: 75,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// MOCK TRADING SUGGESTION
|
||||
// ============================================
|
||||
|
||||
export const MOCK_TRADING_SUGGESTION: TradingSuggestion = {
|
||||
tokenAddress: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
|
||||
tokenSymbol: "BULLA",
|
||||
tokenName: "Bulla Token",
|
||||
chain: "solana",
|
||||
currentPrice: 0.0001234,
|
||||
timestamp: new Date(),
|
||||
|
||||
entry: {
|
||||
min: 0.0001100,
|
||||
max: 0.0001250,
|
||||
reasoning: "Strong support zone at 0.00011 with high volume. Current price offers good risk/reward entry.",
|
||||
},
|
||||
|
||||
targets: [
|
||||
{
|
||||
level: 1,
|
||||
price: 0.0001800,
|
||||
percentGain: 45.8,
|
||||
confidence: 85,
|
||||
},
|
||||
{
|
||||
level: 2,
|
||||
price: 0.0002500,
|
||||
percentGain: 102.6,
|
||||
confidence: 70,
|
||||
},
|
||||
{
|
||||
level: 3,
|
||||
price: 0.0003500,
|
||||
percentGain: 183.7,
|
||||
confidence: 50,
|
||||
},
|
||||
],
|
||||
|
||||
stopLoss: {
|
||||
price: 0.0000950,
|
||||
percentLoss: -23.0,
|
||||
reasoning: "Below key support level. Invalidates bullish structure if broken.",
|
||||
},
|
||||
|
||||
riskReward: 3.2,
|
||||
overallConfidence: 78,
|
||||
|
||||
technicalLevels: {
|
||||
support: [0.0001100, 0.0000950, 0.0000800],
|
||||
resistance: [0.0001800, 0.0002500, 0.0003500],
|
||||
},
|
||||
|
||||
reasoning: [
|
||||
"Strong accumulation pattern forming on 4H chart",
|
||||
"Volume profile shows increasing buyer interest",
|
||||
"RSI showing bullish divergence at support",
|
||||
"Whale wallets accumulating over past 48 hours",
|
||||
"Social sentiment turning positive with growing community",
|
||||
],
|
||||
|
||||
invalidationConditions: [
|
||||
"Break below 0.000095 with high volume",
|
||||
"Sudden large holder dumping (>5% supply)",
|
||||
"Liquidity removal or unlock event",
|
||||
"Negative news or security concerns",
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// MOCK PORTFOLIO DATA
|
||||
// ============================================
|
||||
|
||||
export const MOCK_PORTFOLIO: PortfolioData = {
|
||||
wallets: [
|
||||
{
|
||||
address: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
|
||||
chain: "solana",
|
||||
type: "phantom",
|
||||
},
|
||||
{
|
||||
address: "0x6982508145454Ce325dDbE47a25d4ec3d2311933",
|
||||
chain: "ethereum",
|
||||
type: "metamask",
|
||||
},
|
||||
],
|
||||
|
||||
totalValue: 12450.50,
|
||||
change24h: 850.25,
|
||||
change24hPercent: 7.33,
|
||||
|
||||
holdings: [
|
||||
{
|
||||
tokenAddress: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
|
||||
chain: "solana",
|
||||
symbol: "BULLA",
|
||||
name: "Bulla Token",
|
||||
amount: "8,100,000",
|
||||
currentPrice: 0.0001234,
|
||||
currentValue: 1000.00,
|
||||
change24h: 150.00,
|
||||
change24hPercent: 17.65,
|
||||
entryPrice: 0.0001000,
|
||||
pnl: 189.54,
|
||||
pnlPercent: 23.4,
|
||||
},
|
||||
{
|
||||
tokenAddress: "So11111111111111111111111111111111111111112",
|
||||
chain: "solana",
|
||||
symbol: "SOL",
|
||||
name: "Solana",
|
||||
amount: "50",
|
||||
currentPrice: 100.50,
|
||||
currentValue: 5025.00,
|
||||
change24h: 250.00,
|
||||
change24hPercent: 5.24,
|
||||
entryPrice: 95.00,
|
||||
pnl: 275.00,
|
||||
pnlPercent: 5.79,
|
||||
},
|
||||
{
|
||||
tokenAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
|
||||
chain: "solana",
|
||||
symbol: "BONK",
|
||||
name: "Bonk",
|
||||
amount: "2,300,000,000",
|
||||
currentPrice: 0.00001500,
|
||||
currentValue: 3450.00,
|
||||
change24h: -125.00,
|
||||
change24hPercent: -3.50,
|
||||
entryPrice: 0.00001200,
|
||||
pnl: 690.00,
|
||||
pnlPercent: 25.0,
|
||||
},
|
||||
{
|
||||
tokenAddress: "0x6982508145454Ce325dDbE47a25d4ec3d2311933",
|
||||
chain: "ethereum",
|
||||
symbol: "PEPE",
|
||||
name: "Pepe",
|
||||
amount: "23,000,000,000",
|
||||
currentPrice: 0.00000012,
|
||||
currentValue: 2760.00,
|
||||
change24h: 180.00,
|
||||
change24hPercent: 6.98,
|
||||
entryPrice: 0.00000010,
|
||||
pnl: 460.00,
|
||||
pnlPercent: 20.0,
|
||||
},
|
||||
{
|
||||
tokenAddress: "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm",
|
||||
chain: "solana",
|
||||
symbol: "WIF",
|
||||
name: "dogwifhat",
|
||||
amount: "100",
|
||||
currentPrice: 2.15,
|
||||
currentValue: 215.50,
|
||||
change24h: -15.50,
|
||||
change24hPercent: -6.71,
|
||||
entryPrice: 2.50,
|
||||
pnl: -35.00,
|
||||
pnlPercent: -14.0,
|
||||
},
|
||||
],
|
||||
|
||||
analytics: {
|
||||
bestPerformer: {
|
||||
symbol: "BONK",
|
||||
change: 25.0,
|
||||
},
|
||||
worstPerformer: {
|
||||
symbol: "WIF",
|
||||
change: -14.0,
|
||||
},
|
||||
winRate: 80,
|
||||
avgHoldTime: 14,
|
||||
totalTrades: 25,
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// MOCK ALERT CONFIGS
|
||||
// ============================================
|
||||
|
||||
export const MOCK_ALERT_CONFIGS: AlertConfig[] = [
|
||||
{ type: "price_above", threshold: 0.00002, enabled: true },
|
||||
{ type: "price_below", threshold: 0.000005, enabled: true },
|
||||
{ type: "price_change", threshold: 20, enabled: false },
|
||||
{ type: "volume", threshold: 3, enabled: true },
|
||||
{ type: "whale", threshold: 10000, enabled: false },
|
||||
{ type: "liquidity", threshold: 30, enabled: false },
|
||||
{ type: "holder_concentration", threshold: 50, enabled: false },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// MOCK SEARCH SPACES
|
||||
// ============================================
|
||||
|
||||
export const MOCK_SEARCH_SPACES = [
|
||||
{ id: "crypto", name: "Crypto", icon: "🪙" },
|
||||
{ id: "general", name: "General", icon: "📚" },
|
||||
{ id: "research", name: "Research", icon: "🔬" },
|
||||
{ id: "defi", name: "DeFi", icon: "💰" },
|
||||
{ id: "nft", name: "NFT", icon: "🖼️" },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// MOCK CHAT MESSAGES
|
||||
// ============================================
|
||||
|
||||
export const MOCK_CHAT_MESSAGES = [
|
||||
{
|
||||
id: "msg-1",
|
||||
role: "user" as const,
|
||||
content: "Is BULLA token safe to invest?",
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 5),
|
||||
},
|
||||
{
|
||||
id: "msg-2",
|
||||
role: "assistant" as const,
|
||||
content: `Based on my analysis of BULLA token, here's what I found:
|
||||
|
||||
**Safety Score: 72/100** ⚠️ Medium Risk
|
||||
|
||||
**✅ Positive Signals:**
|
||||
- Liquidity is locked for 12 months
|
||||
- Contract is verified
|
||||
- Active for 45 days
|
||||
|
||||
**⚠️ Warnings:**
|
||||
- Top 10 holders own 35% of supply
|
||||
- Volume decreased 40% recently
|
||||
|
||||
**🚨 Red Flags:**
|
||||
- Mint authority is NOT revoked
|
||||
|
||||
**Recommendation:** Proceed with caution. The unlocked mint authority is a significant risk factor.`,
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 4),
|
||||
thinkingSteps: [
|
||||
{ id: "1", type: "thinking" as const, title: "Understanding your question...", isComplete: true },
|
||||
{ id: "2", type: "searching" as const, title: "Fetching token data from DexScreener...", isComplete: true },
|
||||
{ id: "3", type: "analyzing" as const, title: "Running safety analysis...", isComplete: true },
|
||||
{ id: "4", type: "complete" as const, title: "Analysis complete", isComplete: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// FEATURE FLAGS
|
||||
// ============================================
|
||||
|
||||
export const MOCK_MODE = {
|
||||
/** Enable mock data for development/testing */
|
||||
enabled: true,
|
||||
/** Simulate DexScreener page context */
|
||||
simulateDexScreener: true,
|
||||
/** Show mock watchlist data */
|
||||
showWatchlist: true,
|
||||
/** Show mock alerts */
|
||||
showAlerts: true,
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Wallet,
|
||||
Plus,
|
||||
BarChart3,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
Star,
|
||||
Bell,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface PortfolioHolding {
|
||||
tokenAddress: string;
|
||||
chain: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
amount: string;
|
||||
currentPrice: number;
|
||||
currentValue: number;
|
||||
change24h: number;
|
||||
change24hPercent: number;
|
||||
entryPrice?: number;
|
||||
pnl?: number;
|
||||
pnlPercent?: number;
|
||||
}
|
||||
|
||||
export interface PortfolioAnalytics {
|
||||
bestPerformer: { symbol: string; change: number };
|
||||
worstPerformer: { symbol: string; change: number };
|
||||
winRate: number;
|
||||
avgHoldTime: number;
|
||||
totalTrades: number;
|
||||
}
|
||||
|
||||
export interface PortfolioData {
|
||||
wallets: {
|
||||
address: string;
|
||||
chain: string;
|
||||
type: "metamask" | "phantom" | "coinbase";
|
||||
}[];
|
||||
|
||||
totalValue: number;
|
||||
change24h: number;
|
||||
change24hPercent: number;
|
||||
|
||||
holdings: PortfolioHolding[];
|
||||
analytics: PortfolioAnalytics;
|
||||
}
|
||||
|
||||
export interface PortfolioPanelProps {
|
||||
/** Portfolio data */
|
||||
portfolio: PortfolioData;
|
||||
/** Callback when refresh is clicked */
|
||||
onRefresh?: () => void;
|
||||
/** Callback when "Analyze" is clicked for a token */
|
||||
onAnalyzeToken?: (holding: PortfolioHolding) => void;
|
||||
/** Callback when "Set Alert" is clicked for a token */
|
||||
onSetAlert?: (holding: PortfolioHolding) => void;
|
||||
/** Callback when "View on DexScreener" is clicked */
|
||||
onViewToken?: (holding: PortfolioHolding) => void;
|
||||
/** Callback when "Add Manual Position" is clicked */
|
||||
onAddPosition?: () => void;
|
||||
/** Whether data is loading */
|
||||
isLoading?: boolean;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PortfolioPanel - Portfolio tracker with holdings and P&L
|
||||
*
|
||||
* Features:
|
||||
* - Total portfolio value and 24h change
|
||||
* - List of holdings with current value and P&L
|
||||
* - Performance analytics (best/worst performers, win rate)
|
||||
* - Quick actions per token (analyze, alert, view)
|
||||
* - Manual position entry
|
||||
*/
|
||||
export function PortfolioPanel({
|
||||
portfolio,
|
||||
onRefresh,
|
||||
onAnalyzeToken,
|
||||
onSetAlert,
|
||||
onViewToken,
|
||||
onAddPosition,
|
||||
isLoading = false,
|
||||
className,
|
||||
}: PortfolioPanelProps) {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await onRefresh?.();
|
||||
setTimeout(() => setIsRefreshing(false), 1000);
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
if (value >= 1000000) return `$${(value / 1000000).toFixed(2)}M`;
|
||||
if (value >= 1000) return `$${(value / 1000).toFixed(1)}K`;
|
||||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const formatPercent = (value: number) => {
|
||||
const sign = value >= 0 ? "+" : "";
|
||||
return `${sign}${value.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="font-semibold">Portfolio</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{portfolio.holdings.length} tokens
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Total Value */}
|
||||
<div className="p-4 border-b bg-muted/30">
|
||||
<div className="text-xs text-muted-foreground mb-1">Total Value</div>
|
||||
<div className="flex items-baseline gap-2 mb-2">
|
||||
<span className="font-bold text-3xl">{formatCurrency(portfolio.totalValue)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"font-semibold text-sm",
|
||||
portfolio.change24hPercent >= 0 ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"
|
||||
)}>
|
||||
{formatPercent(portfolio.change24hPercent)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({portfolio.change24h >= 0 ? "+" : ""}{formatCurrency(portfolio.change24h)}) 24h
|
||||
</span>
|
||||
{portfolio.change24hPercent >= 0 ? (
|
||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Holdings List */}
|
||||
<div className="divide-y">
|
||||
{portfolio.holdings.map((holding) => (
|
||||
<div key={`${holding.chain}-${holding.tokenAddress}`} className="p-4 hover:bg-muted/50 transition-colors">
|
||||
{/* Token Info */}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{holding.symbol}</span>
|
||||
<ChainIcon chain={holding.chain} size="xs" />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{holding.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold">{formatCurrency(holding.currentValue)}</div>
|
||||
<div className={cn(
|
||||
"text-xs font-medium",
|
||||
holding.change24hPercent >= 0 ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"
|
||||
)}>
|
||||
{formatPercent(holding.change24hPercent)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount and Price */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mb-3">
|
||||
<span>{holding.amount} tokens</span>
|
||||
<span>${holding.currentPrice.toFixed(6)}</span>
|
||||
</div>
|
||||
|
||||
{/* P&L (if available) */}
|
||||
{holding.pnl !== undefined && holding.pnlPercent !== undefined && (
|
||||
<div className="flex items-center justify-between mb-3 p-2 bg-muted/50 rounded">
|
||||
<span className="text-xs text-muted-foreground">P&L</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"text-xs font-semibold",
|
||||
holding.pnl >= 0 ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"
|
||||
)}>
|
||||
{holding.pnl >= 0 ? "+" : ""}{formatCurrency(holding.pnl)}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-xs font-medium",
|
||||
holding.pnlPercent >= 0 ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"
|
||||
)}>
|
||||
({formatPercent(holding.pnlPercent)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-xs"
|
||||
onClick={() => onAnalyzeToken?.(holding)}
|
||||
>
|
||||
<BarChart3 className="h-3 w-3 mr-1" />
|
||||
Analyze
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-xs"
|
||||
onClick={() => onSetAlert?.(holding)}
|
||||
>
|
||||
<Bell className="h-3 w-3 mr-1" />
|
||||
Alert
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => onViewToken?.(holding)}
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Position Button */}
|
||||
<div className="p-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={onAddPosition}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Manual Position
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Performance Analytics */}
|
||||
<div className="p-4 border-t bg-muted/30">
|
||||
<h3 className="font-semibold text-sm mb-3">Performance</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-2 bg-background rounded">
|
||||
<span className="text-xs text-muted-foreground">Best Performer</span>
|
||||
<span className="text-xs font-semibold text-green-600 dark:text-green-400">
|
||||
{portfolio.analytics.bestPerformer.symbol} (+{portfolio.analytics.bestPerformer.change.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 bg-background rounded">
|
||||
<span className="text-xs text-muted-foreground">Worst Performer</span>
|
||||
<span className="text-xs font-semibold text-red-600 dark:text-red-400">
|
||||
{portfolio.analytics.worstPerformer.symbol} ({portfolio.analytics.worstPerformer.change.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 bg-background rounded">
|
||||
<span className="text-xs text-muted-foreground">Win Rate</span>
|
||||
<span className="text-xs font-semibold">{portfolio.analytics.winRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Bell, BellOff, Volume2, VolumeX, Clock, Filter, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
|
||||
export interface NotificationSettings {
|
||||
enabled: boolean;
|
||||
sound: boolean;
|
||||
quietHoursEnabled: boolean;
|
||||
quietHoursStart: string;
|
||||
quietHoursEnd: string;
|
||||
groupNotifications: boolean;
|
||||
priorities: {
|
||||
high: boolean;
|
||||
medium: boolean;
|
||||
low: boolean;
|
||||
};
|
||||
categories: {
|
||||
priceAlerts: boolean;
|
||||
whaleActivity: boolean;
|
||||
rugPullWarnings: boolean;
|
||||
portfolioUpdates: boolean;
|
||||
newsAlerts: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationSettingsPanelProps {
|
||||
settings: NotificationSettings;
|
||||
onSettingsChange: (settings: NotificationSettings) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: NotificationSettings = {
|
||||
enabled: true,
|
||||
sound: true,
|
||||
quietHoursEnabled: false,
|
||||
quietHoursStart: "22:00",
|
||||
quietHoursEnd: "08:00",
|
||||
groupNotifications: true,
|
||||
priorities: {
|
||||
high: true,
|
||||
medium: true,
|
||||
low: false,
|
||||
},
|
||||
categories: {
|
||||
priceAlerts: true,
|
||||
whaleActivity: true,
|
||||
rugPullWarnings: true,
|
||||
portfolioUpdates: true,
|
||||
newsAlerts: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* NotificationSettingsPanel - Configure notification preferences
|
||||
* Part of Epic 4.4 - Smart Notifications
|
||||
*/
|
||||
export function NotificationSettingsPanel({
|
||||
settings = DEFAULT_SETTINGS,
|
||||
onSettingsChange,
|
||||
className,
|
||||
}: NotificationSettingsPanelProps) {
|
||||
const updateSettings = (partial: Partial<NotificationSettings>) => {
|
||||
onSettingsChange({ ...settings, ...partial });
|
||||
};
|
||||
|
||||
const updatePriority = (key: keyof NotificationSettings["priorities"], value: boolean) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
priorities: { ...settings.priorities, [key]: value },
|
||||
});
|
||||
};
|
||||
|
||||
const updateCategory = (key: keyof NotificationSettings["categories"], value: boolean) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
categories: { ...settings.categories, [key]: value },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border bg-card p-4 space-y-4", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Notification Settings</span>
|
||||
</div>
|
||||
<Button
|
||||
variant={settings.enabled ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => updateSettings({ enabled: !settings.enabled })}
|
||||
>
|
||||
{settings.enabled ? (
|
||||
<>
|
||||
<Bell className="h-4 w-4 mr-1" /> On
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BellOff className="h-4 w-4 mr-1" /> Off
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{settings.enabled && (
|
||||
<>
|
||||
{/* Sound Toggle */}
|
||||
<div className="flex items-center justify-between py-2 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
{settings.sound ? (
|
||||
<Volume2 className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<VolumeX className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm">Sound</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => updateSettings({ sound: !settings.sound })}
|
||||
>
|
||||
{settings.sound ? "On" : "Off"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quiet Hours */}
|
||||
<div className="space-y-2 py-2 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">Quiet Hours</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => updateSettings({ quietHoursEnabled: !settings.quietHoursEnabled })}
|
||||
>
|
||||
{settings.quietHoursEnabled ? "On" : "Off"}
|
||||
</Button>
|
||||
</div>
|
||||
{settings.quietHoursEnabled && (
|
||||
<div className="flex items-center gap-2 ml-6 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="time"
|
||||
value={settings.quietHoursStart}
|
||||
onChange={(e) => updateSettings({ quietHoursStart: e.target.value })}
|
||||
className="bg-muted rounded px-2 py-1"
|
||||
/>
|
||||
<span>to</span>
|
||||
<input
|
||||
type="time"
|
||||
value={settings.quietHoursEnd}
|
||||
onChange={(e) => updateSettings({ quietHoursEnd: e.target.value })}
|
||||
className="bg-muted rounded px-2 py-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priority Levels */}
|
||||
<div className="space-y-2 py-2 border-b">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Priority Levels</span>
|
||||
</div>
|
||||
<div className="space-y-1 ml-6">
|
||||
{(["high", "medium", "low"] as const).map((priority) => (
|
||||
<label key={priority} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.priorities[priority]}
|
||||
onChange={(e) => updatePriority(priority, e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className={cn(
|
||||
"text-xs capitalize",
|
||||
priority === "high" && "text-red-500",
|
||||
priority === "medium" && "text-yellow-500",
|
||||
priority === "low" && "text-muted-foreground"
|
||||
)}>
|
||||
{priority}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="space-y-2 py-2">
|
||||
<span className="text-sm font-medium">Categories</span>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(settings.categories).map(([key, value]) => (
|
||||
<label key={key} className="flex items-center justify-between cursor-pointer py-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(e) => updateCategory(key as keyof NotificationSettings["categories"], e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group Notifications */}
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<span className="text-sm">Group similar notifications</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => updateSettings({ groupNotifications: !settings.groupNotifications })}
|
||||
>
|
||||
{settings.groupNotifications ? "On" : "Off"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { Bell, AlertTriangle, TrendingUp, TrendingDown, Wallet, Fish, X, Check } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: "price_alert" | "whale_activity" | "rug_warning" | "portfolio" | "news";
|
||||
priority: "high" | "medium" | "low";
|
||||
title: string;
|
||||
message: string;
|
||||
tokenSymbol?: string;
|
||||
timestamp: Date;
|
||||
read: boolean;
|
||||
actionUrl?: string;
|
||||
}
|
||||
|
||||
export interface NotificationsListProps {
|
||||
notifications: Notification[];
|
||||
onMarkRead: (id: string) => void;
|
||||
onMarkAllRead: () => void;
|
||||
onDismiss: (id: string) => void;
|
||||
onNotificationClick?: (notification: Notification) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getNotificationIcon = (type: Notification["type"]) => {
|
||||
switch (type) {
|
||||
case "price_alert":
|
||||
return <TrendingUp className="h-4 w-4" />;
|
||||
case "whale_activity":
|
||||
return <Fish className="h-4 w-4" />;
|
||||
case "rug_warning":
|
||||
return <AlertTriangle className="h-4 w-4" />;
|
||||
case "portfolio":
|
||||
return <Wallet className="h-4 w-4" />;
|
||||
case "news":
|
||||
return <Bell className="h-4 w-4" />;
|
||||
default:
|
||||
return <Bell className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: Notification["priority"]) => {
|
||||
switch (priority) {
|
||||
case "high":
|
||||
return "border-l-red-500 bg-red-500/5";
|
||||
case "medium":
|
||||
return "border-l-yellow-500 bg-yellow-500/5";
|
||||
case "low":
|
||||
return "border-l-muted-foreground bg-muted/30";
|
||||
default:
|
||||
return "border-l-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (date: Date): string => {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return "Just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
/**
|
||||
* NotificationsList - Display and manage notifications
|
||||
* Part of Epic 4.4 - Smart Notifications
|
||||
*/
|
||||
export function NotificationsList({
|
||||
notifications,
|
||||
onMarkRead,
|
||||
onMarkAllRead,
|
||||
onDismiss,
|
||||
onNotificationClick,
|
||||
className,
|
||||
}: NotificationsListProps) {
|
||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border bg-card", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium text-sm">Notifications</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="bg-primary text-primary-foreground text-xs px-1.5 py-0.5 rounded-full">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={onMarkAllRead}>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground text-sm">
|
||||
<Bell className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No notifications yet</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-3 border-b border-l-2 cursor-pointer hover:bg-muted/50 transition-colors",
|
||||
getPriorityColor(notification.priority),
|
||||
!notification.read && "bg-primary/5"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!notification.read) onMarkRead(notification.id);
|
||||
onNotificationClick?.(notification);
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"p-1.5 rounded-full",
|
||||
notification.type === "rug_warning" ? "bg-red-500/20 text-red-500" :
|
||||
notification.type === "whale_activity" ? "bg-blue-500/20 text-blue-500" :
|
||||
notification.type === "price_alert" ? "bg-green-500/20 text-green-500" :
|
||||
"bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{getNotificationIcon(notification.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("text-sm font-medium", !notification.read && "text-foreground")}>
|
||||
{notification.title}
|
||||
</span>
|
||||
{notification.tokenSymbol && (
|
||||
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{notification.tokenSymbol}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
|
||||
{notification.message}
|
||||
</p>
|
||||
<span className="text-[10px] text-muted-foreground mt-1 block">
|
||||
{formatTime(notification.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss(notification.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
Settings,
|
||||
Bell,
|
||||
BellOff,
|
||||
Clock,
|
||||
Keyboard,
|
||||
Menu,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
|
||||
export interface NotificationSettings {
|
||||
enabled: boolean;
|
||||
priorities: {
|
||||
high: boolean;
|
||||
medium: boolean;
|
||||
low: boolean;
|
||||
};
|
||||
quietHours: {
|
||||
enabled: boolean;
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
groupNotifications: boolean;
|
||||
smartBatching: boolean;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
id: string;
|
||||
action: string;
|
||||
shortcut: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface QuickActionsSettings {
|
||||
contextMenuEnabled: boolean;
|
||||
autoDetectAddresses: boolean;
|
||||
}
|
||||
|
||||
export interface ProductivitySettingsData {
|
||||
notifications: NotificationSettings;
|
||||
shortcuts: KeyboardShortcut[];
|
||||
quickActions: QuickActionsSettings;
|
||||
}
|
||||
|
||||
export interface ProductivitySettingsPanelProps {
|
||||
/** Current settings */
|
||||
settings?: ProductivitySettingsData;
|
||||
/** Callback when settings are saved */
|
||||
onSave?: (settings: ProductivitySettingsData) => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProductivitySettingsPanel - Productivity settings management
|
||||
*
|
||||
* Features:
|
||||
* - Notification settings (priorities, quiet hours, grouping)
|
||||
* - Keyboard shortcuts configuration
|
||||
* - Quick actions settings (context menu, auto-detect)
|
||||
* - Per-token notification settings
|
||||
*/
|
||||
export function ProductivitySettingsPanel({
|
||||
settings: initialSettings,
|
||||
onSave,
|
||||
className,
|
||||
}: ProductivitySettingsPanelProps) {
|
||||
const [settings, setSettings] = useState<ProductivitySettingsData>(
|
||||
initialSettings || {
|
||||
notifications: {
|
||||
enabled: true,
|
||||
priorities: {
|
||||
high: true,
|
||||
medium: true,
|
||||
low: false,
|
||||
},
|
||||
quietHours: {
|
||||
enabled: true,
|
||||
start: "23:00",
|
||||
end: "07:00",
|
||||
},
|
||||
groupNotifications: true,
|
||||
smartBatching: true,
|
||||
},
|
||||
shortcuts: [
|
||||
{ id: "open-panel", action: "Open Side Panel", shortcut: "Cmd+Shift+S", description: "Open/close the side panel" },
|
||||
{ id: "new-chat", action: "New Chat", shortcut: "Cmd+Shift+N", description: "Start a new chat" },
|
||||
{ id: "analyze-token", action: "Analyze Token", shortcut: "Cmd+Shift+A", description: "Analyze current token" },
|
||||
{ id: "add-watchlist", action: "Add to Watchlist", shortcut: "Cmd+Shift+W", description: "Add token to watchlist" },
|
||||
{ id: "capture-chart", action: "Capture Chart", shortcut: "Cmd+Shift+C", description: "Capture chart screenshot" },
|
||||
{ id: "open-portfolio", action: "Open Portfolio", shortcut: "Cmd+Shift+P", description: "Open portfolio panel" },
|
||||
],
|
||||
quickActions: {
|
||||
contextMenuEnabled: true,
|
||||
autoDetectAddresses: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const updateSettings = (updates: Partial<ProductivitySettingsData>) => {
|
||||
setSettings({ ...settings, ...updates });
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave?.(settings);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="font-semibold">Productivity Settings</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Notifications, shortcuts, and quick actions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{/* Notifications */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-semibold text-sm">Notifications</h3>
|
||||
</div>
|
||||
|
||||
{/* Enable/Disable */}
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-sm">Enable notifications</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.notifications.enabled}
|
||||
onChange={(e) =>
|
||||
updateSettings({
|
||||
notifications: {
|
||||
...settings.notifications,
|
||||
enabled: e.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="rounded"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Priority Levels */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-muted-foreground">Priority Levels</label>
|
||||
<div className="space-y-2 pl-4">
|
||||
{(["high", "medium", "low"] as const).map((priority) => (
|
||||
<label key={priority} className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-sm capitalize">{priority}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.notifications.priorities[priority]}
|
||||
onChange={(e) =>
|
||||
updateSettings({
|
||||
notifications: {
|
||||
...settings.notifications,
|
||||
priorities: {
|
||||
...settings.notifications.priorities,
|
||||
[priority]: e.target.checked,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
className="rounded"
|
||||
disabled={!settings.notifications.enabled}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quiet Hours */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm">Quiet Hours</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.notifications.quietHours.enabled}
|
||||
onChange={(e) =>
|
||||
updateSettings({
|
||||
notifications: {
|
||||
...settings.notifications,
|
||||
quietHours: {
|
||||
...settings.notifications.quietHours,
|
||||
enabled: e.target.checked,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
className="rounded"
|
||||
disabled={!settings.notifications.enabled}
|
||||
/>
|
||||
</label>
|
||||
{settings.notifications.quietHours.enabled && (
|
||||
<div className="flex gap-2 pl-6">
|
||||
<input
|
||||
type="time"
|
||||
value={settings.notifications.quietHours.start}
|
||||
onChange={(e) =>
|
||||
updateSettings({
|
||||
notifications: {
|
||||
...settings.notifications,
|
||||
quietHours: {
|
||||
...settings.notifications.quietHours,
|
||||
start: e.target.value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
className="flex-1 p-1 text-xs border rounded"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">to</span>
|
||||
<input
|
||||
type="time"
|
||||
value={settings.notifications.quietHours.end}
|
||||
onChange={(e) =>
|
||||
updateSettings({
|
||||
notifications: {
|
||||
...settings.notifications,
|
||||
quietHours: {
|
||||
...settings.notifications.quietHours,
|
||||
end: e.target.value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
className="flex-1 p-1 text-xs border rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grouping Options */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-sm">Group notifications</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.notifications.groupNotifications}
|
||||
onChange={(e) =>
|
||||
updateSettings({
|
||||
notifications: {
|
||||
...settings.notifications,
|
||||
groupNotifications: e.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="rounded"
|
||||
disabled={!settings.notifications.enabled}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-sm">Smart batching (5+ alerts)</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.notifications.smartBatching}
|
||||
onChange={(e) =>
|
||||
updateSettings({
|
||||
notifications: {
|
||||
...settings.notifications,
|
||||
smartBatching: e.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="rounded"
|
||||
disabled={!settings.notifications.enabled}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Keyboard className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-semibold text-sm">Keyboard Shortcuts</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{settings.shortcuts.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.id}
|
||||
className="flex items-center justify-between p-2 bg-muted/50 rounded"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{shortcut.action}</div>
|
||||
<div className="text-xs text-muted-foreground">{shortcut.description}</div>
|
||||
</div>
|
||||
<kbd className="px-2 py-1 text-xs font-mono bg-background border rounded">
|
||||
{shortcut.shortcut}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
Customize Shortcuts
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Menu className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-semibold text-sm">Quick Actions</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-sm">Context menu enabled</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.quickActions.contextMenuEnabled}
|
||||
onChange={(e) =>
|
||||
updateSettings({
|
||||
quickActions: {
|
||||
...settings.quickActions,
|
||||
contextMenuEnabled: e.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="rounded"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-sm">Auto-detect token addresses</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.quickActions.autoDetectAddresses}
|
||||
onChange={(e) =>
|
||||
updateSettings({
|
||||
quickActions: {
|
||||
...settings.quickActions,
|
||||
autoDetectAddresses: e.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="rounded"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer - Save Button */}
|
||||
<div className="border-t p-3">
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{hasChanges ? "Save Changes" : "No Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
6
surfsense_browser_extension/sidepanel/settings/index.ts
Normal file
6
surfsense_browser_extension/sidepanel/settings/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Settings components for SurfSense Browser Extension
|
||||
|
||||
export { NotificationSettingsPanel, type NotificationSettings, type NotificationSettingsPanelProps } from "./NotificationSettingsPanel";
|
||||
export { NotificationsList, type Notification, type NotificationsListProps } from "./NotificationsList";
|
||||
export { ProductivitySettings } from "./ProductivitySettings";
|
||||
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
ExternalLink,
|
||||
Filter,
|
||||
Star,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface WhaleTransaction {
|
||||
/** Transaction ID */
|
||||
id: string;
|
||||
/** Token symbol */
|
||||
tokenSymbol: string;
|
||||
/** Token name */
|
||||
tokenName?: string;
|
||||
/** Blockchain chain */
|
||||
chain: string;
|
||||
/** Transaction type */
|
||||
type: "buy" | "sell";
|
||||
/** Amount in USD */
|
||||
amountUSD: number;
|
||||
/** Amount in tokens */
|
||||
amountTokens: string;
|
||||
/** Wallet address */
|
||||
walletAddress: string;
|
||||
/** Transaction hash */
|
||||
txHash: string;
|
||||
/** When the transaction occurred */
|
||||
timestamp: Date;
|
||||
/** Whether this is a smart money wallet */
|
||||
isSmartMoney?: boolean;
|
||||
/** Whether this token is in user's watchlist */
|
||||
isInWatchlist?: boolean;
|
||||
}
|
||||
|
||||
export interface WhaleActivityFeedProps {
|
||||
/** List of whale transactions */
|
||||
transactions: WhaleTransaction[];
|
||||
/** Callback when transaction is clicked */
|
||||
onTransactionClick?: (tx: WhaleTransaction) => void;
|
||||
/** Callback when "Track Wallet" is clicked */
|
||||
onTrackWallet?: (walletAddress: string) => void;
|
||||
/** Callback when "View on Explorer" is clicked */
|
||||
onViewExplorer?: (txHash: string, chain: string) => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WhaleActivityFeed - Display whale transactions (large buys/sells >$10K)
|
||||
*
|
||||
* Features:
|
||||
* - Real-time feed of large transactions
|
||||
* - Filter by watchlist tokens or all tokens
|
||||
* - Smart money wallet indicators
|
||||
* - Quick links to block explorer
|
||||
* - Track wallet functionality
|
||||
*/
|
||||
export function WhaleActivityFeed({
|
||||
transactions,
|
||||
onTransactionClick,
|
||||
onTrackWallet,
|
||||
onViewExplorer,
|
||||
className,
|
||||
}: WhaleActivityFeedProps) {
|
||||
const [filter, setFilter] = useState<"all" | "watchlist" | "smart_money">("all");
|
||||
|
||||
// Filter transactions based on selected filter
|
||||
const filteredTransactions = transactions.filter((tx) => {
|
||||
if (filter === "watchlist") return tx.isInWatchlist;
|
||||
if (filter === "smart_money") return tx.isSmartMoney;
|
||||
return true;
|
||||
});
|
||||
|
||||
const formatTimeAgo = (date: Date) => {
|
||||
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number) => {
|
||||
if (amount >= 1000000) return `$${(amount / 1000000).toFixed(2)}M`;
|
||||
if (amount >= 1000) return `$${(amount / 1000).toFixed(1)}K`;
|
||||
return `$${amount.toFixed(0)}`;
|
||||
};
|
||||
|
||||
const shortenAddress = (address: string) => {
|
||||
return `${address.slice(0, 6)}...${address.slice(-4)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl">🐋</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">Whale Activity</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{filteredTransactions.length} transactions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-1 p-2 border-b bg-muted/30">
|
||||
<Button
|
||||
variant={filter === "all" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setFilter("all")}
|
||||
className="flex-1"
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "watchlist" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setFilter("watchlist")}
|
||||
className="flex-1"
|
||||
>
|
||||
<Star className="h-3 w-3 mr-1" />
|
||||
Watchlist
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "smart_money" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setFilter("smart_money")}
|
||||
className="flex-1"
|
||||
>
|
||||
Smart Money
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Transaction feed */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredTransactions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<div className="text-4xl mb-2">🐋</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No whale activity detected yet
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Large transactions (>$10K) will appear here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredTransactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="p-4 hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onClick={() => onTransactionClick?.(tx)}
|
||||
>
|
||||
{/* Time and smart money badge */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTimeAgo(tx.timestamp)}
|
||||
</div>
|
||||
{tx.isSmartMoney && (
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 bg-purple-500/10 text-purple-600 dark:text-purple-400 rounded-full text-xs font-medium">
|
||||
⚠️ Smart Money
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transaction type and amount */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{tx.type === "buy" ? (
|
||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-400 font-semibold">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
BUY
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-red-600 dark:text-red-400 font-semibold">
|
||||
<TrendingDown className="h-4 w-4" />
|
||||
SELL
|
||||
</div>
|
||||
)}
|
||||
<span className="font-bold text-lg">
|
||||
{formatAmount(tx.amountUSD)}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{tx.tokenSymbol}
|
||||
</span>
|
||||
<ChainIcon chain={tx.chain} size="sm" />
|
||||
</div>
|
||||
|
||||
{/* Token amount */}
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
Amount: {tx.amountTokens} tokens
|
||||
</div>
|
||||
|
||||
{/* Wallet address */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Wallet className="h-3 w-3 text-muted-foreground" />
|
||||
<code className="text-xs bg-muted px-2 py-0.5 rounded">
|
||||
{shortenAddress(tx.walletAddress)}
|
||||
</code>
|
||||
<button
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(tx.walletAddress);
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewExplorer?.(tx.txHash, tx.chain);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
Explorer
|
||||
</Button>
|
||||
{!tx.isSmartMoney && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTrackWallet?.(tx.walletAddress);
|
||||
}}
|
||||
>
|
||||
<Star className="h-3 w-3 mr-1" />
|
||||
Track Wallet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer info */}
|
||||
<div className="border-t p-3 bg-muted/30">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Monitoring transactions >$10K</span>
|
||||
<span>Updates every 1 min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { CheckCircle, Bell, Eye, Settings } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
|
||||
export interface ActionConfirmationProps {
|
||||
/** Type of action that was confirmed */
|
||||
actionType: "watchlist_add" | "watchlist_remove" | "alert_set" | "alert_delete";
|
||||
/** Token symbol */
|
||||
tokenSymbol: string;
|
||||
/** Additional details about the action */
|
||||
details?: string[];
|
||||
/** Callback when view watchlist is clicked */
|
||||
onViewWatchlist?: () => void;
|
||||
/** Callback when edit alerts is clicked */
|
||||
onEditAlerts?: () => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ActionConfirmationWidget - Embedded widget showing action confirmation in chat
|
||||
*
|
||||
* Used when AI executes an action like adding to watchlist or setting alerts.
|
||||
* Displays confirmation with relevant follow-up actions.
|
||||
*/
|
||||
export function ActionConfirmationWidget({
|
||||
actionType,
|
||||
tokenSymbol,
|
||||
details = [],
|
||||
onViewWatchlist,
|
||||
onEditAlerts,
|
||||
className,
|
||||
}: ActionConfirmationProps) {
|
||||
const getActionTitle = () => {
|
||||
switch (actionType) {
|
||||
case "watchlist_add":
|
||||
return `${tokenSymbol} added to your watchlist`;
|
||||
case "watchlist_remove":
|
||||
return `${tokenSymbol} removed from watchlist`;
|
||||
case "alert_set":
|
||||
return `Alert configured for ${tokenSymbol}`;
|
||||
case "alert_delete":
|
||||
return `Alert removed for ${tokenSymbol}`;
|
||||
default:
|
||||
return "Action completed";
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (actionType) {
|
||||
case "watchlist_add":
|
||||
case "watchlist_remove":
|
||||
return Eye;
|
||||
case "alert_set":
|
||||
case "alert_delete":
|
||||
return Bell;
|
||||
default:
|
||||
return CheckCircle;
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = getIcon();
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg border bg-card p-4 my-2",
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<span className="font-medium text-sm">Action Confirmed</span>
|
||||
</div>
|
||||
|
||||
{/* Action details */}
|
||||
<div className="bg-muted/50 rounded-md p-3 mb-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium text-sm">{getActionTitle()}</span>
|
||||
</div>
|
||||
|
||||
{details.length > 0 && (
|
||||
<div className="space-y-1 mt-2">
|
||||
<p className="text-xs text-muted-foreground">Also set up:</p>
|
||||
{details.map((detail, index) => (
|
||||
<div key={index} className="flex items-center gap-2 text-xs">
|
||||
<Bell className="h-3 w-3 text-primary" />
|
||||
<span>{detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
{(actionType === "watchlist_add" || actionType === "watchlist_remove") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onViewWatchlist}
|
||||
className="flex-1"
|
||||
>
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
View Watchlist
|
||||
</Button>
|
||||
)}
|
||||
{(actionType === "watchlist_add" || actionType === "alert_set") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onEditAlerts}
|
||||
className="flex-1"
|
||||
>
|
||||
<Settings className="h-3 w-3 mr-1" />
|
||||
Edit Alerts
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
148
surfsense_browser_extension/sidepanel/widgets/AlertWidget.tsx
Normal file
148
surfsense_browser_extension/sidepanel/widgets/AlertWidget.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { Bell, CheckCircle, Edit, Trash2, Plus } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
|
||||
export interface AlertConfigData {
|
||||
/** Token symbol */
|
||||
tokenSymbol: string;
|
||||
/** Alert condition description */
|
||||
condition: string;
|
||||
/** Current price */
|
||||
currentPrice?: string;
|
||||
/** Trigger price */
|
||||
triggerPrice?: string;
|
||||
/** Notification channels */
|
||||
channels: {
|
||||
browser: boolean;
|
||||
inApp: boolean;
|
||||
email: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AlertWidgetProps {
|
||||
/** Alert configuration data */
|
||||
config: AlertConfigData;
|
||||
/** Whether this is a new alert or existing */
|
||||
isNew?: boolean;
|
||||
/** Callback when edit is clicked */
|
||||
onEdit?: () => void;
|
||||
/** Callback when delete is clicked */
|
||||
onDelete?: () => void;
|
||||
/** Callback when add another is clicked */
|
||||
onAddAnother?: () => void;
|
||||
/** Callback when view all alerts is clicked */
|
||||
onViewAll?: () => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AlertWidget - Embedded alert configuration widget for chat
|
||||
*
|
||||
* Shows alert configuration inline in chat after user sets an alert
|
||||
* via natural language command.
|
||||
*/
|
||||
export function AlertWidget({
|
||||
config,
|
||||
isNew = true,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddAnother,
|
||||
onViewAll,
|
||||
className,
|
||||
}: AlertWidgetProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg border bg-card p-4 my-2",
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
{isNew ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Bell className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium text-sm">
|
||||
{isNew ? "Alert Created" : "AlertWidget"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Alert details */}
|
||||
<div className="bg-muted/50 rounded-md p-3 mb-3 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Token:</span>
|
||||
<span className="font-medium">{config.tokenSymbol}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Condition:</span>
|
||||
<span className="font-medium">{config.condition}</span>
|
||||
</div>
|
||||
{config.currentPrice && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Current:</span>
|
||||
<span>{config.currentPrice}</span>
|
||||
</div>
|
||||
)}
|
||||
{config.triggerPrice && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Trigger at:</span>
|
||||
<span className="font-medium text-primary">{config.triggerPrice}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notification channels */}
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs text-muted-foreground mb-1">Notify via:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={cn(
|
||||
"text-xs px-2 py-0.5 rounded",
|
||||
config.channels.browser ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground line-through"
|
||||
)}>
|
||||
{config.channels.browser ? "✓" : "✗"} Browser
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-xs px-2 py-0.5 rounded",
|
||||
config.channels.inApp ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground line-through"
|
||||
)}>
|
||||
{config.channels.inApp ? "✓" : "✗"} In-app
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-xs px-2 py-0.5 rounded",
|
||||
config.channels.email ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground line-through"
|
||||
)}>
|
||||
{config.channels.email ? "✓" : "✗"} Email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onEdit} className="flex-1">
|
||||
<Edit className="h-3 w-3 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onDelete}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onAddAnother}>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* View all link */}
|
||||
{onViewAll && (
|
||||
<button
|
||||
onClick={onViewAll}
|
||||
className="w-full mt-2 text-xs text-primary hover:underline"
|
||||
>
|
||||
View all alerts →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { ChartCapturePanel, type ChartCaptureMetadata } from "../capture/ChartCapturePanel";
|
||||
|
||||
export interface ChartCaptureWidgetProps {
|
||||
/** Current token metadata */
|
||||
metadata?: ChartCaptureMetadata;
|
||||
/** Callback when capture is clicked */
|
||||
onCapture?: () => void;
|
||||
/** Callback when export is clicked */
|
||||
onExport?: (format: "twitter" | "telegram" | "instagram" | "clipboard") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ChartCaptureWidget - Inline chart capture tool in chat
|
||||
* Wraps ChartCapturePanel for conversational UX
|
||||
*/
|
||||
export function ChartCaptureWidget({
|
||||
metadata,
|
||||
onCapture,
|
||||
onExport,
|
||||
}: ChartCaptureWidgetProps) {
|
||||
return (
|
||||
<div className="my-3 max-h-[600px] overflow-hidden rounded-lg border">
|
||||
<ChartCapturePanel
|
||||
metadata={metadata}
|
||||
onCapture={onCapture}
|
||||
onExport={onExport}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { Users, AlertTriangle, Crown } from "lucide-react";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface Holder {
|
||||
rank: number;
|
||||
address: string;
|
||||
label?: string;
|
||||
balance: number;
|
||||
percentage: number;
|
||||
isContract?: boolean;
|
||||
}
|
||||
|
||||
export interface HolderAnalysisData {
|
||||
tokenSymbol: string;
|
||||
chain: string;
|
||||
totalHolders: number;
|
||||
top10Percentage: number;
|
||||
top50Percentage?: number;
|
||||
holders: Holder[];
|
||||
concentrationRisk?: "low" | "medium" | "high" | "critical";
|
||||
}
|
||||
|
||||
export interface HolderAnalysisWidgetProps {
|
||||
/** Holder analysis data */
|
||||
data: HolderAnalysisData;
|
||||
/** Callback when holder is clicked */
|
||||
onHolderClick?: (holder: Holder) => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const shortenAddress = (address: string): string => {
|
||||
return `${address.slice(0, 6)}...${address.slice(-4)}`;
|
||||
};
|
||||
|
||||
const formatBalance = (balance: number): string => {
|
||||
if (balance >= 1e9) return `${(balance / 1e9).toFixed(2)}B`;
|
||||
if (balance >= 1e6) return `${(balance / 1e6).toFixed(2)}M`;
|
||||
if (balance >= 1e3) return `${(balance / 1e3).toFixed(2)}K`;
|
||||
return balance.toFixed(2);
|
||||
};
|
||||
|
||||
const getRiskColor = (risk: string) => {
|
||||
switch (risk) {
|
||||
case "low": return "text-green-500 bg-green-500/10";
|
||||
case "medium": return "text-yellow-500 bg-yellow-500/10";
|
||||
case "high": return "text-orange-500 bg-orange-500/10";
|
||||
case "critical": return "text-red-500 bg-red-500/10";
|
||||
default: return "text-muted-foreground bg-muted";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* HolderAnalysisWidget - Displays holder distribution inline in chat
|
||||
* Used when AI responds to "who holds BULLA?" or "analyze holders"
|
||||
*/
|
||||
export function HolderAnalysisWidget({
|
||||
data,
|
||||
onHolderClick,
|
||||
className,
|
||||
}: HolderAnalysisWidgetProps) {
|
||||
const risk = data.concentrationRisk || "medium";
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border bg-card p-4 my-2", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-purple-500" />
|
||||
<span className="font-medium text-sm">Holder Analysis - {data.tokenSymbol}</span>
|
||||
</div>
|
||||
<ChainIcon chain={data.chain} size="sm" />
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Total Holders</p>
|
||||
<p className="font-medium text-sm">{data.totalHolders.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className={cn("rounded p-2", data.top10Percentage > 50 ? "bg-red-500/10" : "bg-muted/50")}>
|
||||
<p className="text-xs text-muted-foreground">Top 10 Hold</p>
|
||||
<p className={cn("font-medium text-sm", data.top10Percentage > 50 && "text-red-500")}>
|
||||
{data.top10Percentage.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
{data.top50Percentage && (
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Top 50 Hold</p>
|
||||
<p className="font-medium text-sm">{data.top50Percentage.toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("rounded p-2", getRiskColor(risk))}>
|
||||
<p className="text-xs text-muted-foreground">Concentration Risk</p>
|
||||
<p className="font-medium text-sm capitalize">{risk}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Warning */}
|
||||
{(risk === "high" || risk === "critical") && (
|
||||
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400 text-xs bg-yellow-500/10 rounded-lg p-2 mb-3">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>High holder concentration. Top wallets could impact price.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Holders List */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">Top Holders</p>
|
||||
<div className="divide-y max-h-[200px] overflow-y-auto">
|
||||
{data.holders.slice(0, 10).map((holder) => (
|
||||
<div
|
||||
key={holder.address}
|
||||
className="flex items-center justify-between py-2 hover:bg-muted/50 -mx-2 px-2 rounded cursor-pointer transition-colors"
|
||||
onClick={() => onHolderClick?.(holder)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-muted-foreground w-5">#{holder.rank}</span>
|
||||
{holder.rank <= 3 && (
|
||||
<Crown className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
holder.rank === 1 ? "text-yellow-500" :
|
||||
holder.rank === 2 ? "text-gray-400" : "text-amber-600"
|
||||
)} />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-xs">{holder.label || shortenAddress(holder.address)}</p>
|
||||
{holder.isContract && (
|
||||
<span className="text-[10px] bg-muted px-1 rounded">Contract</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-xs">{holder.percentage.toFixed(2)}%</p>
|
||||
<p className="text-[10px] text-muted-foreground">{formatBalance(holder.balance)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { Activity, TrendingUp, TrendingDown, RefreshCw, ExternalLink, Droplets, BarChart3 } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface LiveTokenDataInfo {
|
||||
chain: string;
|
||||
tokenAddress: string;
|
||||
tokenSymbol?: string;
|
||||
tokenName?: string;
|
||||
priceUsd?: string;
|
||||
priceNative?: string;
|
||||
priceChange5m?: number;
|
||||
priceChange1h?: number;
|
||||
priceChange6h?: number;
|
||||
priceChange24h?: number;
|
||||
volume24h?: number;
|
||||
volume6h?: number;
|
||||
volume1h?: number;
|
||||
liquidityUsd?: number;
|
||||
marketCap?: number;
|
||||
fdv?: number;
|
||||
txns24hBuys?: number;
|
||||
txns24hSells?: number;
|
||||
dex?: string;
|
||||
pairUrl?: string;
|
||||
totalPairs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface LiveTokenDataWidgetProps {
|
||||
/** Live token data */
|
||||
data: LiveTokenDataInfo;
|
||||
/** Whether data is loading */
|
||||
isLoading?: boolean;
|
||||
/** Callback when view on DexScreener is clicked */
|
||||
onViewDexScreener?: () => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatPrice = (price: string | undefined): string => {
|
||||
if (!price || price === "N/A") return "N/A";
|
||||
const num = parseFloat(price);
|
||||
if (isNaN(num)) return price;
|
||||
if (num < 0.00001) return `$${num.toExponential(2)}`;
|
||||
if (num < 1) return `$${num.toFixed(6)}`;
|
||||
return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const formatLargeNumber = (num: number | undefined): string => {
|
||||
if (num === undefined || num === null || num === 0) return "N/A";
|
||||
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
|
||||
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
|
||||
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
|
||||
return `$${num.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const formatNumber = (num: number | undefined): string => {
|
||||
if (num === undefined || num === null) return "0";
|
||||
return num.toLocaleString();
|
||||
};
|
||||
|
||||
const PriceChange = ({ value, label }: { value: number | undefined; label: string }) => {
|
||||
if (value === undefined || value === null) return null;
|
||||
const isPositive = value >= 0;
|
||||
return (
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">{label}</p>
|
||||
<p className={cn("text-xs font-medium", isPositive ? "text-green-500" : "text-red-500")}>
|
||||
{isPositive ? "+" : ""}{value.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* LiveTokenDataWidget - Displays comprehensive real-time market data
|
||||
* Used when AI fetches detailed live market information
|
||||
*/
|
||||
export function LiveTokenDataWidget({
|
||||
data,
|
||||
isLoading = false,
|
||||
onViewDexScreener,
|
||||
className,
|
||||
}: LiveTokenDataWidgetProps) {
|
||||
const handleOpenDexScreener = () => {
|
||||
if (onViewDexScreener) {
|
||||
onViewDexScreener();
|
||||
} else if (data.pairUrl) {
|
||||
window.open(data.pairUrl, "_blank");
|
||||
} else if (data.tokenAddress) {
|
||||
window.open(`https://dexscreener.com/${data.chain}/${data.tokenAddress}`, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
const totalTxns24h = (data.txns24hBuys || 0) + (data.txns24hSells || 0);
|
||||
const buyRatio = totalTxns24h > 0 ? ((data.txns24hBuys || 0) / totalTxns24h) * 100 : 50;
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-purple-500/20 bg-card p-4 my-2", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-purple-500" />
|
||||
<span className="font-medium text-sm">Live Market Data</span>
|
||||
{isLoading ? (
|
||||
<span className="text-xs bg-muted px-2 py-0.5 rounded animate-pulse">Fetching...</span>
|
||||
) : (
|
||||
<span className="text-xs text-purple-500 flex items-center gap-1">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Real-time
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.error ? (
|
||||
<div className="text-red-500 text-xs p-2 bg-red-500/10 rounded">
|
||||
⚠️ {data.error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Token Header */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<ChainIcon chain={data.chain} size="sm" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">{data.tokenSymbol || "Token"}</span>
|
||||
{data.tokenName && (
|
||||
<span className="text-xs text-muted-foreground">{data.tokenName}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-lg">{formatPrice(data.priceUsd)}</span>
|
||||
{data.priceChange24h !== undefined && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-0.5 text-xs font-medium",
|
||||
data.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{data.priceChange24h >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||
{data.priceChange24h >= 0 ? "+" : ""}{data.priceChange24h.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Changes */}
|
||||
<div className="flex justify-around py-2 bg-muted/30 rounded mb-3">
|
||||
<PriceChange value={data.priceChange5m} label="5m" />
|
||||
<PriceChange value={data.priceChange1h} label="1h" />
|
||||
<PriceChange value={data.priceChange6h} label="6h" />
|
||||
<PriceChange value={data.priceChange24h} label="24h" />
|
||||
</div>
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<BarChart3 className="h-3 w-3" /> 24h Volume
|
||||
</p>
|
||||
<p className="font-medium text-sm">{formatLargeNumber(data.volume24h)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<Droplets className="h-3 w-3" /> Liquidity
|
||||
</p>
|
||||
<p className="font-medium text-sm">{formatLargeNumber(data.liquidityUsd)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-[10px] text-muted-foreground">Market Cap</p>
|
||||
<p className="font-medium text-sm">{formatLargeNumber(data.marketCap)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-[10px] text-muted-foreground">FDV</p>
|
||||
<p className="font-medium text-sm">{formatLargeNumber(data.fdv)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transaction Activity */}
|
||||
<div className="space-y-1 mb-3">
|
||||
<p className="text-xs font-medium flex items-center gap-1">
|
||||
<Activity className="h-3 w-3" /> 24h Transactions
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all"
|
||||
style={{ width: `${buyRatio}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-green-500">{formatNumber(data.txns24hBuys)} buys</span>
|
||||
<span className="text-muted-foreground">{formatNumber(totalTxns24h)} total</span>
|
||||
<span className="text-red-500">{formatNumber(data.txns24hSells)} sells</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DEX Info & Actions */}
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
<span>DEX: {data.dex || "Unknown"}</span>
|
||||
{data.totalPairs && data.totalPairs > 1 && (
|
||||
<span className="ml-2">• {data.totalPairs} pairs</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleOpenDexScreener}>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
DexScreener
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { Zap, TrendingUp, TrendingDown, RefreshCw, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface LiveTokenPriceData {
|
||||
chain: string;
|
||||
tokenAddress: string;
|
||||
tokenSymbol?: string;
|
||||
tokenName?: string;
|
||||
priceUsd?: string;
|
||||
priceNative?: string;
|
||||
priceChange5m?: number;
|
||||
priceChange1h?: number;
|
||||
priceChange6h?: number;
|
||||
priceChange24h?: number;
|
||||
volume24h?: number;
|
||||
liquidityUsd?: number;
|
||||
marketCap?: number;
|
||||
fdv?: number;
|
||||
dex?: string;
|
||||
pairUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface LiveTokenPriceWidgetProps {
|
||||
/** Live token price data */
|
||||
data: LiveTokenPriceData;
|
||||
/** Whether data is loading */
|
||||
isLoading?: boolean;
|
||||
/** Callback when view on DexScreener is clicked */
|
||||
onViewDexScreener?: () => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatPrice = (price: string | undefined): string => {
|
||||
if (!price || price === "N/A") return "N/A";
|
||||
const num = parseFloat(price);
|
||||
if (isNaN(num)) return price;
|
||||
if (num < 0.00001) return `$${num.toExponential(2)}`;
|
||||
if (num < 1) return `$${num.toFixed(6)}`;
|
||||
return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const PriceChange = ({ value, label }: { value: number | undefined; label: string }) => {
|
||||
if (value === undefined || value === null) return null;
|
||||
const isPositive = value >= 0;
|
||||
return (
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">{label}</p>
|
||||
<p className={cn("text-xs font-medium", isPositive ? "text-green-500" : "text-red-500")}>
|
||||
{isPositive ? "+" : ""}{value.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* LiveTokenPriceWidget - Displays real-time token price inline in chat
|
||||
* Used when AI fetches current/live price data
|
||||
*/
|
||||
export function LiveTokenPriceWidget({
|
||||
data,
|
||||
isLoading = false,
|
||||
onViewDexScreener,
|
||||
className,
|
||||
}: LiveTokenPriceWidgetProps) {
|
||||
const handleOpenDexScreener = () => {
|
||||
if (onViewDexScreener) {
|
||||
onViewDexScreener();
|
||||
} else if (data.pairUrl) {
|
||||
window.open(data.pairUrl, "_blank");
|
||||
} else if (data.tokenAddress) {
|
||||
window.open(`https://dexscreener.com/${data.chain}/${data.tokenAddress}`, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-blue-500/20 bg-card p-4 my-2", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-blue-500" />
|
||||
<span className="font-medium text-sm">Live Price</span>
|
||||
{isLoading ? (
|
||||
<span className="text-xs bg-muted px-2 py-0.5 rounded animate-pulse">Fetching...</span>
|
||||
) : (
|
||||
<span className="text-xs text-blue-500 flex items-center gap-1">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Real-time
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.error ? (
|
||||
<div className="text-red-500 text-xs p-2 bg-red-500/10 rounded">
|
||||
⚠️ {data.error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Token Header */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<ChainIcon chain={data.chain} size="sm" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">{data.tokenSymbol || "Token"}</span>
|
||||
{data.tokenName && (
|
||||
<span className="text-xs text-muted-foreground">{data.tokenName}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-lg">{formatPrice(data.priceUsd)}</span>
|
||||
{data.priceChange24h !== undefined && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-0.5 text-xs font-medium",
|
||||
data.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{data.priceChange24h >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{data.priceChange24h >= 0 ? "+" : ""}{data.priceChange24h.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Changes */}
|
||||
<div className="flex justify-around py-2 bg-muted/30 rounded mb-3">
|
||||
<PriceChange value={data.priceChange5m} label="5m" />
|
||||
<PriceChange value={data.priceChange1h} label="1h" />
|
||||
<PriceChange value={data.priceChange6h} label="6h" />
|
||||
<PriceChange value={data.priceChange24h} label="24h" />
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={handleOpenDexScreener}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
View on DexScreener
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { Globe, TrendingUp, TrendingDown } from "lucide-react";
|
||||
|
||||
export interface MarketToken {
|
||||
symbol: string;
|
||||
name: string;
|
||||
price: number;
|
||||
priceChange24h: number;
|
||||
marketCap?: number;
|
||||
volume24h?: number;
|
||||
}
|
||||
|
||||
export interface MarketOverviewData {
|
||||
tokens: MarketToken[];
|
||||
totalMarketCap?: number;
|
||||
totalVolume24h?: number;
|
||||
btcDominance?: number;
|
||||
fearGreedIndex?: number;
|
||||
}
|
||||
|
||||
export interface MarketOverviewWidgetProps {
|
||||
/** Market overview data */
|
||||
data: MarketOverviewData;
|
||||
/** Callback when token is clicked */
|
||||
onTokenClick?: (token: MarketToken) => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatPrice = (price: number): string => {
|
||||
if (price < 1) return `$${price.toFixed(4)}`;
|
||||
return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const formatLargeNumber = (num: number): string => {
|
||||
if (num >= 1e12) return `$${(num / 1e12).toFixed(2)}T`;
|
||||
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
|
||||
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
|
||||
return `$${num.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const getFearGreedLabel = (index: number): string => {
|
||||
if (index > 75) return "Extreme Greed";
|
||||
if (index > 50) return "Greed";
|
||||
if (index > 25) return "Fear";
|
||||
return "Extreme Fear";
|
||||
};
|
||||
|
||||
/**
|
||||
* MarketOverviewWidget - Displays market overview inline in chat
|
||||
* Used when AI responds to "show market overview" or "how's the market?"
|
||||
*/
|
||||
export function MarketOverviewWidget({
|
||||
data,
|
||||
onTokenClick,
|
||||
className,
|
||||
}: MarketOverviewWidgetProps) {
|
||||
return (
|
||||
<div className={cn("rounded-lg border bg-card p-4 my-2", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Globe className="h-5 w-5 text-blue-500" />
|
||||
<span className="font-medium text-sm">Market Overview</span>
|
||||
</div>
|
||||
|
||||
{/* Global Stats */}
|
||||
{(data.totalMarketCap || data.btcDominance || data.fearGreedIndex) && (
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
{data.totalMarketCap && (
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Total Market Cap</p>
|
||||
<p className="font-medium text-sm">{formatLargeNumber(data.totalMarketCap)}</p>
|
||||
</div>
|
||||
)}
|
||||
{data.totalVolume24h && (
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">24h Volume</p>
|
||||
<p className="font-medium text-sm">{formatLargeNumber(data.totalVolume24h)}</p>
|
||||
</div>
|
||||
)}
|
||||
{data.btcDominance && (
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">BTC Dominance</p>
|
||||
<p className="font-medium text-sm">{data.btcDominance.toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
{data.fearGreedIndex && (
|
||||
<div className={cn(
|
||||
"rounded p-2",
|
||||
data.fearGreedIndex > 50 ? "bg-green-500/10" : "bg-red-500/10"
|
||||
)}>
|
||||
<p className="text-xs text-muted-foreground">Fear & Greed</p>
|
||||
<p className={cn(
|
||||
"font-medium text-sm",
|
||||
data.fearGreedIndex > 50 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{data.fearGreedIndex} - {getFearGreedLabel(data.fearGreedIndex)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token Prices */}
|
||||
<div className="space-y-2">
|
||||
{data.tokens.map((token) => (
|
||||
<div
|
||||
key={token.symbol}
|
||||
className="bg-muted/50 rounded p-3 flex items-center justify-between hover:bg-muted/70 cursor-pointer transition-colors"
|
||||
onClick={() => onTokenClick?.(token)}
|
||||
>
|
||||
<div>
|
||||
<p className="font-bold">{token.symbol}</p>
|
||||
<p className="text-xs text-muted-foreground">{token.name}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{formatPrice(token.price)}</p>
|
||||
<p className={cn(
|
||||
"text-xs flex items-center justify-end gap-0.5",
|
||||
token.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{token.priceChange24h >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{token.priceChange24h >= 0 ? "+" : ""}{token.priceChange24h.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { PortfolioPanel, type PortfolioData, type PortfolioHolding } from "../portfolio/PortfolioPanel";
|
||||
|
||||
export interface PortfolioWidgetProps {
|
||||
/** Portfolio data */
|
||||
portfolio: PortfolioData;
|
||||
/** Callback when refresh is clicked */
|
||||
onRefresh?: () => void;
|
||||
/** Callback when "Analyze" is clicked for a token */
|
||||
onAnalyzeToken?: (holding: PortfolioHolding) => void;
|
||||
/** Callback when "Set Alert" is clicked for a token */
|
||||
onSetAlert?: (holding: PortfolioHolding) => void;
|
||||
/** Callback when "View on DexScreener" is clicked */
|
||||
onViewToken?: (holding: PortfolioHolding) => void;
|
||||
/** Callback when "Add Manual Position" is clicked */
|
||||
onAddPosition?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PortfolioWidget - Inline portfolio display in chat
|
||||
* Wraps PortfolioPanel for conversational UX
|
||||
*/
|
||||
export function PortfolioWidget({
|
||||
portfolio,
|
||||
onRefresh,
|
||||
onAnalyzeToken,
|
||||
onSetAlert,
|
||||
onViewToken,
|
||||
onAddPosition,
|
||||
}: PortfolioWidgetProps) {
|
||||
return (
|
||||
<div className="my-3 max-h-[600px] overflow-hidden rounded-lg border">
|
||||
<PortfolioPanel
|
||||
portfolio={portfolio}
|
||||
onRefresh={onRefresh}
|
||||
onAnalyzeToken={onAnalyzeToken}
|
||||
onSetAlert={onSetAlert}
|
||||
onViewToken={onViewToken}
|
||||
onAddPosition={onAddPosition}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { AlertTriangle, TrendingUp, Info, X, Bell, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
|
||||
export interface ProactiveAlertData {
|
||||
/** Alert ID */
|
||||
id: string;
|
||||
/** Alert type */
|
||||
type: "price_pump" | "price_dump" | "whale_activity" | "volume_spike" | "safety_warning";
|
||||
/** Token symbol */
|
||||
tokenSymbol: string;
|
||||
/** Alert title */
|
||||
title: string;
|
||||
/** Current price */
|
||||
currentPrice?: string;
|
||||
/** User's entry price (if applicable) */
|
||||
entryPrice?: string;
|
||||
/** User's P&L (if applicable) */
|
||||
pnl?: string;
|
||||
/** Warning messages */
|
||||
warnings?: string[];
|
||||
/** When the alert was triggered */
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface ProactiveAlertCardProps {
|
||||
/** Alert data */
|
||||
alert: ProactiveAlertData;
|
||||
/** AI's recommendation text */
|
||||
recommendation?: string;
|
||||
/** Callback when view details is clicked */
|
||||
onViewDetails?: () => void;
|
||||
/** Callback when dismiss is clicked */
|
||||
onDismiss?: () => void;
|
||||
/** Callback when set alert is clicked */
|
||||
onSetAlert?: () => void;
|
||||
/** Callback when tell me more is clicked */
|
||||
onTellMore?: () => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProactiveAlertCard - AI-initiated alert card embedded in chat
|
||||
*
|
||||
* Displays proactive alerts from the AI about price movements,
|
||||
* whale activity, or safety concerns. Shows user's position if applicable.
|
||||
*/
|
||||
export function ProactiveAlertCard({
|
||||
alert,
|
||||
recommendation,
|
||||
onViewDetails,
|
||||
onDismiss,
|
||||
onSetAlert,
|
||||
onTellMore,
|
||||
className,
|
||||
}: ProactiveAlertCardProps) {
|
||||
const getAlertIcon = () => {
|
||||
switch (alert.type) {
|
||||
case "price_pump":
|
||||
case "price_dump":
|
||||
return TrendingUp;
|
||||
case "whale_activity":
|
||||
case "volume_spike":
|
||||
return AlertTriangle;
|
||||
case "safety_warning":
|
||||
return AlertTriangle;
|
||||
default:
|
||||
return Info;
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertColor = () => {
|
||||
switch (alert.type) {
|
||||
case "price_pump":
|
||||
return "text-green-500 bg-green-500/10";
|
||||
case "price_dump":
|
||||
case "safety_warning":
|
||||
return "text-red-500 bg-red-500/10";
|
||||
case "whale_activity":
|
||||
case "volume_spike":
|
||||
return "text-yellow-500 bg-yellow-500/10";
|
||||
default:
|
||||
return "text-primary bg-primary/10";
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = getAlertIcon();
|
||||
const colorClass = getAlertColor();
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg border bg-card p-4 my-2",
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("w-8 h-8 rounded-full flex items-center justify-center", colorClass)}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-sm">🚨 ProactiveAlertCard</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{alert.timestamp.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="p-1 hover:bg-muted rounded text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Alert content */}
|
||||
<div className="bg-muted/50 rounded-md p-3 mb-3">
|
||||
<p className="font-medium text-sm mb-2">{alert.title}</p>
|
||||
|
||||
{/* Price info */}
|
||||
{(alert.currentPrice || alert.entryPrice || alert.pnl) && (
|
||||
<div className="space-y-1 text-xs">
|
||||
{alert.currentPrice && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">📊 Current:</span>
|
||||
<span className="font-medium">{alert.currentPrice}</span>
|
||||
</div>
|
||||
)}
|
||||
{alert.entryPrice && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">📈 Your entry:</span>
|
||||
<span>{alert.entryPrice}</span>
|
||||
</div>
|
||||
)}
|
||||
{alert.pnl && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">💰 Your P&L:</span>
|
||||
<span className={cn(
|
||||
"font-medium",
|
||||
alert.pnl.startsWith("+") ? "text-green-500" : "text-red-500"
|
||||
)}>{alert.pnl}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{alert.warnings && alert.warnings.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t space-y-1">
|
||||
{alert.warnings.map((warning, index) => (
|
||||
<div key={index} className="flex items-center gap-2 text-xs text-yellow-600">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<span>{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Recommendation */}
|
||||
{recommendation && (
|
||||
<p className="text-sm text-muted-foreground mb-3 italic">
|
||||
{recommendation}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onTellMore} className="flex-1">
|
||||
<Info className="h-3 w-3 mr-1" />
|
||||
Tell me more
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onViewDetails}>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onSetAlert}>
|
||||
<Bell className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { ThreadGeneratorPanel, type GeneratedThread } from "../content/ThreadGeneratorPanel";
|
||||
|
||||
export interface ThreadGeneratorWidgetProps {
|
||||
/** Current token info */
|
||||
tokenAddress?: string;
|
||||
tokenSymbol?: string;
|
||||
chain?: string;
|
||||
/** Generated thread (if available) */
|
||||
generatedThread?: GeneratedThread;
|
||||
/** Callback when thread is generated */
|
||||
onGenerate?: (request: any) => void;
|
||||
/** Callback when thread is exported */
|
||||
onExport?: (format: "copy" | "twitter") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ThreadGeneratorWidget - Inline thread generator in chat
|
||||
* Wraps ThreadGeneratorPanel for conversational UX
|
||||
*/
|
||||
export function ThreadGeneratorWidget({
|
||||
tokenAddress,
|
||||
tokenSymbol,
|
||||
chain,
|
||||
generatedThread,
|
||||
onGenerate,
|
||||
onExport,
|
||||
}: ThreadGeneratorWidgetProps) {
|
||||
return (
|
||||
<div className="my-3 max-h-[600px] overflow-hidden rounded-lg border">
|
||||
<ThreadGeneratorPanel
|
||||
tokenAddress={tokenAddress}
|
||||
tokenSymbol={tokenSymbol}
|
||||
chain={chain}
|
||||
onGenerate={onGenerate}
|
||||
onExport={onExport}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { Shield, TrendingUp, TrendingDown, Users, AlertTriangle, CheckCircle, Star, Bell } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface TokenAnalysisData {
|
||||
/** Token symbol */
|
||||
symbol: string;
|
||||
/** Token name */
|
||||
name?: string;
|
||||
/** Blockchain */
|
||||
chain: string;
|
||||
/** Current price */
|
||||
price: string;
|
||||
/** 24h price change */
|
||||
priceChange24h: number;
|
||||
/** Market cap */
|
||||
marketCap?: string;
|
||||
/** 24h volume */
|
||||
volume24h?: string;
|
||||
/** Liquidity */
|
||||
liquidity?: string;
|
||||
/** Safety score (0-100) */
|
||||
safetyScore?: number;
|
||||
/** Holder count */
|
||||
holderCount?: number;
|
||||
/** Top 10 holder percentage */
|
||||
top10HolderPercent?: number;
|
||||
}
|
||||
|
||||
export interface TokenAnalysisWidgetProps {
|
||||
/** Token analysis data */
|
||||
data: TokenAnalysisData;
|
||||
/** Whether token is in watchlist */
|
||||
isInWatchlist?: boolean;
|
||||
/** Callback when add to watchlist is clicked */
|
||||
onAddToWatchlist?: () => void;
|
||||
/** Callback when set alert is clicked */
|
||||
onSetAlert?: () => void;
|
||||
/** Callback when analyze further is clicked */
|
||||
onAnalyzeFurther?: () => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TokenAnalysisWidget - Full token analysis card embedded in chat
|
||||
*
|
||||
* Displays comprehensive token analysis including price, safety score,
|
||||
* and key metrics. Used when AI responds to token research queries.
|
||||
*/
|
||||
export function TokenAnalysisWidget({
|
||||
data,
|
||||
isInWatchlist = false,
|
||||
onAddToWatchlist,
|
||||
onSetAlert,
|
||||
onAnalyzeFurther,
|
||||
className,
|
||||
}: TokenAnalysisWidgetProps) {
|
||||
const getSafetyColor = (score?: number) => {
|
||||
if (!score) return "text-muted-foreground";
|
||||
if (score >= 80) return "text-green-500";
|
||||
if (score >= 60) return "text-yellow-500";
|
||||
return "text-red-500";
|
||||
};
|
||||
|
||||
const getSafetyLabel = (score?: number) => {
|
||||
if (!score) return "Unknown";
|
||||
if (score >= 80) return "Low Risk";
|
||||
if (score >= 60) return "Medium Risk";
|
||||
return "High Risk";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg border bg-card p-4 my-2",
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">📊</span>
|
||||
<span className="font-medium text-sm">TokenAnalysisCard</span>
|
||||
</div>
|
||||
<ChainIcon chain={data.chain} size="sm" />
|
||||
</div>
|
||||
|
||||
{/* Token info */}
|
||||
<div className="flex items-center gap-3 mb-3 pb-3 border-b">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="text-lg">🪙</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">{data.symbol}</span>
|
||||
{data.name && (
|
||||
<span className="text-xs text-muted-foreground">{data.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{data.price}</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-0.5 text-sm",
|
||||
data.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{data.priceChange24h >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{data.priceChange24h >= 0 ? "+" : ""}{data.priceChange24h.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onAddToWatchlist}
|
||||
className={cn(
|
||||
"p-2 rounded-full transition-colors",
|
||||
isInWatchlist
|
||||
? "text-yellow-500 bg-yellow-500/10"
|
||||
: "text-muted-foreground hover:text-yellow-500 hover:bg-yellow-500/10"
|
||||
)}
|
||||
>
|
||||
<Star className={cn("h-5 w-5", isInWatchlist && "fill-current")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Metrics grid */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-3 text-sm">
|
||||
{data.marketCap && (
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Market Cap</p>
|
||||
<p className="font-medium">{data.marketCap}</p>
|
||||
</div>
|
||||
)}
|
||||
{data.volume24h && (
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">24h Volume</p>
|
||||
<p className="font-medium">{data.volume24h}</p>
|
||||
</div>
|
||||
)}
|
||||
{data.liquidity && (
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Liquidity</p>
|
||||
<p className="font-medium">{data.liquidity}</p>
|
||||
</div>
|
||||
)}
|
||||
{data.safetyScore !== undefined && (
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Safety Score</p>
|
||||
<p className={cn("font-medium flex items-center gap-1", getSafetyColor(data.safetyScore))}>
|
||||
<Shield className="h-3 w-3" />
|
||||
{data.safetyScore}/100 ({getSafetyLabel(data.safetyScore)})
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Holder info */}
|
||||
{(data.holderCount || data.top10HolderPercent) && (
|
||||
<div className="flex items-center gap-4 mb-3 text-xs text-muted-foreground">
|
||||
{data.holderCount && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{data.holderCount.toLocaleString()} holders
|
||||
</span>
|
||||
)}
|
||||
{data.top10HolderPercent && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-1",
|
||||
data.top10HolderPercent > 50 ? "text-yellow-500" : ""
|
||||
)}>
|
||||
{data.top10HolderPercent > 50 && <AlertTriangle className="h-3 w-3" />}
|
||||
Top 10: {data.top10HolderPercent}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={onAddToWatchlist} className="flex-1 min-w-0 text-xs h-8">
|
||||
<Star className="h-3 w-3 mr-1 flex-shrink-0" />
|
||||
<span className="truncate">{isInWatchlist ? "In Watchlist" : "Add to Watchlist"}</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onSetAlert} className="h-8 w-8 p-0 flex-shrink-0">
|
||||
<Bell className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="default" onClick={onAnalyzeFurther} className="text-xs h-8">
|
||||
Analyze More
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { TradingSuggestionPanel, type TradingSuggestion } from "../analysis/TradingSuggestionPanel";
|
||||
|
||||
export interface TradingSuggestionWidgetProps {
|
||||
/** Trading suggestion data */
|
||||
suggestion: TradingSuggestion;
|
||||
/** Callback when "Set Alerts" is clicked */
|
||||
onSetAlerts?: () => void;
|
||||
/** Callback when "View Chart" is clicked */
|
||||
onViewChart?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* TradingSuggestionWidget - Inline trading suggestion display in chat
|
||||
* Wraps TradingSuggestionPanel for conversational UX
|
||||
*/
|
||||
export function TradingSuggestionWidget({
|
||||
suggestion,
|
||||
onSetAlerts,
|
||||
onViewChart,
|
||||
}: TradingSuggestionWidgetProps) {
|
||||
return (
|
||||
<div className="my-3 max-h-[600px] overflow-hidden rounded-lg border">
|
||||
<TradingSuggestionPanel
|
||||
suggestion={suggestion}
|
||||
onSetAlerts={onSetAlerts}
|
||||
onViewChart={onViewChart}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { Flame, TrendingUp, TrendingDown, Star } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface TrendingToken {
|
||||
symbol: string;
|
||||
name: string;
|
||||
chain: string;
|
||||
contractAddress?: string;
|
||||
price: number;
|
||||
priceChange24h: number;
|
||||
priceChange1h?: number;
|
||||
volume24h?: number;
|
||||
liquidity?: number;
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
export interface TrendingTokensWidgetProps {
|
||||
/** List of trending tokens */
|
||||
tokens: TrendingToken[];
|
||||
/** Filter by chain (optional) */
|
||||
chain?: string;
|
||||
/** Timeframe for trending data */
|
||||
timeframe?: string;
|
||||
/** Callback when token is clicked */
|
||||
onTokenClick?: (token: TrendingToken) => void;
|
||||
/** Callback when add to watchlist is clicked */
|
||||
onAddToWatchlist?: (token: TrendingToken) => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatPrice = (price: number): string => {
|
||||
if (price < 0.00001) return `$${price.toExponential(2)}`;
|
||||
if (price < 1) return `$${price.toFixed(6)}`;
|
||||
return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const formatLargeNumber = (num: number): string => {
|
||||
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
|
||||
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
|
||||
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
|
||||
return `$${num.toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* TrendingTokensWidget - Displays trending/hot tokens inline in chat
|
||||
* Used when AI responds to "what's hot on Solana?" or "show trending tokens"
|
||||
*/
|
||||
export function TrendingTokensWidget({
|
||||
tokens,
|
||||
chain = "All Chains",
|
||||
timeframe = "24h",
|
||||
onTokenClick,
|
||||
onAddToWatchlist,
|
||||
className,
|
||||
}: TrendingTokensWidgetProps) {
|
||||
return (
|
||||
<div className={cn("rounded-lg border bg-card p-4 my-2", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Flame className="h-5 w-5 text-orange-500" />
|
||||
<span className="font-medium text-sm">Trending on {chain}</span>
|
||||
<span className="text-xs bg-muted px-2 py-0.5 rounded">{timeframe}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token List */}
|
||||
{tokens.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-4 text-sm">No trending tokens found</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{tokens.map((token, index) => (
|
||||
<div
|
||||
key={token.symbol + index}
|
||||
className="flex items-center justify-between py-2.5 hover:bg-muted/50 -mx-2 px-2 rounded cursor-pointer transition-colors"
|
||||
onClick={() => onTokenClick?.(token)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-muted-foreground w-5">
|
||||
#{token.rank || index + 1}
|
||||
</span>
|
||||
<ChainIcon chain={token.chain} size="xs" />
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-sm">{token.symbol}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{token.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-sm">{formatPrice(token.price)}</p>
|
||||
<p className={cn(
|
||||
"text-xs flex items-center justify-end gap-0.5",
|
||||
token.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{token.priceChange24h >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{token.priceChange24h >= 0 ? "+" : ""}{token.priceChange24h.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
{token.volume24h && (
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-xs text-muted-foreground">Vol</p>
|
||||
<p className="text-xs">{formatLargeNumber(token.volume24h)}</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddToWatchlist?.(token);
|
||||
}}
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { TrendingUp, TrendingDown, Bell, Trash2, Search, Plus } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface WatchlistItem {
|
||||
id: string;
|
||||
symbol: string;
|
||||
name?: string;
|
||||
chain: string;
|
||||
price: string;
|
||||
priceChange24h: number;
|
||||
alertCount?: number;
|
||||
}
|
||||
|
||||
export interface WatchlistWidgetProps {
|
||||
/** List of tokens in watchlist */
|
||||
tokens: WatchlistItem[];
|
||||
/** Callback when analyze token is clicked */
|
||||
onAnalyze?: (token: WatchlistItem) => void;
|
||||
/** Callback when remove token is clicked */
|
||||
onRemove?: (tokenId: string) => void;
|
||||
/** Callback when add token is clicked */
|
||||
onAddToken?: () => void;
|
||||
/** Callback when clear all is clicked */
|
||||
onClearAll?: () => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WatchlistWidget - Embedded watchlist widget for chat interface
|
||||
*
|
||||
* Displays user's watchlist inline in the chat conversation.
|
||||
* Supports quick actions like analyze, remove, and add tokens.
|
||||
*/
|
||||
export function WatchlistWidget({
|
||||
tokens,
|
||||
onAnalyze,
|
||||
onRemove,
|
||||
onAddToken,
|
||||
onClearAll,
|
||||
className,
|
||||
}: WatchlistWidgetProps) {
|
||||
if (tokens.length === 0) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg border bg-card p-4 my-2",
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-lg">📋</span>
|
||||
<span className="font-medium text-sm">Your Watchlist</span>
|
||||
</div>
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||
Your watchlist is empty. Add tokens to track them!
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={onAddToken} className="w-full">
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add Token
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg border bg-card p-4 my-2",
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">📋</span>
|
||||
<span className="font-medium text-sm">WatchlistWidget</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{tokens.length} tokens</span>
|
||||
</div>
|
||||
|
||||
{/* Token list */}
|
||||
<div className="space-y-2 mb-3">
|
||||
{tokens.map((token) => (
|
||||
<div
|
||||
key={token.id}
|
||||
className="flex items-center gap-3 p-2 rounded-md bg-muted/50 hover:bg-muted transition-colors"
|
||||
>
|
||||
{/* Token info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{token.symbol}</span>
|
||||
<ChainIcon chain={token.chain} size="sm" />
|
||||
{token.alertCount && token.alertCount > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-xs text-primary">
|
||||
<Bell className="h-3 w-3" />
|
||||
{token.alertCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{token.price}</p>
|
||||
</div>
|
||||
|
||||
{/* Price change */}
|
||||
<div className={cn(
|
||||
"flex items-center gap-1 text-xs font-medium",
|
||||
token.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{token.priceChange24h >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{token.priceChange24h >= 0 ? "+" : ""}{token.priceChange24h.toFixed(1)}%
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className="p-1 hover:bg-background rounded text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onAnalyze?.(token)}
|
||||
title="Analyze"
|
||||
>
|
||||
<Search className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 hover:bg-destructive/10 rounded text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onRemove?.(token.id)}
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="flex gap-2 pt-2 border-t">
|
||||
<Button size="sm" variant="outline" onClick={onAddToken} className="flex-1">
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add Token
|
||||
</Button>
|
||||
{tokens.length > 0 && (
|
||||
<Button size="sm" variant="ghost" onClick={onClearAll} className="text-destructive">
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { WhaleActivityFeed, type WhaleTransaction } from "../whale/WhaleActivityFeed";
|
||||
|
||||
export interface WhaleActivityWidgetProps {
|
||||
/** List of whale transactions */
|
||||
transactions: WhaleTransaction[];
|
||||
/** Callback when a wallet is tracked */
|
||||
onTrackWallet?: (address: string) => void;
|
||||
/** Callback when a transaction is viewed */
|
||||
onViewTransaction?: (txHash: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* WhaleActivityWidget - Inline whale activity display in chat
|
||||
* Wraps WhaleActivityFeed for conversational UX
|
||||
*/
|
||||
export function WhaleActivityWidget({
|
||||
transactions,
|
||||
onTrackWallet,
|
||||
onViewTransaction,
|
||||
}: WhaleActivityWidgetProps) {
|
||||
return (
|
||||
<div className="my-3">
|
||||
<WhaleActivityFeed
|
||||
transactions={transactions}
|
||||
onTrackWallet={onTrackWallet}
|
||||
onViewTransaction={onViewTransaction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
27
surfsense_browser_extension/sidepanel/widgets/index.ts
Normal file
27
surfsense_browser_extension/sidepanel/widgets/index.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Conversational UX Widgets for SurfSense Browser Extension
|
||||
// These widgets are embedded inline in chat messages for a conversation-first experience
|
||||
|
||||
export { ActionConfirmationWidget, type ActionConfirmationProps } from "./ActionConfirmationWidget";
|
||||
export { ProactiveAlertCard, type ProactiveAlertCardProps, type ProactiveAlertData } from "./ProactiveAlertCard";
|
||||
export { WatchlistWidget, type WatchlistWidgetProps, type WatchlistItem } from "./WatchlistWidget";
|
||||
export { AlertWidget, type AlertWidgetProps, type AlertConfigData } from "./AlertWidget";
|
||||
export { TokenAnalysisWidget, type TokenAnalysisWidgetProps, type TokenAnalysisData } from "./TokenAnalysisWidget";
|
||||
|
||||
// Epic 2: Smart Monitoring & Alerts
|
||||
export { WhaleActivityWidget, type WhaleActivityWidgetProps } from "./WhaleActivityWidget";
|
||||
export { TrendingTokensWidget, type TrendingTokensWidgetProps, type TrendingToken } from "./TrendingTokensWidget";
|
||||
|
||||
// Epic 3: Trading Intelligence
|
||||
export { TradingSuggestionWidget, type TradingSuggestionWidgetProps } from "./TradingSuggestionWidget";
|
||||
export { PortfolioWidget, type PortfolioWidgetProps } from "./PortfolioWidget";
|
||||
export { HolderAnalysisWidget, type HolderAnalysisWidgetProps, type HolderAnalysisData, type Holder } from "./HolderAnalysisWidget";
|
||||
|
||||
// Epic 4: Content Creation & Productivity
|
||||
export { ChartCaptureWidget, type ChartCaptureWidgetProps } from "./ChartCaptureWidget";
|
||||
export { ThreadGeneratorWidget, type ThreadGeneratorWidgetProps } from "./ThreadGeneratorWidget";
|
||||
|
||||
// Market Data Widgets
|
||||
export { MarketOverviewWidget, type MarketOverviewWidgetProps, type MarketOverviewData, type MarketToken } from "./MarketOverviewWidget";
|
||||
export { LiveTokenPriceWidget, type LiveTokenPriceWidgetProps, type LiveTokenPriceData } from "./LiveTokenPriceWidget";
|
||||
export { LiveTokenDataWidget, type LiveTokenDataWidgetProps, type LiveTokenDataInfo } from "./LiveTokenDataWidget";
|
||||
|
||||
|
|
@ -3,7 +3,13 @@ const { fontFamily } = require("tailwindcss/defaultTheme");
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: ["./*.{js,jsx,ts,tsx}", "./routes/*.tsx", "./routes/**/*.tsx"],
|
||||
content: [
|
||||
"./*.{js,jsx,ts,tsx}",
|
||||
"./routes/**/*.{js,jsx,ts,tsx}",
|
||||
"./sidepanel/**/*.{js,jsx,ts,tsx}",
|
||||
"./lib/**/*.{js,jsx,ts,tsx}",
|
||||
"./background/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,24 @@
|
|||
{
|
||||
"extends": "plasmo/templates/tsconfig.base.json",
|
||||
"exclude": ["node_modules"],
|
||||
"include": [".plasmo/index.d.ts", "./**/*.ts", "./**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"~*": ["./*"],
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
}
|
||||
"extends": "plasmo/templates/tsconfig.base.json",
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"include": [
|
||||
".plasmo/index.d.ts",
|
||||
"./**/*.ts",
|
||||
"./**/*.tsx"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"~*": [
|
||||
"./*"
|
||||
],
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"baseUrl": ".",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue