feat: add Escape key to close collapsible sections and arrow key navigation in tables
This commit is contained in:
@@ -138,7 +138,7 @@ export function EmotionLogger() {
|
|||||||
key={opt.label}
|
key={opt.label}
|
||||||
onClick={() => handleQuickAdd(opt.label)}
|
onClick={() => handleQuickAdd(opt.label)}
|
||||||
title={`Log ${opt.label}`}
|
title={`Log ${opt.label}`}
|
||||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${opt.bgColor} ${opt.color} ${opt.borderColor} hover:opacity-80`}
|
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors ${opt.bgColor} ${opt.color} ${opt.borderColor} hover:opacity-80`}
|
||||||
>
|
>
|
||||||
<span>{opt.emoji}</span>
|
<span>{opt.emoji}</span>
|
||||||
<span>{opt.label}</span>
|
<span>{opt.label}</span>
|
||||||
|
|||||||
@@ -2,13 +2,12 @@
|
|||||||
import { SidebarNav } from "./SidebarNav";
|
import { SidebarNav } from "./SidebarNav";
|
||||||
import { WatchlistSidebar } from "./WatchlistSidebar";
|
import { WatchlistSidebar } from "./WatchlistSidebar";
|
||||||
import { SymbolHeader } from "./SymbolHeader";
|
import { SymbolHeader } from "./SymbolHeader";
|
||||||
import { MobileTabNav } from "./MobileTabNav";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main layout shell for the redesigned terminal.
|
* Main layout shell for the redesigned terminal.
|
||||||
* - Desktop (>1024px): Sidebar + Watchlist + Header + Content (3-column)
|
* - Desktop (>1024px): Sidebar + Watchlist + Header + Content (3-column)
|
||||||
* - Tablet (768-1024px): Watchlist + Header + Content (2-column, sidebar hidden)
|
* - Tablet (768-1024px): Compact watchlist bar + Header + Content (2-column, sidebar hidden, right column below center)
|
||||||
* - Mobile (<768px): Header + Content + TabBar (single column)
|
* - Mobile (<768px): Header + Content (single column)
|
||||||
*/
|
*/
|
||||||
export function LayoutShell({ children }: { children: React.ReactNode }) {
|
export function LayoutShell({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -16,6 +15,9 @@ export function LayoutShell({ children }: { children: React.ReactNode }) {
|
|||||||
{/* Symbol Header — always visible */}
|
{/* Symbol Header — always visible */}
|
||||||
<SymbolHeader />
|
<SymbolHeader />
|
||||||
|
|
||||||
|
{/* Tablet Compact Watchlist Bar — replaces sidebar on tablet */}
|
||||||
|
<CompactWatchlistBar />
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Left Sidebar Navigation — hidden on tablet/mobile */}
|
{/* Left Sidebar Navigation — hidden on tablet/mobile */}
|
||||||
@@ -24,16 +26,88 @@ export function LayoutShell({ children }: { children: React.ReactNode }) {
|
|||||||
{/* Watchlist Sidebar — hidden on mobile */}
|
{/* Watchlist Sidebar — hidden on mobile */}
|
||||||
<WatchlistSidebar />
|
<WatchlistSidebar />
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content + Right Analytics Column */}
|
||||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6 pb-20 md:pb-6">
|
<div className="flex flex-1 min-w-0">
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
{/* Center Content — full width on tablet/mobile, narrower on desktop */}
|
||||||
{children}
|
<main className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||||
</div>
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
</main>
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
{/* Mobile Tab Navigation — shown on mobile only */}
|
{/* Right Analytics Column — drops below center on tablet */}
|
||||||
<MobileTabNav />
|
<RightAnalyticsColumn />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact horizontal watchlist bar for tablet (768-1024px).
|
||||||
|
* Replaces the sidebar on tablet — shows 2-3 symbols with mini-overview.
|
||||||
|
* Hidden on desktop and mobile.
|
||||||
|
*/
|
||||||
|
function CompactWatchlistBar() {
|
||||||
|
return (
|
||||||
|
<div className="hidden md:flex items-center gap-3 px-4 py-2 border-b border-[#2a2b3a] bg-[#121318]/95 backdrop-blur-sm overflow-x-auto">
|
||||||
|
{/* Placeholder watchlist items — will be replaced with real data */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<span className="text-xs font-bold text-[#e6e7ec]">AAPL</span>
|
||||||
|
<span className="text-xs font-mono text-[#34d399]">+1.2%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<span className="text-xs font-bold text-[#e6e7ec]">MSFT</span>
|
||||||
|
<span className="text-xs font-mono text-[#34d399]">+0.8%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<span className="text-xs font-bold text-[#e6e7ec]">NVDA</span>
|
||||||
|
<span className="text-xs font-mono text-[#fbbf24]">-0.3%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right analytics column containing Filings and Options DD panels.
|
||||||
|
* Desktop: flex row with center + right columns side by side
|
||||||
|
* Tablet: right column drops below center content (flex column)
|
||||||
|
*/
|
||||||
|
function RightAnalyticsColumn() {
|
||||||
|
return (
|
||||||
|
<div className="hidden lg:flex flex-col w-96 border-l border-[#2a2b3a] bg-[#121318]/50 overflow-y-auto p-4 space-y-4">
|
||||||
|
{/* Collapsible Filings Panel */}
|
||||||
|
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318]">
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center justify-between px-5 py-3 rounded-t-xl hover:bg-[#1a1b24] transition-colors"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-semibold text-[#e6e7ec]">SEC Filings</h2>
|
||||||
|
<span className="text-[10px] text-[#5a5b6a]">▼</span>
|
||||||
|
</button>
|
||||||
|
<div className="px-5 pb-5">
|
||||||
|
<p className="text-xs text-[#5a5b6a]">Loading filings...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Collapsible Options DD Panel */}
|
||||||
|
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318]">
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center justify-between px-5 py-3 rounded-t-xl hover:bg-[#1a1b24] transition-colors"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-semibold text-[#e6e7ec]">Options Due Diligence</h2>
|
||||||
|
<span className="text-[10px] text-[#5a5b6a]">▼</span>
|
||||||
|
</button>
|
||||||
|
<div className="px-5 pb-5">
|
||||||
|
<p className="text-xs text-[#5a5b6a]">Loading options data...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t border-[#2a2b3a] pt-4 mt-4">
|
||||||
|
<p className="text-[11px] text-[#5a5b6a]">Educational analysis, not investment advice.</p>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left sidebar navigation with collapsible sections.
|
||||||
|
* Organized by workflow phase: Research, Execution, Analytics, Settings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface NavSection {
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
items: { label: string; href: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTIONS: NavSection[] = [
|
||||||
|
{
|
||||||
|
title: "Research",
|
||||||
|
icon: "📊",
|
||||||
|
items: [
|
||||||
|
{ label: "Overview", href: "/" },
|
||||||
|
{ label: "Chart Lab", href: "/chart-lab" },
|
||||||
|
{ label: "Institutional", href: "/institutional" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Execution",
|
||||||
|
icon: "⚡",
|
||||||
|
items: [
|
||||||
|
{ label: "Trade Plan", href: "/trade-plan" },
|
||||||
|
{ label: "Execution", href: "/execution" },
|
||||||
|
{ label: "Daily Focus", href: "/daily-focus" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Analytics",
|
||||||
|
icon: "🔬",
|
||||||
|
items: [
|
||||||
|
{ label: "Filings", href: "/filings" },
|
||||||
|
{ label: "Options DD", href: "/options-dd" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Settings",
|
||||||
|
icon: "⚙️",
|
||||||
|
items: [
|
||||||
|
{ label: "Profile / Auth", href: "/settings" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SidebarNav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const toggleCollapse = (sectionTitle: string) => {
|
||||||
|
setCollapsed(prev => ({ ...prev, [sectionTitle]: !prev[sectionTitle] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-52 border-r border-[#2a2b3a] bg-[#121318] flex-shrink-0 hidden lg:block">
|
||||||
|
<nav className="p-3 space-y-1">
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<div key={section.title} className="mb-1">
|
||||||
|
{/* Section Header */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleCollapse(section.title)}
|
||||||
|
className="w-full flex items-center justify-between px-2 py-1.5 text-xs font-semibold text-[#8a8b9a] hover:text-[#e6e7ec] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>{section.icon}</span>
|
||||||
|
<span>{section.title}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] transition-transform duration-200" style={{ transform: collapsed[section.title] ? "rotate(-90deg)" : "none" }}>
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Section Items */}
|
||||||
|
{!collapsed[section.title] && (
|
||||||
|
<div className="ml-4 mt-1 space-y-0.5">
|
||||||
|
{section.items.map((item) => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
href={item.href}
|
||||||
|
className={`block px-2 py-1.5 rounded-lg text-xs transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-[#4f8cff]/20 text-[#4f8cff] border-l-2 border-[#4f8cff]"
|
||||||
|
: "text-[#8a8b9a] hover:text-[#e6e7ec] hover:bg-[#1a1b24]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
import { UI_STRINGS } from "@/lib/strings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent top bar showing the active symbol, price, change, and search.
|
||||||
|
* This is the "central nervous system" — switching symbols updates everything globally.
|
||||||
|
*/
|
||||||
|
export function SymbolHeader() {
|
||||||
|
const symbol = useActiveSymbol((s) => s.activeSymbol);
|
||||||
|
const setActive = useActiveSymbol((s) => s.setActive);
|
||||||
|
const [draft, setDraft] = useState(symbol);
|
||||||
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
|
|
||||||
|
// Fetch current price for display
|
||||||
|
const { data: snap } = api.market.snapshot.useQuery(draft || symbol);
|
||||||
|
const quote = snap?.quote ?? null;
|
||||||
|
const sector = snap?.sector ?? null;
|
||||||
|
|
||||||
|
const up = (quote?.change ?? 0) >= 0;
|
||||||
|
const priceLabel = quote ? `$${quote.price?.toFixed(2)}` : "";
|
||||||
|
const changeLabel = quote && quote.change !== undefined
|
||||||
|
? `${up ? "▲" : "▼"}${up ? "+" : ""}${quote.change.toFixed(2)} (${up ? "+" : ""}${(quote.changePercent ?? 0).toFixed(2)}%)`
|
||||||
|
: "";
|
||||||
|
const sectorLabel = sector ? `${sector.sector} · ${sector.tickerKind}` : "";
|
||||||
|
|
||||||
|
const handleSubmit = useCallback((e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const sym = draft.trim().toUpperCase() || symbol;
|
||||||
|
setActive(sym);
|
||||||
|
setShowSearch(false);
|
||||||
|
}, [draft, symbol, setActive]);
|
||||||
|
|
||||||
|
// Keyboard shortcut: Ctrl+K to toggle search
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowSearch(prev => !prev);
|
||||||
|
}
|
||||||
|
if (e.key === "Escape" && showSearch) {
|
||||||
|
setShowSearch(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [showSearch]);
|
||||||
|
|
||||||
|
// Sync draft when active symbol changes externally
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(symbol);
|
||||||
|
}, [symbol]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 border-b border-[#2a2b3a] bg-[#121318]/95 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center gap-4 px-4 py-2.5">
|
||||||
|
{/* Symbol Info */}
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
|
<h1 className="text-base font-bold text-[#4f8cff]">Investor Flow</h1>
|
||||||
|
<div className="hidden sm:flex items-baseline gap-2">
|
||||||
|
<span className="text-lg font-bold text-[#e6e7ec]">{symbol}</span>
|
||||||
|
{priceLabel && <span className="text-sm font-mono text-[#e6e7ec]">{priceLabel}</span>}
|
||||||
|
{changeLabel && (
|
||||||
|
<span className={`text-xs font-mono ${up ? "text-[#34d399]" : "text-[#fbbf24]"}`}>
|
||||||
|
{changeLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<form onSubmit={handleSubmit} className="flex-1 max-w-xs">
|
||||||
|
<div className={`relative transition-all duration-200 ${showSearch ? "w-full" : ""}`}>
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onFocus={() => setShowSearch(true)}
|
||||||
|
onBlur={() => setTimeout(() => setShowSearch(false), 200)}
|
||||||
|
placeholder="Search symbol (Ctrl+K)"
|
||||||
|
className="w-full bg-[#1a1b24] border border-[#2a2b3a] rounded-lg px-3 py-1.5 text-sm text-[#e6e7ec] placeholder:text-[#5a5b6a] focus:outline-none focus:border-[#4f8cff]"
|
||||||
|
/>
|
||||||
|
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] text-[#5a5b6a] bg-[#0a0b0f] border border-[#2a2b3a] rounded px-1.5">
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Sector info */}
|
||||||
|
{sectorLabel && (
|
||||||
|
<span className="hidden md:block text-xs text-[#5a5b6a]">{sectorLabel}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile symbol display */}
|
||||||
|
<div className="sm:hidden flex items-center gap-2">
|
||||||
|
<span className="text-sm font-bold text-[#e6e7ec]">{symbol}</span>
|
||||||
|
{priceLabel && (
|
||||||
|
<span className="text-xs font-mono text-[#e6e7ec]">{priceLabel}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||||
|
import { api, type WatchlistEntry, type Quote } from "@/lib/trpc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact watchlist sidebar with mini sparklines and quick symbol switching.
|
||||||
|
* Shows portfolio summary at top. Replaces the full WatchlistPanel on the main page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface MiniQuote extends Quote {
|
||||||
|
sparkline?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WatchlistSidebar() {
|
||||||
|
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
|
||||||
|
const setActive = useActiveSymbol((s) => s.setActive);
|
||||||
|
const [entries, setEntries] = useState<WatchlistEntry[]>([]);
|
||||||
|
const [quotes, setQuotes] = useState<Map<string, MiniQuote>>(new Map());
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Portfolio summary
|
||||||
|
const { data: holdings } = api.portfolio.holdings.useQuery();
|
||||||
|
const totalValue = (holdings ?? []).reduce((sum, h) => sum + h.shares * h.avg_cost, 0);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setEntries(await api.watchlists.list());
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
// Fetch quotes for watchlist symbols
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchQuotes = async () => {
|
||||||
|
const quoteMap = new Map<string, MiniQuote>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
try {
|
||||||
|
const snap = await api.market.snapshot(entry.symbol);
|
||||||
|
if (snap.quote && snap.candles) {
|
||||||
|
const sparkline = snap.candles.slice(-20).map(c => c.c);
|
||||||
|
quoteMap.set(entry.symbol, { ...snap.quote, symbol: entry.symbol, sparkline });
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
setQuotes(quoteMap);
|
||||||
|
};
|
||||||
|
if (entries.length > 0) fetchQuotes();
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
if (!draft.trim()) return;
|
||||||
|
const sym = draft.trim().toUpperCase();
|
||||||
|
await api.watchlists.addSymbol(sym);
|
||||||
|
setDraft("");
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (symbol: string) => {
|
||||||
|
await api.watchlists.removeSymbol(symbol);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-56 border-r border-[#2a2b3a] bg-[#121318] flex-shrink-0 hidden lg:block">
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
{/* Portfolio Summary */}
|
||||||
|
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Portfolio</p>
|
||||||
|
<p className="text-sm font-bold text-[#e6e7ec]">${totalValue.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</p>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<span className="text-[10px] text-[#5a5b6a]">{entries.length} symbols</span>
|
||||||
|
<span className="text-[10px] text-[#5a5b6a]">{holdings?.length ?? 0} holdings</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Symbol */}
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); add(); }} className="flex gap-1">
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
placeholder="Add symbol..."
|
||||||
|
className="flex-1 bg-[#0a0b0f] border border-[#2a2b3a] rounded px-2 py-1.5 text-xs text-[#e6e7ec] placeholder:text-[#5a5b6a] focus:outline-none focus:border-[#4f8cff]"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="px-2 py-1.5 rounded text-xs bg-[#4f8cff] text-white">+</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Watchlist */}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-xs text-[#5a5b6a]">Loading...</p>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<p className="text-xs text-[#5a5b6a]">No symbols tracked</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5 max-h-[calc(100vh-320px)] overflow-y-auto">
|
||||||
|
{entries.map((e) => {
|
||||||
|
const quote = quotes.get(e.symbol);
|
||||||
|
const isActive = e.symbol === activeSymbol;
|
||||||
|
const up = (quote?.change ?? 0) >= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={e.symbol}
|
||||||
|
onClick={() => setActive(e.symbol)}
|
||||||
|
className={`w-full flex items-center justify-between py-1.5 px-2 rounded-lg text-xs transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-[#4f8cff]/20 border-l-2 border-[#4f8cff]"
|
||||||
|
: "hover:bg-[#1a1b24]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`font-mono font-bold ${isActive ? "text-[#4f8cff]" : "text-[#e6e7ec]"}`}>
|
||||||
|
{e.symbol}
|
||||||
|
</span>
|
||||||
|
{/* Mini sparkline */}
|
||||||
|
{quote?.sparkline && quote.sparkline.length > 1 && (
|
||||||
|
<svg width="24" height="12" className="flex-shrink-0">
|
||||||
|
<polyline
|
||||||
|
points={quote.sparkline.map((v, i) => `${(i / (quote.sparkline!.length - 1)) * 24},${12 - ((v - Math.min(...quote.sparkline)) / (Math.max(...quote.sparkline) - Math.min(...quote.sparkline) || 1)) * 10}`).join(" ")}
|
||||||
|
fill="none"
|
||||||
|
stroke={up ? "#34d399" : "#fbbf24"}
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{quote && (
|
||||||
|
<>
|
||||||
|
<span className={`font-mono ${isActive ? "text-[#e6e7ec]" : "text-[#8a8b9a]"}`}>
|
||||||
|
${quote.price?.toFixed(0)}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[9px] font-mono ${up ? "text-[#34d399]" : "text-[#fbbf24]"}`}>
|
||||||
|
{up ? "+" : ""}{quote.changePercent?.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(ev) => { ev.stopPropagation(); remove(e.symbol); }}
|
||||||
|
className="text-[9px] text-[#5a5b6a] hover:text-[#ef4444]"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user