GammaDesk initial commit - Options Gamma Analytics platform

master
isnowglobal-admin 2026-05-21 01:41:11 -04:00
commit 003a913186
101 changed files with 18628 additions and 0 deletions

16
.env.example Normal file
View File

@ -0,0 +1,16 @@
# GammaDesk server configuration
# Keep ORATS credentials server-side only. Never expose this as a VITE_ variable.
ORATS_API_KEY=
# delayed | live | intraday
ORATS_MODE=delayed
# Defaults:
# delayed: https://api.orats.io/datav2
# live: https://api.orats.io/datav2/live
ORATS_BASE_URL=https://api.orats.io/datav2
# Server port used by Express in production.
PORT=5000

20
.gitignore vendored Normal file
View File

@ -0,0 +1,20 @@
# Dependencies
node_modules/
# Build output
dist/
# Environment
.env
.env.local
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Logs
*.log

110
README.md Normal file
View File

@ -0,0 +1,110 @@
# GammaDesk
GammaDesk is an options-market analytics dashboard for TanukiTrade-style workflows built on a clean-room calculation engine. The MVP currently runs on deterministic mock ORATS-style data, with a server-side ORATS connector stub ready for live or delayed API wiring.
The product goal is to highlight the levels an active trader cares about:
- **GEX profile** by strike
- **HVL / gamma flip**
- **Call wall / call resistance**
- **Put wall / put support**
- **Selected-alone vs cumulative expiration views**
- **IV rank, IVx, expected move, and skew**
- **Symbol screener and presets**
This is analytics software only. It is not financial advice and it should not be treated as a standalone trading signal.
## Quick start
```bash
npm install
npm run dev
```
The development server runs the Express API and Vite frontend together.
Useful scripts:
```bash
npm run dev # start local dev server
npm run build # production build
npm run start # run production server after build
npm run check # TypeScript check
```
## Environment
Copy `.env.example` to `.env` when wiring real ORATS data:
```bash
cp .env.example .env
```
The ORATS key must stay server-side. Do not expose it through Vite `VITE_` variables or browser settings.
## Project layout
```text
client/
src/
components/ Reusable dashboard components and charts
lib/ Formatting, API client, symbol/theme contexts
pages/ Dashboard, Gamma Levels, Expiry Matrix, Screener, Settings
server/
index.ts Express server entrypoint
routes.ts API routes consumed by the frontend and NT8 connector
marketData.ts Mock ORATS-style data generation and gamma calculations
oratsClient.ts Future ORATS API client integration point
shared/
schema.ts Shared TypeScript types and Zod schemas
docs/
architecture.md Product architecture and data flow
orats-field-map.md ORATS field mapping and calculation plan
implementation.md Build roadmap and engineering notes
```
## Current API routes
```text
GET /api/symbols
GET /api/orats/status
GET /api/market/:symbol/summary
GET /api/market/:symbol/gex
GET /api/market/:symbol/expirations
GET /api/screener
```
These routes currently return mock data from `server/marketData.ts`. The route shape is intentionally stable so NinjaTrader, future TradingView adapters, and the frontend can keep the same contracts after ORATS is connected.
## ORATS integration path
The intended production path is:
1. Implement `server/oratsClient.ts` methods for ORATS strikes, summaries, monies implied, expirations, tickers, and IV rank.
2. Normalize ORATS rows into the shared GammaDesk model.
3. Feed normalized rows into the existing gamma engine.
4. Keep mock fallback available for demos and local development.
5. Add caching and rate-limit protection before using large screeners.
See `docs/orats-field-map.md` for exact field mapping.
## Gamma calculation convention
Default Tanuki-style clean-room convention:
```ts
callGex = +gamma * callOpenInterest * 100 * spot ** 2 * 0.01;
putGex = -gamma * putOpenInterest * 100 * spot ** 2 * 0.01;
netGex = callGex + putGex;
```
The sign convention should remain configurable internally because public vendors differ in dealer-positioning assumptions and TanukiTrade does not publicly disclose its proprietary formula.
## Related deliverables
- NinjaTrader 8 indicator package: `gammadesk-ninjatrader8`
- ORATS integration field-mapping spec: `docs/orats-field-map.md`

20
client/index.html Normal file
View File

@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<title>GammaDesk — Options Gamma Analytics</title>
<meta name="description" content="Dealer gamma exposure, expected move, and IV analytics across expirations. Built for traders who think in walls and flips." />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Architects+Daughter&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Fira+Code:wght@300..700&family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Lora:ital,wght@0,400..700;1,400..700&family=Merriweather:ital,opsz,wght@0,18..144,300..900;1,18..144,300..900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Outfit:wght@100..900&family=Oxanium:wght@200..800&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100..900;1,100..900&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

116
client/src/App.tsx Normal file
View File

@ -0,0 +1,116 @@
import { useEffect } from "react";
import { Switch, Route, Router, useLocation } from "wouter";
import { useHashLocation } from "wouter/use-hash-location";
import { queryClient } from "./lib/queryClient";
import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { SidebarProvider } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/app-sidebar";
import { TopBar } from "@/components/top-bar";
import { SymbolProvider } from "@/lib/symbol-context";
import { ThemeProvider } from "@/lib/theme-context";
import { AuthProvider, useAuth } from "@/lib/auth-context";
import DashboardPage from "@/pages/dashboard";
import GammaLevelsPage from "@/pages/gamma-levels";
import ExpiryMatrixPage from "@/pages/expiry-matrix";
import ScreenerPage from "@/pages/screener";
import SettingsPage from "@/pages/settings";
import AccountPage from "@/pages/account";
import LoginPage from "@/pages/login";
import NotFound from "@/pages/not-found";
import { Loader2 } from "lucide-react";
function usePageTitle(): string {
const [location] = useLocation();
if (location.startsWith("/gamma")) return "Gamma Levels";
if (location.startsWith("/expiry")) return "Expiry Matrix";
if (location.startsWith("/screener")) return "Screener";
if (location.startsWith("/settings")) return "Settings & API";
if (location.startsWith("/account")) return "Account";
if (location.startsWith("/login")) return "Sign In";
return "Dashboard";
}
// Redirect to login if not authenticated
function ProtectedRoute({ component: Component }: { component: () => JSX.Element }) {
const { user, loading } = useAuth();
const [, setLocation] = useLocation();
// Must call hooks unconditionally before any early return
useEffect(() => {
if (!loading && !user) {
setLocation("/login");
}
}, [loading, user, setLocation]);
if (loading) {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!user) {
return null;
}
return <Component />;
}
function AppShell() {
const title = usePageTitle();
const [location] = useLocation();
const isAuthPage = location === "/login";
return (
<div className="flex h-screen w-full overflow-hidden">
{!isAuthPage && <AppSidebar />}
<div className="flex min-w-0 flex-1 flex-col">
<TopBar title={title} />
<main
className="flex-1 overflow-y-auto"
style={{ overscrollBehavior: "contain" }}
data-testid="main-content"
>
<Switch>
<Route path="/login" component={LoginPage} />
<Route path="/account" component={AccountPage} />
<Route path="/" component={() => <ProtectedRoute component={DashboardPage} />} />
<Route path="/gamma" component={() => <ProtectedRoute component={GammaLevelsPage} />} />
<Route path="/expiry" component={() => <ProtectedRoute component={ExpiryMatrixPage} />} />
<Route path="/screener" component={() => <ProtectedRoute component={ScreenerPage} />} />
<Route path="/settings" component={() => <ProtectedRoute component={SettingsPage} />} />
<Route component={NotFound} />
</Switch>
</main>
</div>
</div>
);
}
export default function App() {
const sidebarStyle = {
"--sidebar-width": "16rem",
"--sidebar-width-icon": "3.25rem",
} as React.CSSProperties;
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<TooltipProvider delayDuration={200}>
<SymbolProvider>
<AuthProvider>
<Router hook={useHashLocation}>
<SidebarProvider style={sidebarStyle}>
<AppShell />
</SidebarProvider>
</Router>
<Toaster />
</AuthProvider>
</SymbolProvider>
</TooltipProvider>
</ThemeProvider>
</QueryClientProvider>
);
}

View File

@ -0,0 +1,83 @@
import { LayoutDashboard, Activity, CalendarRange, ListFilter, Settings2, UserCircle } from "lucide-react";
import { Link, useLocation } from "wouter";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { Logo } from "@/components/logo";
const NAV = [
{ title: "Dashboard", url: "/", icon: LayoutDashboard, testid: "nav-dashboard" },
{ title: "Gamma Levels", url: "/gamma", icon: Activity, testid: "nav-gamma" },
{ title: "Expiry Matrix", url: "/expiry", icon: CalendarRange, testid: "nav-expiry" },
{ title: "Screener", url: "/screener", icon: ListFilter, testid: "nav-screener" },
{ title: "Settings & API", url: "/settings", icon: Settings2, testid: "nav-settings" },
{ title: "Account", url: "/account", icon: UserCircle, testid: "nav-account" },
];
export function AppSidebar() {
const [location] = useLocation();
return (
<Sidebar data-testid="sidebar-main">
<SidebarHeader className="px-3 pt-4 pb-3">
<Link
href="/"
className="flex items-center gap-2 px-2"
data-testid="link-home"
>
<span className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-primary/15 text-primary">
<Logo className="h-5 w-5" />
</span>
<span className="flex flex-col leading-tight">
<span className="text-sm font-semibold tracking-tight">GammaDesk</span>
<span className="text-[11px] text-muted-foreground tracking-wide uppercase">
Options Analytics
</span>
</span>
</Link>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{NAV.map((item) => {
const active =
item.url === "/"
? location === "/" || location === ""
: location.startsWith(item.url);
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={active} data-testid={item.testid}>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="px-3 pb-4">
<p
className="text-[11px] leading-snug text-muted-foreground"
data-testid="text-sidebar-disclaimer"
>
Analytics &amp; education only. Not financial advice. Data is simulated
ORATS-style mock data until an API key is provided.
</p>
</SidebarFooter>
</Sidebar>
);
}

View File

@ -0,0 +1,123 @@
import { useMemo } from "react";
import {
Bar,
BarChart,
CartesianGrid,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { GexProfile } from "@shared/schema";
import { fmtCompactCurrency, fmtStrike } from "@/lib/format";
function ChartTooltip({ active, payload, label }: any) {
if (!active || !payload?.length) return null;
const row = payload[0]?.payload;
if (!row) return null;
return (
<div className="rounded-md border border-border bg-popover/95 px-3 py-2 text-xs shadow-md backdrop-blur">
<div className="mb-1 font-mono font-semibold">Strike {fmtStrike(label)}</div>
<div className="grid grid-cols-[80px_1fr] gap-x-2 num">
<span className="text-pos">Call γ</span>
<span>{fmtCompactCurrency(row.callGex)}</span>
<span className="text-neg">Put γ</span>
<span>{fmtCompactCurrency(row.putGex)}</span>
<span className="text-muted-foreground">Net</span>
<span>{fmtCompactCurrency(row.netGex)}</span>
</div>
</div>
);
}
export function GexChart({
profile,
height = 360,
}: {
profile: GexProfile;
height?: number;
}) {
const data = useMemo(
() =>
profile.bars.map((b) => ({
strike: b.strike,
callGex: b.callGex,
putGex: b.putGex,
netGex: b.netGex,
})),
[profile.bars],
);
const callColor = "hsl(var(--pos))";
const putColor = "hsl(var(--neg))";
const gridColor = "hsl(var(--border) / 0.5)";
const axisColor = "hsl(var(--muted-foreground))";
return (
<div style={{ width: "100%", height }} data-testid="chart-gex-profile">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 16, right: 24, bottom: 16, left: 8 }}>
<CartesianGrid stroke={gridColor} vertical={false} />
<XAxis
type="number"
dataKey="strike"
domain={["dataMin", "dataMax"]}
tick={{ fill: axisColor, fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: gridColor }}
tickFormatter={(v) => fmtStrike(v)}
minTickGap={24}
/>
<YAxis
tick={{ fill: axisColor, fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: gridColor }}
tickFormatter={(v) => fmtCompactCurrency(v as number)}
width={68}
/>
<Tooltip
content={<ChartTooltip />}
cursor={{ fill: "hsl(var(--foreground) / 0.06)" }}
/>
<ReferenceLine y={0} stroke={gridColor} strokeWidth={1} />
<ReferenceLine
x={profile.hvl}
stroke="hsl(var(--hvl))"
strokeWidth={1.5}
label={{
value: `HVL ${fmtStrike(profile.hvl)}`,
position: "insideTop",
fill: "hsl(var(--hvl))",
fontSize: 11,
}}
/>
<ReferenceLine
x={profile.callWall}
stroke="hsl(var(--call-wall))"
strokeWidth={1.5}
label={{
value: `Call Wall ${fmtStrike(profile.callWall)}`,
position: "insideTopLeft",
fill: "hsl(var(--call-wall))",
fontSize: 11,
}}
/>
<ReferenceLine
x={profile.putWall}
stroke="hsl(var(--put-wall))"
strokeWidth={1.5}
label={{
value: `Put Wall ${fmtStrike(profile.putWall)}`,
position: "insideBottomRight",
fill: "hsl(var(--put-wall))",
fontSize: 11,
}}
/>
<Bar dataKey="callGex" stackId="gex" fill={callColor} radius={[2, 2, 0, 0]} />
<Bar dataKey="putGex" stackId="gex" fill={putColor} radius={[0, 0, 2, 2]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}

View File

@ -0,0 +1,95 @@
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { ExpirationRow } from "@shared/schema";
function ChartTooltip({ active, payload, label }: any) {
if (!active || !payload?.length) return null;
return (
<div className="rounded-md border border-border bg-popover/95 px-3 py-2 text-xs shadow-md backdrop-blur">
<div className="mb-1 font-mono font-semibold">{label} DTE</div>
<div className="grid grid-cols-[80px_1fr] gap-x-2 num">
{payload.map((p: any) => (
<span key={p.dataKey} style={{ color: p.color }}>
{p.name}
<span className="ml-2 text-foreground">{Number(p.value).toFixed(2)}</span>
</span>
))}
</div>
</div>
);
}
export function IvSkewChart({
rows,
height = 240,
}: {
rows: ExpirationRow[];
height?: number;
}) {
const data = rows
.filter((r) => r.dte > 0)
.map((r) => ({ dte: r.dte, ivx: r.ivx, callSkew: r.callSkew, putSkew: r.putSkew }));
const gridColor = "hsl(var(--border) / 0.5)";
const axisColor = "hsl(var(--muted-foreground))";
return (
<div style={{ width: "100%", height }} data-testid="chart-iv-skew">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 12, right: 16, bottom: 8, left: 0 }}>
<CartesianGrid stroke={gridColor} vertical={false} />
<XAxis
dataKey="dte"
type="number"
tick={{ fill: axisColor, fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: gridColor }}
domain={["dataMin", "dataMax"]}
tickFormatter={(v) => `${v}d`}
/>
<YAxis
tick={{ fill: axisColor, fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: gridColor }}
tickFormatter={(v) => `${v}%`}
width={48}
/>
<Tooltip content={<ChartTooltip />} />
<Line
type="monotone"
dataKey="ivx"
name="IVx"
stroke="hsl(var(--call-wall))"
strokeWidth={2}
dot={{ r: 3, strokeWidth: 0, fill: "hsl(var(--call-wall))" }}
/>
<Line
type="monotone"
dataKey="callSkew"
name="Call IV"
stroke="hsl(var(--pos))"
strokeWidth={1.5}
strokeDasharray="4 3"
dot={false}
/>
<Line
type="monotone"
dataKey="putSkew"
name="Put IV"
stroke="hsl(var(--put-wall))"
strokeWidth={1.5}
strokeDasharray="4 3"
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}

View File

@ -0,0 +1,15 @@
import { Skeleton } from "@/components/ui/skeleton";
export function LoadingBlock({ height = 200 }: { height?: number }) {
return <Skeleton className="w-full" style={{ height }} />;
}
export function LoadingCards({ count = 6 }: { count?: number }) {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{Array.from({ length: count }).map((_, i) => (
<Skeleton key={i} className="h-[88px] w-full" />
))}
</div>
);
}

View File

@ -0,0 +1,22 @@
// GammaDesk mark — a gamma glyph (Γ) reduced to two intersecting strokes.
// Works at 20px to 200px; uses currentColor so it adapts to light/dark.
export function Logo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 32 32"
role="img"
aria-label="GammaDesk"
className={className}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="square"
>
<rect x="3" y="3" width="26" height="26" rx="6" stroke="currentColor" strokeWidth="1.5" opacity="0.35" />
<path d="M9 9 H22" />
<path d="M9 9 V23" />
<circle cx="22" cy="23" r="2.2" fill="currentColor" stroke="none" />
</svg>
);
}

View File

@ -0,0 +1,55 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Card } from "@/components/ui/card";
import { MetricTooltip, METRIC_INFO } from "@/components/metric-tooltip";
interface MetricCardProps {
label: string;
value: ReactNode;
metric?: keyof typeof METRIC_INFO;
hint?: ReactNode;
accent?: "default" | "pos" | "neg" | "call-wall" | "put-wall" | "hvl";
testId?: string;
}
const ACCENT_CLASSES: Record<NonNullable<MetricCardProps["accent"]>, string> = {
default: "text-foreground",
pos: "text-pos",
neg: "text-neg",
"call-wall": "text-call-wall",
"put-wall": "text-put-wall",
hvl: "text-hvl",
};
export function MetricCard({
label,
value,
metric,
hint,
accent = "default",
testId,
}: MetricCardProps) {
return (
<Card
className="flex flex-col gap-2 p-4"
data-testid={testId}
>
<div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<span>{label}</span>
{metric && <MetricTooltip metric={metric} />}
</div>
<div
className={cn(
"num text-lg font-semibold leading-none sm:text-xl",
ACCENT_CLASSES[accent],
)}
data-testid={testId ? `${testId}-value` : undefined}
>
{value}
</div>
{hint && (
<div className="text-xs text-muted-foreground num">{hint}</div>
)}
</Card>
);
}

View File

@ -0,0 +1,91 @@
import { Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
// Educational definitions for every metric exposed in the UI. The same
// content is reused in the Settings page as a glossary.
export const METRIC_INFO: Record<
string,
{ label: string; body: string }
> = {
spot: {
label: "Spot",
body: "Last traded price of the underlying. All gamma exposure is anchored to this price.",
},
netGex: {
label: "Net Gamma Exposure (GEX)",
body: "Estimated dealer gamma in dollars per 1% move. Positive net GEX implies dealers buy dips and sell rips (suppressed volatility); negative GEX implies the opposite (amplified moves).",
},
gammaRegime: {
label: "Gamma Regime",
body: "Sign of net dealer gamma. Positive = mean-reverting tape. Negative = trending, gappy tape.",
},
hvl: {
label: "HVL / Gamma Flip",
body: "High Volume Level — the strike where cumulative dealer gamma crosses zero. Below HVL the tape tends negative-gamma; above it tends positive.",
},
callWall: {
label: "Call Wall",
body: "Strike with the largest concentration of positive call-side gamma. Acts as a near-term resistance / pin candidate.",
},
putWall: {
label: "Put Wall",
body: "Strike with the largest concentration of negative put-side gamma. Acts as near-term support; a break through can accelerate moves.",
},
ivRank: {
label: "IV Rank",
body: "Where current 30-day implied vol sits in its 52-week range, 0100. High IVR favors premium-selling; low IVR favors premium-buying.",
},
ivx: {
label: "IVx (Implied Vol Index)",
body: "ORATS-style normalized 30-day implied volatility across the chain. Comparable across symbols and time.",
},
expectedMove: {
label: "Expected Move",
body: "One-sigma move implied by ATM straddle pricing for the chosen horizon. Roughly the range the option market expects 68% of the time.",
},
skew: {
label: "Skew",
body: "Difference between 25-delta put and call implied vol. Positive skew means puts trade rich to calls — typical hedging premium.",
},
zeroDte: {
label: "0DTE Net GEX",
body: "Net dealer gamma exposure isolated to today's expiration. Drives intraday pinning behavior near key strikes.",
},
};
export function MetricTooltip({
metric,
className = "h-3.5 w-3.5",
}: {
metric: keyof typeof METRIC_INFO;
className?: string;
}) {
const info = METRIC_INFO[metric];
if (!info) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`About ${info.label}`}
data-testid={`tooltip-trigger-${metric}`}
>
<Info className={className} />
</button>
</TooltipTrigger>
<TooltipContent
side="top"
className="max-w-[260px] text-xs leading-snug"
data-testid={`tooltip-content-${metric}`}
>
<div className="mb-1 font-semibold">{info.label}</div>
<p>{info.body}</p>
</TooltipContent>
</Tooltip>
);
}

View File

@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import type { SymbolInfo } from "@shared/schema";
import { useSymbol } from "@/lib/symbol-context";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function SymbolSelector() {
const { symbol, setSymbol } = useSymbol();
const { data } = useQuery<SymbolInfo[]>({ queryKey: ["/api/symbols"] });
const symbols = data ?? [];
return (
<Select value={symbol} onValueChange={setSymbol}>
<SelectTrigger
className="h-9 w-[160px] font-mono text-sm"
aria-label="Active symbol"
data-testid="select-symbol"
>
<SelectValue placeholder="Symbol" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Symbols</SelectLabel>
{symbols.map((s) => (
<SelectItem
key={s.ticker}
value={s.ticker}
data-testid={`option-symbol-${s.ticker}`}
>
<div className="flex items-baseline gap-2">
<span className="font-mono font-medium">{s.ticker}</span>
<span className="truncate text-xs text-muted-foreground">{s.name}</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
}

View File

@ -0,0 +1,67 @@
import { Moon, Sun, LogOut } from "lucide-react";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { SymbolSelector } from "@/components/symbol-selector";
import { useTheme } from "@/lib/theme-context";
import { useAuth } from "@/lib/auth-context";
import { useLocation } from "wouter";
export function TopBar({ title }: { title: string }) {
const { theme, toggle } = useTheme();
const { user, logout } = useAuth();
const [location, setLocation] = useLocation();
const isAuthPage = location === "/login";
return (
<header className="flex h-14 items-center gap-3 border-b border-border bg-background/80 px-3 backdrop-blur sm:px-5">
{!isAuthPage && <SidebarTrigger data-testid="button-sidebar-toggle" />}
{!isAuthPage && <Separator orientation="vertical" className="h-6" />}
<h1
className="truncate text-sm font-semibold tracking-tight sm:text-base"
data-testid="text-page-title"
>
{title}
</h1>
<div className="ml-auto flex items-center gap-2 sm:gap-3">
{!isAuthPage && (
<>
<span className="text-xs text-muted-foreground hidden sm:inline">{user?.name}</span>
<Button
variant="ghost"
size="sm"
className="text-xs h-8 px-2"
onClick={() => setLocation("/#/account")}
data-testid="button-account"
>
Account
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Sign out"
onClick={() => logout()}
data-testid="button-logout"
>
<LogOut className="h-4 w-4" />
</Button>
</>
)}
<SymbolSelector />
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
onClick={toggle}
data-testid="button-theme-toggle"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
</div>
</header>
);
}

View File

@ -0,0 +1,56 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,60 @@
import * as React from "react"
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View File

@ -0,0 +1,51 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(`
after:content-[''] after:block after:absolute after:inset-0 after:rounded-full after:pointer-events-none after:border after:border-black/10 dark:after:border-white/10
relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full`,
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,39 @@
import * as React from "react"
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
const badgeVariants = cva(
// Whitespace-nowrap: Badges should never wrap.
"whitespace-nowrap inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" +
" hover-elevate " ,
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow-xs",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow-xs",
outline: " border [border-color:var(--badge-outline)] shadow-xs",
},
},
defaultVariants: {
variant: "default",
},
},
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@ -0,0 +1,63 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0" +
" hover-elevate active-elevate-2",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground border border-primary-border",
destructive:
"bg-destructive text-destructive-foreground border border-destructive-border",
outline:
// Shows the background color of whatever card / sidebar / accent background it is inside of.
// Inherits the current text color.
" border [border-color:var(--button-outline)] shadow-xs active:shadow-none ",
secondary: "border bg-secondary text-secondary-foreground border border-secondary-border ",
// Add a transparent border so that when someone toggles a border on later, it doesn't shift layout/size.
ghost: "border border-transparent",
},
// Heights are set as "min" heights, because sometimes Ai will place large amount of content
// inside buttons. With a min-height they will look appropriate with small amounts of content,
// but will expand to fit large amounts of content.
size: {
default: "min-h-9 px-4 py-2",
sm: "min-h-8 rounded-md px-3 text-xs",
lg: "min-h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,68 @@
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
),
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@ -0,0 +1,85 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"shadcn-card rounded-xl border bg-card border-card-border text-card-foreground shadow-sm",
className
)}
{...props}
/>
));
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,259 @@
import * as React from "react"
import useEmblaCarousel from 'embla-carousel-react';
import type { UseEmblaCarouselType } from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@ -0,0 +1,365 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@ -0,0 +1,151 @@
import * as React from "react"
import type { DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,198 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,118 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,172 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import { Controller, FormProvider, useFormContext } from 'react-hook-form';
import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@ -0,0 +1,69 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
// h-9 to match icon buttons and default buttons.
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,25 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,256 @@
"use client"
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return <MenubarPrimitive.RadioGroup {...props} />
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]",
className
)}
{...props}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}

View File

@ -0,0 +1,128 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View File

@ -0,0 +1,117 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,42 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@ -0,0 +1,45 @@
"use client"
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,727 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-[var(--sidebar-width)] flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-[var(--sidebar-width)] p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-[var(--sidebar-width)] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+var(--spacing-4))]"
: "group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)]"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[var(--sidebar-width)] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+var(--spacing-4)+2px)]"
: "group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
// Note: Tailwind v3.4 doesn't support "in-" selectors. So the rail won't work perfectly.
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:h-4 [&>svg]:w-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:w-8! group-data-[collapsible=icon]:h-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[var(--skeleton-width)] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline outline-2 outline-transparent outline-offset-2 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,26 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,128 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -0,0 +1,33 @@
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import type { VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@ -0,0 +1,44 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3 min-w-10",
sm: "h-9 px-2.5 min-w-9",
lg: "h-11 px-5 min-w-11",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@ -0,0 +1,191 @@
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

318
client/src/index.css Normal file
View File

@ -0,0 +1,318 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* GammaDesk palette
----------------------------------------------------------
Finance dashboard aesthetic: cool slate surfaces, sharp
high-contrast text, restrained teal/blue accent. Reserved
semantic colors map to a trading floor mental model:
- positive (long gamma, call side) -> emerald
- negative (short gamma, put side) -> rose
- call wall -> cyan
- put wall -> magenta-rose
- HVL / gamma flip -> gold
- spot marker -> primary text color
Values are HSL (H S% L%) without hsl() wrapper, per the
shadcn/Tailwind v3 convention. */
/* LIGHT MODE */
:root {
--button-outline: hsl(220 14% 90% / 0.8);
--badge-outline: hsl(220 14% 90% / 0.4);
--opaque-button-border-intensity: -8;
--elevate-1: hsl(220 14% 14% / 0.04);
--elevate-2: hsl(220 14% 14% / 0.08);
--background: 220 24% 98%;
--foreground: 222 32% 12%;
--border: 220 14% 90%;
--card: 0 0% 100%;
--card-foreground: 222 32% 12%;
--card-border: 220 14% 92%;
--sidebar: 220 22% 96%;
--sidebar-foreground: 222 32% 12%;
--sidebar-border: 220 14% 90%;
--sidebar-primary: 192 80% 36%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 220 16% 92%;
--sidebar-accent-foreground: 222 32% 12%;
--sidebar-ring: 192 80% 36%;
--popover: 0 0% 100%;
--popover-foreground: 222 32% 12%;
--popover-border: 220 14% 90%;
--primary: 192 80% 36%;
--primary-foreground: 0 0% 100%;
--secondary: 220 16% 92%;
--secondary-foreground: 222 32% 12%;
--muted: 220 16% 94%;
--muted-foreground: 220 8% 42%;
--accent: 220 16% 92%;
--accent-foreground: 222 32% 12%;
--destructive: 348 75% 48%;
--destructive-foreground: 0 0% 100%;
--input: 220 14% 85%;
--ring: 192 80% 36%;
/* Chart palette tuned for gamma analytics
1 long-gamma / positive (emerald)
2 short-gamma / negative (rose)
3 call wall (cyan)
4 put wall (magenta)
5 HVL / gamma flip (amber)
*/
--chart-1: 152 65% 38%;
--chart-2: 348 75% 48%;
--chart-3: 188 76% 42%;
--chart-4: 326 70% 50%;
--chart-5: 38 92% 50%;
/* Semantic data colors (consumed via tailwind config) */
--pos: 152 65% 38%;
--neg: 348 75% 48%;
--call-wall: 188 76% 42%;
--put-wall: 326 70% 50%;
--hvl: 38 92% 50%;
--spot: 222 32% 12%;
--font-sans: 'Inter', 'Helvetica Neue', sans-serif;
--font-serif: Georgia, serif;
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
--radius: 0.5rem;
--shadow-2xs: 0 1px 0 0 hsl(220 14% 14% / 0.04);
--shadow-xs: 0 1px 2px 0 hsl(220 14% 14% / 0.05);
--shadow-sm: 0 1px 2px 0 hsl(220 14% 14% / 0.06), 0 1px 1px -1px hsl(220 14% 14% / 0.04);
--shadow: 0 2px 4px 0 hsl(220 14% 14% / 0.06), 0 1px 2px -1px hsl(220 14% 14% / 0.04);
--shadow-md: 0 4px 8px 0 hsl(220 14% 14% / 0.08), 0 2px 4px -1px hsl(220 14% 14% / 0.05);
--shadow-lg: 0 8px 24px 0 hsl(220 14% 14% / 0.12), 0 4px 8px -2px hsl(220 14% 14% / 0.06);
--shadow-xl: 0 16px 40px 0 hsl(220 14% 14% / 0.16);
--shadow-2xl: 0 24px 64px 0 hsl(220 14% 14% / 0.24);
--tracking-normal: 0em;
--spacing: 0.25rem;
--sidebar-primary-border: hsl(var(--sidebar-primary));
--sidebar-primary-border: hsl(
from hsl(var(--sidebar-primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha
);
--sidebar-accent-border: hsl(var(--sidebar-accent));
--sidebar-accent-border: hsl(
from hsl(var(--sidebar-accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha
);
--primary-border: hsl(var(--primary));
--primary-border: hsl(
from hsl(var(--primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha
);
--secondary-border: hsl(var(--secondary));
--secondary-border: hsl(
from hsl(var(--secondary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha
);
--muted-border: hsl(var(--muted));
--muted-border: hsl(
from hsl(var(--muted)) h s calc(l + var(--opaque-button-border-intensity)) / alpha
);
--accent-border: hsl(var(--accent));
--accent-border: hsl(
from hsl(var(--accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha
);
--destructive-border: hsl(var(--destructive));
--destructive-border: hsl(
from hsl(var(--destructive)) h s calc(l + var(--opaque-button-border-intensity)) / alpha
);
}
/* DARK MODE — primary surface for the dashboard */
.dark {
--button-outline: hsl(220 14% 24% / 0.8);
--badge-outline: hsl(220 14% 24% / 0.6);
--opaque-button-border-intensity: 9;
--elevate-1: hsl(0 0% 100% / 0.04);
--elevate-2: hsl(0 0% 100% / 0.08);
/* Surfaces: layered slate / near-black gradient */
--background: 222 24% 6%;
--foreground: 220 14% 96%;
--border: 220 12% 18%;
--card: 222 20% 9%;
--card-foreground: 220 14% 96%;
--card-border: 220 12% 16%;
--sidebar: 222 28% 7%;
--sidebar-foreground: 220 14% 92%;
--sidebar-border: 220 12% 14%;
--sidebar-primary: 188 72% 56%;
--sidebar-primary-foreground: 222 32% 8%;
--sidebar-accent: 220 14% 14%;
--sidebar-accent-foreground: 220 14% 96%;
--sidebar-ring: 188 72% 56%;
--popover: 222 22% 14%;
--popover-foreground: 220 14% 96%;
--popover-border: 220 12% 26%;
--primary: 188 72% 56%;
--primary-foreground: 222 32% 8%;
--secondary: 220 14% 14%;
--secondary-foreground: 220 14% 96%;
--muted: 220 14% 12%;
--muted-foreground: 220 8% 64%;
--accent: 220 14% 14%;
--accent-foreground: 220 14% 96%;
--destructive: 348 70% 56%;
--destructive-foreground: 0 0% 100%;
--input: 220 12% 22%;
--ring: 188 72% 56%;
--chart-1: 152 62% 52%;
--chart-2: 348 75% 62%;
--chart-3: 188 72% 56%;
--chart-4: 326 68% 62%;
--chart-5: 38 92% 60%;
--pos: 152 62% 52%;
--neg: 348 75% 62%;
--call-wall: 188 72% 56%;
--put-wall: 326 68% 62%;
--hvl: 38 92% 60%;
--spot: 220 14% 96%;
--shadow-2xs: 0 1px 0 0 hsl(0 0% 0% / 0.4);
--shadow-xs: 0 1px 2px 0 hsl(0 0% 0% / 0.5);
--shadow-sm: 0 1px 2px 0 hsl(0 0% 0% / 0.5), 0 1px 1px -1px hsl(0 0% 0% / 0.4);
--shadow: 0 2px 4px 0 hsl(0 0% 0% / 0.5), 0 1px 2px -1px hsl(0 0% 0% / 0.4);
--shadow-md: 0 4px 8px 0 hsl(0 0% 0% / 0.55), 0 2px 4px -1px hsl(0 0% 0% / 0.45);
--shadow-lg: 0 8px 24px 0 hsl(0 0% 0% / 0.6), 0 4px 8px -2px hsl(0 0% 0% / 0.5);
--shadow-xl: 0 16px 40px 0 hsl(0 0% 0% / 0.7);
--shadow-2xl: 0 24px 64px 0 hsl(0 0% 0% / 0.8);
--sidebar-primary-border: hsl(var(--sidebar-primary));
--sidebar-primary-border: hsl(
from hsl(var(--sidebar-primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha
);
--sidebar-accent-border: hsl(var(--sidebar-accent));
--sidebar-accent-border: hsl(
from hsl(var(--sidebar-accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha
);
--primary-border: hsl(var(--primary));
--primary-border: hsl(
from hsl(var(--primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha
);
--secondary-border: hsl(var(--secondary));
--secondary-border: hsl(
from hsl(var(--secondary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha
);
--muted-border: hsl(var(--muted));
--muted-border: hsl(
from hsl(var(--muted)) h s calc(l + var(--opaque-button-border-intensity)) / alpha
);
--accent-border: hsl(var(--accent));
--accent-border: hsl(
from hsl(var(--accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha
);
--destructive-border: hsl(var(--destructive));
--destructive-border: hsl(
from hsl(var(--destructive)) h s calc(l + var(--opaque-button-border-intensity)) / alpha
);
}
@layer base {
* {
@apply border-border;
}
html,
body,
#root {
height: 100%;
}
body {
@apply font-sans antialiased bg-background text-foreground;
font-feature-settings: 'ss01', 'cv11';
}
/* Tabular numerals for all numeric display */
.num,
td.num,
th.num {
font-variant-numeric: tabular-nums lining-nums;
font-family: var(--font-mono);
}
}
@layer utilities {
input[type='search']::-webkit-search-cancel-button {
@apply hidden;
}
[contenteditable][data-placeholder]:empty::before {
content: attr(data-placeholder);
color: hsl(var(--muted-foreground));
pointer-events: none;
}
.no-default-hover-elevate {
}
.no-default-active-elevate {
}
.toggle-elevate::before,
.toggle-elevate-2::before {
content: '';
pointer-events: none;
position: absolute;
inset: 0px;
border-radius: inherit;
z-index: -1;
}
.toggle-elevate.toggle-elevated::before {
background-color: var(--elevate-2);
}
.border.toggle-elevate::before {
inset: -1px;
}
.hover-elevate:not(.no-default-hover-elevate),
.active-elevate:not(.no-default-active-elevate),
.hover-elevate-2:not(.no-default-hover-elevate),
.active-elevate-2:not(.no-default-active-elevate) {
position: relative;
z-index: 0;
}
.hover-elevate:not(.no-default-hover-elevate)::after,
.active-elevate:not(.no-default-active-elevate)::after,
.hover-elevate-2:not(.no-default-hover-elevate)::after,
.active-elevate-2:not(.no-default-active-elevate)::after {
content: '';
pointer-events: none;
position: absolute;
inset: 0px;
border-radius: inherit;
z-index: 999;
}
.hover-elevate:hover:not(.no-default-hover-elevate)::after,
.active-elevate:active:not(.no-default-active-elevate)::after {
background-color: var(--elevate-1);
}
.hover-elevate-2:hover:not(.no-default-hover-elevate)::after,
.active-elevate-2:active:not(.no-default-active-elevate)::after {
background-color: var(--elevate-2);
}
.border.hover-elevate:not(.no-hover-interaction-elevate)::after,
.border.active-elevate:not(.no-active-interaction-elevate)::after,
.border.hover-elevate-2:not(.no-hover-interaction-elevate)::after,
.border.active-elevate-2:not(.no-active-interaction-elevate)::after {
inset: -1px;
}
/* Subtle grid backdrop for hero / nav header */
.grid-backdrop {
background-image: linear-gradient(hsl(var(--border) / 0.4) 1px, transparent 1px),
linear-gradient(90deg, hsl(var(--border) / 0.4) 1px, transparent 1px);
background-size: 32px 32px;
}
}

View File

@ -0,0 +1,135 @@
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
import { useLocation } from "wouter";
interface User {
id: number;
name: string;
email: string;
}
interface AuthContextType {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (name: string, email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
updateProfile: (data: { name?: string; email?: string }) => Promise<void>;
changePassword: (current: string, newPass: string) => Promise<void>;
requestReset: (email: string) => Promise<void>;
error: string | null;
clearError: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [, setLocation] = useLocation();
useEffect(() => {
fetch("/api/auth/me")
.then((r) => {
if (r.ok) return r.json();
throw new Error("not authenticated");
})
.then((data: User) => { setUser(data); })
.catch(() => { setUser(null); })
.finally(() => { setLoading(false); });
}, []);
const clearError = () => setError(null);
const tryAuth = async <T,>(fn: () => Promise<T>): Promise<{ ok: boolean }> => {
try {
await fn();
return { ok: true };
} catch (err: any) {
const msg = err?.message || "Something went wrong";
setError(msg);
return { ok: false };
}
};
const login = async (email: string, password: string) => {
clearError();
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) { setError(await res.text()); return; }
const data: User = await res.json();
setUser(data);
setLocation("/");
};
const register = async (name: string, email: string, password: string) => {
clearError();
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
});
if (!res.ok) { setError(await res.text()); return; }
const data: User = await res.json();
setUser(data);
setLocation("/");
};
const logout = async () => {
await fetch("/api/auth/logout", { method: "POST" });
setUser(null);
setLocation("/#/login");
};
const updateProfile = async (data: { name?: string; email?: string }) => {
clearError();
const res = await fetch("/api/auth/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) { setError(await res.text()); return false; }
const updated: User = await res.json();
setUser(updated);
return true;
};
const changePassword = async (current: string, newPass: string) => {
clearError();
const res = await fetch("/api/auth/password", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword: current, newPassword: newPass }),
});
if (!res.ok) { setError(await res.text()); return false; }
await res.json();
return true;
};
const requestReset = async (email: string) => {
clearError();
const res = await fetch("/api/auth/request-reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!res.ok) { setError(await res.text()); return false; }
await res.json();
return true;
};
return (
<AuthContext.Provider value={{ user, loading, login, register, logout, updateProfile, changePassword, requestReset, error, clearError }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be inside AuthProvider");
return ctx;
}

51
client/src/lib/format.ts Normal file
View File

@ -0,0 +1,51 @@
// Display formatting helpers — kept here so every page renders identically.
export function fmtCurrency(value: number, opts: { digits?: number } = {}): string {
const digits = opts.digits ?? 2;
return value.toLocaleString("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
export function fmtNumber(value: number, digits = 2): string {
return value.toLocaleString("en-US", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
export function fmtCompactCurrency(value: number): string {
const abs = Math.abs(value);
const sign = value < 0 ? "-" : "";
if (abs >= 1e9) return `${sign}$${(abs / 1e9).toFixed(2)}B`;
if (abs >= 1e6) return `${sign}$${(abs / 1e6).toFixed(2)}M`;
if (abs >= 1e3) return `${sign}$${(abs / 1e3).toFixed(2)}K`;
return `${sign}$${abs.toFixed(0)}`;
}
export function fmtCompact(value: number): string {
const abs = Math.abs(value);
const sign = value < 0 ? "-" : "";
if (abs >= 1e9) return `${sign}${(abs / 1e9).toFixed(2)}B`;
if (abs >= 1e6) return `${sign}${(abs / 1e6).toFixed(2)}M`;
if (abs >= 1e3) return `${sign}${(abs / 1e3).toFixed(1)}K`;
return `${sign}${abs.toFixed(0)}`;
}
export function fmtPct(value: number, digits = 2): string {
return `${value >= 0 ? "+" : ""}${value.toFixed(digits)}%`;
}
export function fmtPctPlain(value: number, digits = 2): string {
return `${value.toFixed(digits)}%`;
}
export function fmtStrike(value: number): string {
return value.toLocaleString("en-US", {
minimumFractionDigits: value % 1 === 0 ? 0 : 2,
maximumFractionDigits: 2,
});
}

View File

@ -0,0 +1,56 @@
import { QueryClient, QueryFunction } from "@tanstack/react-query";
const API_BASE = "__PORT_5000__".startsWith("__") ? "" : "__PORT_5000__";
async function throwIfResNotOk(res: Response) {
if (!res.ok) {
const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`);
}
}
export async function apiRequest(
method: string,
url: string,
data?: unknown | undefined,
): Promise<Response> {
const res = await fetch(`${API_BASE}${url}`, {
method,
headers: data ? { "Content-Type": "application/json" } : {},
body: data ? JSON.stringify(data) : undefined,
});
await throwIfResNotOk(res);
return res;
}
type UnauthorizedBehavior = "returnNull" | "throw";
export const getQueryFn: <T>(options: {
on401: UnauthorizedBehavior;
}) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => {
const res = await fetch(`${API_BASE}${queryKey.join("/")}`);
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
return null;
}
await throwIfResNotOk(res);
return await res.json();
};
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryFn: getQueryFn({ on401: "throw" }),
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
retry: false,
},
mutations: {
retry: false,
},
},
});

View File

@ -0,0 +1,24 @@
import { createContext, useContext, useState, type ReactNode } from "react";
interface SymbolContextValue {
symbol: string;
setSymbol: (s: string) => void;
}
const SymbolContext = createContext<SymbolContextValue | undefined>(undefined);
const DEFAULT_SYMBOL = "SPY";
export function SymbolProvider({ children }: { children: ReactNode }) {
// React state only — per sandbox constraints we never persist to localStorage.
const [symbol, setSymbol] = useState<string>(DEFAULT_SYMBOL);
return (
<SymbolContext.Provider value={{ symbol, setSymbol }}>{children}</SymbolContext.Provider>
);
}
export function useSymbol(): SymbolContextValue {
const ctx = useContext(SymbolContext);
if (!ctx) throw new Error("useSymbol must be used inside SymbolProvider");
return ctx;
}

View File

@ -0,0 +1,41 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
type Theme = "light" | "dark";
interface ThemeContextValue {
theme: Theme;
setTheme: (t: Theme) => void;
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function detectInitial(): Theme {
if (typeof window === "undefined") return "dark";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "dark";
// GammaDesk is dark-first; we still honor a user toggle via the header button.
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(detectInitial);
useEffect(() => {
const root = document.documentElement;
if (theme === "dark") root.classList.add("dark");
else root.classList.remove("dark");
}, [theme]);
return (
<ThemeContext.Provider
value={{ theme, setTheme, toggle: () => setTheme(theme === "dark" ? "light" : "dark") }}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
return ctx;
}

7
client/src/lib/utils.ts Normal file
View File

@ -0,0 +1,7 @@
import { clsx } from 'clsx';
import type { ClassValue } from 'clsx';
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

9
client/src/main.tsx Normal file
View File

@ -0,0 +1,9 @@
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
if (!window.location.hash) {
window.location.hash = "#/";
}
createRoot(document.getElementById("root")!).render(<App />);

View File

@ -0,0 +1,175 @@
import { useState } from "react";
import { useAuth } from "@/lib/auth-context";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import { AlertCircle, CheckCircle2, Loader2, User, Mail, Key, LogOut } from "lucide-react";
export default function AccountPage() {
const { user, logout, updateProfile, changePassword, requestReset, error, clearError } = useAuth();
const [loading, setLoading] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// Profile form
const [name, setName] = useState(user?.name || "");
const [email, setEmail] = useState(user?.email || "");
// Password form
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
// Reset request form
const [resetEmail, setResetEmail] = useState("");
const handleProfile = async (e: React.FormEvent) => {
e.preventDefault();
setLoading("profile");
clearError();
setSuccess(null);
const ok = await updateProfile({ name, email });
setLoading(null);
if (ok) setSuccess("Profile updated successfully");
};
const handlePassword = async (e: React.FormEvent) => {
e.preventDefault();
setLoading("password");
clearError();
setSuccess(null);
const ok = await changePassword(currentPassword, newPassword);
setLoading(null);
if (ok) {
setSuccess("Password changed successfully");
setCurrentPassword("");
setNewPassword("");
}
};
const handleReset = async (e: React.FormEvent) => {
e.preventDefault();
setLoading("reset");
clearError();
setSuccess(null);
const ok = await requestReset(resetEmail);
setLoading(null);
if (ok) {
setSuccess("Reset request submitted");
setResetEmail("");
}
};
const handleLogout = async () => {
await logout();
};
if (!user) return null;
return (
<div className="flex flex-col gap-5 p-4 sm:p-6 max-w-2xl mx-auto">
<div>
<h2 className="text-base font-semibold tracking-tight">Account</h2>
<p className="mt-1 text-xs text-muted-foreground">Manage your profile, password, and session.</p>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert>
<CheckCircle2 className="h-4 w-4" />
<AlertDescription>{success}</AlertDescription>
</Alert>
)}
{/* Profile */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<User className="h-4 w-4" /> Profile
</CardTitle>
<CardDescription className="text-xs">Update your name and email address</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleProfile} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="acc-name" className="text-xs">Name</Label>
<Input id="acc-name" value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="acc-email" className="text-xs">Email</Label>
<Input id="acc-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</div>
<Button type="submit" size="sm" disabled={loading === "profile"}>
{loading === "profile" && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
</Button>
</form>
</CardContent>
</Card>
{/* Change Password */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Key className="h-4 w-4" /> Change Password
</CardTitle>
<CardDescription className="text-xs">Update your password</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handlePassword} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="current-pass" className="text-xs">Current Password</Label>
<Input id="current-pass" type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-pass" className="text-xs">New Password (min 6 chars)</Label>
<Input id="new-pass" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required minLength={6} />
</div>
<Button type="submit" size="sm" disabled={loading === "password"}>
{loading === "password" && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Change Password
</Button>
</form>
</CardContent>
</Card>
{/* Request Reset (for reference) */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Mail className="h-4 w-4" /> Password Reset Request
</CardTitle>
<CardDescription className="text-xs">Request a password reset via email (coming soon)</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleReset} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="reset-email" className="text-xs">Email Address</Label>
<Input id="reset-email" type="email" value={resetEmail} onChange={(e) => setResetEmail(e.target.value)} required />
</div>
<Button type="submit" size="sm" variant="outline" disabled={loading === "reset"}>
{loading === "reset" && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Request Reset Link
</Button>
</form>
</CardContent>
</Card>
<Separator />
{/* Logout */}
<div className="flex justify-end">
<Button variant="destructive" size="sm" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" /> Sign Out
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,189 @@
import { useQuery } from "@tanstack/react-query";
import { TrendingDown, TrendingUp } from "lucide-react";
import type { GexProfile, Summary } from "@shared/schema";
import { useSymbol } from "@/lib/symbol-context";
import { fmtCompactCurrency, fmtCurrency, fmtPct, fmtPctPlain, fmtStrike } from "@/lib/format";
import { MetricCard } from "@/components/metric-card";
import { GexChart } from "@/components/gex-chart";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { LoadingBlock, LoadingCards } from "@/components/loading-block";
import { cn } from "@/lib/utils";
export default function DashboardPage() {
const { symbol } = useSymbol();
const summary = useQuery<Summary>({ queryKey: ["/api/market", symbol, "summary"] });
const gex = useQuery<GexProfile>({ queryKey: ["/api/market", symbol, "gex"] });
const s = summary.data;
const g = gex.data;
const regimePositive = s?.gammaRegime === "positive";
return (
<div className="flex flex-col gap-5 p-4 sm:p-6">
{/* Header row */}
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<div className="flex items-baseline gap-3">
<h2
className="font-mono text-lg font-semibold tracking-tight"
data-testid="text-symbol"
>
{symbol}
</h2>
{s && (
<span
className={cn(
"num text-sm font-medium",
s.spotChangePct >= 0 ? "text-pos" : "text-neg",
)}
data-testid="text-spot-change"
>
{fmtPct(s.spotChangePct)}
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">
Snapshot of dealer positioning, expected move, and key gamma levels.
</p>
</div>
{s && (
<Badge
variant="outline"
className={cn(
"gap-1.5 border-border px-2.5 py-1 text-xs font-medium uppercase tracking-wide",
regimePositive
? "bg-pos/10 text-pos"
: "bg-neg/10 text-neg",
)}
data-testid="badge-gamma-regime"
>
{regimePositive ? (
<TrendingUp className="h-3.5 w-3.5" />
) : (
<TrendingDown className="h-3.5 w-3.5" />
)}
{regimePositive ? "Positive gamma" : "Negative gamma"}
</Badge>
)}
</div>
{/* Summary cards */}
{!s ? (
<LoadingCards count={6} />
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<MetricCard
label="Spot"
metric="spot"
value={fmtCurrency(s.spot)}
hint={`${fmtCurrency(s.spotChange)} today`}
testId="card-spot"
/>
<MetricCard
label="Net GEX"
metric="netGex"
value={fmtCompactCurrency(s.netGex)}
hint="$ per 1% move"
accent={s.netGex >= 0 ? "pos" : "neg"}
testId="card-net-gex"
/>
<MetricCard
label="HVL / Flip"
metric="hvl"
value={fmtStrike(s.hvl)}
hint={`${fmtPct(((s.hvl - s.spot) / s.spot) * 100)} from spot`}
accent="hvl"
testId="card-hvl"
/>
<MetricCard
label="Call Wall"
metric="callWall"
value={fmtStrike(s.callWall)}
hint={`${fmtPct(((s.callWall - s.spot) / s.spot) * 100)} from spot`}
accent="call-wall"
testId="card-call-wall"
/>
<MetricCard
label="Put Wall"
metric="putWall"
value={fmtStrike(s.putWall)}
hint={`${fmtPct(((s.putWall - s.spot) / s.spot) * 100)} from spot`}
accent="put-wall"
testId="card-put-wall"
/>
<MetricCard
label="Expected Move"
metric="expectedMove"
value={`±${fmtCurrency(s.expectedMove)}`}
hint={`${fmtPctPlain(s.expectedMovePct)} 1σ next session`}
testId="card-expected-move"
/>
</div>
)}
{/* Secondary metrics row */}
{s && (
<div className="grid gap-3 sm:grid-cols-3">
<MetricCard
label="IV Rank"
metric="ivRank"
value={`${s.ivRank.toFixed(0)} / 100`}
hint="Percentile vs trailing 52w"
testId="card-iv-rank"
/>
<MetricCard
label="IVx"
metric="ivx"
value={fmtPctPlain(s.ivx)}
hint="30d implied vol index"
testId="card-ivx"
/>
<MetricCard
label="Skew"
metric="skew"
value={`${s.skew >= 0 ? "+" : ""}${s.skew.toFixed(2)}`}
hint="25Δ put IV minus call IV"
testId="card-skew"
/>
</div>
)}
{/* GEX profile chart */}
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3 pb-2">
<div>
<CardTitle className="text-base font-semibold">Gamma exposure by strike</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
Estimated dealer gamma contributions. Green bars show call-side positive GEX; pink bars show put-side negative GEX. Vertical guides mark HVL and dominant walls.
</p>
</div>
<div className="hidden flex-wrap justify-end gap-x-3 gap-y-1 text-[11px] md:flex">
<LegendDot color="hsl(var(--pos))" label="Calls" />
<LegendDot color="hsl(var(--neg))" label="Puts" />
<LegendDot color="hsl(var(--hvl))" label="HVL" />
<LegendDot color="hsl(var(--call-wall))" label="Call Wall" />
<LegendDot color="hsl(var(--put-wall))" label="Put Wall" />
</div>
</CardHeader>
<CardContent>
{g ? <GexChart profile={g} /> : <LoadingBlock height={360} />}
</CardContent>
</Card>
</div>
);
}
function LegendDot({ color, label }: { color: string; label: string }) {
return (
<span className="inline-flex whitespace-nowrap items-center gap-1.5 text-muted-foreground">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-sm"
style={{ background: color }}
/>
{label}
</span>
);
}

View File

@ -0,0 +1,234 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { ExpirationsResponse } from "@shared/schema";
import { useSymbol } from "@/lib/symbol-context";
import { fmtCompactCurrency, fmtCurrency, fmtPctPlain, fmtStrike, fmtPct } from "@/lib/format";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { IvSkewChart } from "@/components/iv-skew-chart";
import { MetricTooltip } from "@/components/metric-tooltip";
import { LoadingBlock } from "@/components/loading-block";
import { cn } from "@/lib/utils";
type ViewMode = "standalone" | "cumulative";
export default function ExpiryMatrixPage() {
const { symbol } = useSymbol();
const [mode, setMode] = useState<ViewMode>("standalone");
const exp = useQuery<ExpirationsResponse>({
queryKey: ["/api/market", symbol, "expirations"],
});
const rows = exp.data?.rows ?? [];
const spot = exp.data?.spot ?? 0;
const displayRows = useMemo(() => {
if (mode === "standalone") return rows;
// Cumulative: each row aggregates all rows with dte <= this row's dte.
let cumGex = 0;
return rows.map((r) => {
cumGex += r.netGex;
return { ...r, netGex: cumGex };
});
}, [rows, mode]);
return (
<div className="flex flex-col gap-5 p-4 sm:p-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-base font-semibold tracking-tight">Expiry matrix</h2>
<p className="mt-1 text-xs text-muted-foreground">
Net gamma, IV, and expected move per expiration. Use cumulative view to see how gamma stacks across the term structure.
</p>
</div>
<Tabs value={mode} onValueChange={(v) => setMode(v as ViewMode)}>
<TabsList data-testid="tabs-view-mode">
<TabsTrigger value="standalone" data-testid="tab-standalone">
Standalone
</TabsTrigger>
<TabsTrigger value="cumulative" data-testid="tab-cumulative">
Cumulative
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Expirations</CardTitle>
</CardHeader>
<CardContent className="px-0 sm:px-2">
{exp.isLoading || !exp.data ? (
<LoadingBlock height={280} />
) : (
<div className="overflow-x-auto">
<Table data-testid="table-expirations">
<TableHeader className="sticky top-0 bg-card">
<TableRow>
<TableHead>Expiry</TableHead>
<TableHead className="text-right">DTE</TableHead>
<TableHead className="text-right">
<span className="inline-flex items-center gap-1">
Net GEX
<MetricTooltip metric="netGex" />
</span>
</TableHead>
<TableHead className="text-right">
<span className="inline-flex items-center gap-1">
IVx
<MetricTooltip metric="ivx" />
</span>
</TableHead>
<TableHead className="text-right">
<span className="inline-flex items-center gap-1">
Skew
<MetricTooltip metric="skew" />
</span>
</TableHead>
<TableHead className="text-right">
<span className="inline-flex items-center gap-1">
Exp. Move
<MetricTooltip metric="expectedMove" />
</span>
</TableHead>
<TableHead className="text-right">
<span className="inline-flex items-center gap-1">
Call Wall
<MetricTooltip metric="callWall" />
</span>
</TableHead>
<TableHead className="text-right">
<span className="inline-flex items-center gap-1">
Put Wall
<MetricTooltip metric="putWall" />
</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{displayRows.map((r) => (
<TableRow
key={r.expiry}
data-testid={`row-exp-${r.expiry}`}
>
<TableCell className="num font-medium">{r.expiry}</TableCell>
<TableCell className="num text-right text-muted-foreground">
{r.dte === 0 ? (
<span className="rounded-sm bg-hvl/15 px-1.5 py-0.5 text-hvl">
0DTE
</span>
) : (
r.dte
)}
</TableCell>
<TableCell
className={cn(
"num text-right font-medium",
r.netGex >= 0 ? "text-pos" : "text-neg",
)}
>
{fmtCompactCurrency(r.netGex)}
</TableCell>
<TableCell className="num text-right">{fmtPctPlain(r.ivx)}</TableCell>
<TableCell className="num text-right">
{r.skew >= 0 ? "+" : ""}
{r.skew.toFixed(2)}
</TableCell>
<TableCell className="num text-right">
±{fmtCurrency(r.expectedMove)}
<span className="ml-1 text-xs text-muted-foreground">
({fmtPctPlain(r.expectedMovePct)})
</span>
</TableCell>
<TableCell className="num text-right text-call-wall">
{fmtStrike(r.callWall)}
<span className="ml-1 text-xs text-muted-foreground">
{fmtPct(((r.callWall - spot) / spot) * 100)}
</span>
</TableCell>
<TableCell className="num text-right text-put-wall">
{fmtStrike(r.putWall)}
<span className="ml-1 text-xs text-muted-foreground">
{fmtPct(((r.putWall - spot) / spot) * 100)}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-[1.4fr_1fr]">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">IV term structure &amp; skew</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
IVx (solid) versus call and put 25-delta implied vol across expirations.
</p>
</CardHeader>
<CardContent>
{rows.length ? (
<IvSkewChart rows={rows} height={280} />
) : (
<LoadingBlock height={280} />
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Skew table</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
Vol differential per tenor. Larger positive values mean richer put pricing.
</p>
</CardHeader>
<CardContent className="px-0 sm:px-2">
<div className="overflow-x-auto">
<Table data-testid="table-skew">
<TableHeader>
<TableRow>
<TableHead>Tenor</TableHead>
<TableHead className="text-right">Call IV</TableHead>
<TableHead className="text-right">Put IV</TableHead>
<TableHead className="text-right">Skew</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.expiry}>
<TableCell className="num">{r.dte === 0 ? "0DTE" : `${r.dte}d`}</TableCell>
<TableCell className="num text-right text-pos">
{fmtPctPlain(r.callSkew)}
</TableCell>
<TableCell className="num text-right text-put-wall">
{fmtPctPlain(r.putSkew)}
</TableCell>
<TableCell className="num text-right font-medium">
{(r.putSkew - r.callSkew).toFixed(2)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,174 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { GexProfile, Summary } from "@shared/schema";
import { useSymbol } from "@/lib/symbol-context";
import { fmtCompactCurrency, fmtStrike, fmtPct } from "@/lib/format";
import { GexChart } from "@/components/gex-chart";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { MetricTooltip } from "@/components/metric-tooltip";
import { LoadingBlock } from "@/components/loading-block";
import { cn } from "@/lib/utils";
export default function GammaLevelsPage() {
const { symbol } = useSymbol();
const summary = useQuery<Summary>({ queryKey: ["/api/market", symbol, "summary"] });
const gex = useQuery<GexProfile>({ queryKey: ["/api/market", symbol, "gex"] });
const s = summary.data;
const g = gex.data;
// Sort bars by absolute net gex magnitude — the top 10 are the meaningful walls.
const ranked = useMemo(() => {
if (!g) return [];
return [...g.bars]
.map((b) => ({
...b,
magnitude: Math.abs(b.netGex),
absCall: Math.abs(b.callGex),
absPut: Math.abs(b.putGex),
}))
.sort((a, b) => b.magnitude - a.magnitude)
.slice(0, 12);
}, [g]);
return (
<div className="flex flex-col gap-5 p-4 sm:p-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-base font-semibold tracking-tight">Gamma levels</h2>
<p className="mt-1 text-xs text-muted-foreground">
Dealer gamma by strike with the top concentration zones. Use this view to identify pin candidates and break levels.
</p>
</div>
{s && (
<div className="flex flex-wrap gap-2 text-xs">
<KeyLevel
color="hvl"
label="HVL"
value={fmtStrike(s.hvl)}
from={s.spot}
tooltipMetric="hvl"
/>
<KeyLevel
color="call-wall"
label="Call Wall"
value={fmtStrike(s.callWall)}
from={s.spot}
tooltipMetric="callWall"
/>
<KeyLevel
color="put-wall"
label="Put Wall"
value={fmtStrike(s.putWall)}
from={s.spot}
tooltipMetric="putWall"
/>
</div>
)}
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Gamma exposure profile</CardTitle>
</CardHeader>
<CardContent>
{g ? <GexChart profile={g} height={420} /> : <LoadingBlock height={420} />}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Top strike concentrations</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
Strikes ranked by absolute net gamma. Positive net values indicate dealer-long gamma; negative values indicate dealer-short gamma.
</p>
</CardHeader>
<CardContent className="px-0 sm:px-2">
<div className="overflow-x-auto">
<Table data-testid="table-top-strikes">
<TableHeader className="sticky top-0 bg-card">
<TableRow>
<TableHead className="w-[110px]">Strike</TableHead>
<TableHead className="text-right">Call GEX</TableHead>
<TableHead className="text-right">Put GEX</TableHead>
<TableHead className="text-right">Net GEX</TableHead>
<TableHead className="text-right">Distance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ranked.map((row) => {
const dist = s ? ((row.strike - s.spot) / s.spot) * 100 : 0;
return (
<TableRow key={row.strike} data-testid={`row-strike-${row.strike}`}>
<TableCell className="num font-medium">{fmtStrike(row.strike)}</TableCell>
<TableCell className="num text-right text-pos">
{fmtCompactCurrency(row.callGex)}
</TableCell>
<TableCell className="num text-right text-neg">
{fmtCompactCurrency(row.putGex)}
</TableCell>
<TableCell
className={cn(
"num text-right font-medium",
row.netGex >= 0 ? "text-pos" : "text-neg",
)}
>
{fmtCompactCurrency(row.netGex)}
</TableCell>
<TableCell className="num text-right text-muted-foreground">
{fmtPct(dist)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}
function KeyLevel({
color,
label,
value,
from,
tooltipMetric,
}: {
color: "hvl" | "call-wall" | "put-wall";
label: string;
value: string;
from: number;
tooltipMetric: "hvl" | "callWall" | "putWall";
}) {
const numericValue = Number(value.replace(/,/g, ""));
const distPct = ((numericValue - from) / from) * 100;
return (
<Badge
variant="outline"
className={cn(
"gap-2 border-border px-2.5 py-1 font-mono text-xs",
color === "hvl" && "bg-hvl/10 text-hvl",
color === "call-wall" && "bg-call-wall/10 text-call-wall",
color === "put-wall" && "bg-put-wall/10 text-put-wall",
)}
data-testid={`badge-${tooltipMetric}`}
>
<span className="font-sans font-medium uppercase tracking-wide opacity-80">{label}</span>
<span>{value}</span>
<span className="text-foreground/70">{fmtPct(distPct)}</span>
<MetricTooltip metric={tooltipMetric} className="h-3 w-3" />
</Badge>
);
}

170
client/src/pages/login.tsx Normal file
View File

@ -0,0 +1,170 @@
import { useState, useCallback } from "react";
import { useAuth } from "@/lib/auth-context";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
import { AlertCircle, Eye, EyeOff, Loader2 } from "lucide-react";
export default function LoginPage() {
const { login, register, error, clearError } = useAuth();
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// Login form
const [loginEmail, setLoginEmail] = useState("");
const [loginPassword, setLoginPassword] = useState("");
// Register form
const [regName, setRegName] = useState("");
const [regEmail, setRegEmail] = useState("");
const [regPassword, setRegPassword] = useState("");
const handleLogin = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
clearError();
await login(loginEmail, loginPassword);
setLoading(false);
}, [loginEmail, loginPassword, login, clearError]);
const handleRegister = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
clearError();
await register(regName, regEmail, regPassword);
setLoading(false);
}, [regName, regEmail, regPassword, register, clearError]);
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4">
<div className="w-full max-w-md">
<div className="mb-6 text-center">
<h1 className="text-2xl font-bold tracking-tight">GammaDesk</h1>
<p className="text-sm text-muted-foreground">Options analytics dashboard</p>
</div>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Tabs defaultValue="login" className="w-full" onValueChange={() => clearError()}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="login">Sign In</TabsTrigger>
<TabsTrigger value="register">Create Account</TabsTrigger>
</TabsList>
<TabsContent value="login">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Welcome back</CardTitle>
<CardDescription className="text-xs">Enter your email and password to continue</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="login-email" className="text-xs">Email</Label>
<Input
id="login-email"
type="email"
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="login-password" className="text-xs">Password</Label>
<div className="relative">
<Input
id="login-password"
type={showPassword ? "text" : "password"}
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="current-password"
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign In
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="register">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Create your account</CardTitle>
<CardDescription className="text-xs">Sign up to access the dashboard</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleRegister} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="reg-name" className="text-xs">Name</Label>
<Input
id="reg-name"
type="text"
value={regName}
onChange={(e) => setRegName(e.target.value)}
placeholder="John Doe"
required
autoComplete="name"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="reg-email" className="text-xs">Email</Label>
<Input
id="reg-email"
type="email"
value={regEmail}
onChange={(e) => setRegEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="reg-password" className="text-xs">Password</Label>
<Input
id="reg-password"
type="password"
value={regPassword}
onChange={(e) => setRegPassword(e.target.value)}
placeholder="Min 6 characters"
required
minLength={6}
autoComplete="new-password"
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Account
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@ -0,0 +1,29 @@
import { Card, CardContent } from "@/components/ui/card";
import { AlertCircle } from "lucide-react";
import { Link } from "wouter";
export default function NotFound() {
return (
<div className="flex h-full items-center justify-center p-6">
<Card className="w-full max-w-md">
<CardContent className="pt-6">
<div className="mb-3 flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-neg" />
<h1 className="text-base font-semibold">Page not found</h1>
</div>
<p className="text-sm text-muted-foreground">
That route isn't registered. Head back to the{" "}
<Link
href="/"
className="text-primary underline-offset-2 hover:underline"
data-testid="link-back-home"
>
dashboard
</Link>
.
</p>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,274 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "wouter";
import type { ScreenerRow } from "@shared/schema";
import { useSymbol } from "@/lib/symbol-context";
import { fmtCompactCurrency, fmtCurrency, fmtPctPlain, fmtStrike, fmtPct } from "@/lib/format";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LoadingBlock } from "@/components/loading-block";
import { MetricTooltip } from "@/components/metric-tooltip";
import { cn } from "@/lib/utils";
type Preset =
| "all"
| "negative-gamma"
| "high-iv-rank"
| "near-call-wall"
| "near-put-wall"
| "zero-dte";
const PRESETS: { id: Preset; label: string; description: string }[] = [
{ id: "all", label: "All symbols", description: "No filter applied." },
{
id: "negative-gamma",
label: "Negative gamma",
description: "Dealers short gamma — expect amplified moves.",
},
{
id: "high-iv-rank",
label: "High IV rank",
description: "IVR ≥ 50 — favors premium-sellers.",
},
{
id: "near-call-wall",
label: "Near call wall",
description: "Spot within 1.5% below the dominant call wall.",
},
{
id: "near-put-wall",
label: "Near put wall",
description: "Spot within 1.5% above the dominant put wall.",
},
{
id: "zero-dte",
label: "0DTE focus",
description: "Strong 0DTE gamma in either direction.",
},
];
function applyPreset(rows: ScreenerRow[], preset: Preset): ScreenerRow[] {
switch (preset) {
case "negative-gamma":
return rows.filter((r) => r.gammaRegime === "negative");
case "high-iv-rank":
return rows.filter((r) => r.ivRank >= 50);
case "near-call-wall":
return rows.filter((r) => r.distanceToCallWall > 0 && r.distanceToCallWall <= 1.5);
case "near-put-wall":
return rows.filter((r) => r.distanceToPutWall < 0 && r.distanceToPutWall >= -1.5);
case "zero-dte":
return rows.filter((r) => Math.abs(r.zeroDteNetGex) >= 5e8);
case "all":
default:
return rows;
}
}
export default function ScreenerPage() {
const { setSymbol } = useSymbol();
const [preset, setPreset] = useState<Preset>("all");
const [search, setSearch] = useState("");
const { data, isLoading } = useQuery<ScreenerRow[]>({ queryKey: ["/api/screener"] });
const rows = useMemo(() => {
const base = applyPreset(data ?? [], preset);
if (!search.trim()) return base;
const q = search.trim().toLowerCase();
return base.filter(
(r) => r.ticker.toLowerCase().includes(q) || r.name.toLowerCase().includes(q),
);
}, [data, preset, search]);
return (
<div className="flex flex-col gap-5 p-4 sm:p-6">
<div>
<h2 className="text-base font-semibold tracking-tight">Screener</h2>
<p className="mt-1 text-xs text-muted-foreground">
Cross-symbol view of gamma regime, IV, and key levels. Click a row to load that ticker in the dashboard.
</p>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Filters</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2">
{PRESETS.map((p) => (
<Button
key={p.id}
size="sm"
variant={preset === p.id ? "default" : "outline"}
onClick={() => setPreset(p.id)}
data-testid={`button-preset-${p.id}`}
title={p.description}
>
{p.label}
</Button>
))}
<div className="ml-auto w-full sm:w-[220px]">
<Input
type="search"
placeholder="Search ticker"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9"
data-testid="input-search-ticker"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">
{rows.length} {rows.length === 1 ? "symbol" : "symbols"}
</CardTitle>
</CardHeader>
<CardContent className="px-0 sm:px-2">
{isLoading || !data ? (
<LoadingBlock height={300} />
) : (
<div className="overflow-x-auto">
<Table data-testid="table-screener">
<TableHeader className="sticky top-0 bg-card">
<TableRow>
<TableHead className="w-[100px]">Symbol</TableHead>
<TableHead className="text-right">Spot</TableHead>
<TableHead className="text-right">Δ</TableHead>
<TableHead className="text-right">
<span className="inline-flex items-center gap-1">
Net GEX
<MetricTooltip metric="netGex" />
</span>
</TableHead>
<TableHead className="text-right">Regime</TableHead>
<TableHead className="text-right">
<span className="inline-flex items-center gap-1">
IVR
<MetricTooltip metric="ivRank" />
</span>
</TableHead>
<TableHead className="text-right">
<span className="inline-flex items-center gap-1">
IVx
<MetricTooltip metric="ivx" />
</span>
</TableHead>
<TableHead className="text-right">Exp. Move</TableHead>
<TableHead className="text-right">Call Wall</TableHead>
<TableHead className="text-right">Put Wall</TableHead>
<TableHead className="text-right">
<span className="inline-flex items-center gap-1">
0DTE GEX
<MetricTooltip metric="zeroDte" />
</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow
key={r.ticker}
className="cursor-pointer hover-elevate"
onClick={() => setSymbol(r.ticker)}
data-testid={`row-screener-${r.ticker}`}
>
<TableCell className="font-mono font-semibold">
<Link
href="/"
onClick={(e) => {
e.stopPropagation();
setSymbol(r.ticker);
}}
data-testid={`link-screener-${r.ticker}`}
>
{r.ticker}
</Link>
<div className="text-[11px] font-normal text-muted-foreground">
{r.name}
</div>
</TableCell>
<TableCell className="num text-right">{fmtCurrency(r.spot)}</TableCell>
<TableCell
className={cn(
"num text-right",
r.spotChangePct >= 0 ? "text-pos" : "text-neg",
)}
>
{fmtPct(r.spotChangePct)}
</TableCell>
<TableCell
className={cn(
"num text-right font-medium",
r.netGex >= 0 ? "text-pos" : "text-neg",
)}
>
{fmtCompactCurrency(r.netGex)}
</TableCell>
<TableCell className="text-right">
<Badge
variant="outline"
className={cn(
"border-border text-[10px] font-medium uppercase tracking-wide",
r.gammaRegime === "positive"
? "bg-pos/10 text-pos"
: "bg-neg/10 text-neg",
)}
>
{r.gammaRegime === "positive" ? "Long gamma" : "Short gamma"}
</Badge>
</TableCell>
<TableCell className="num text-right">{r.ivRank.toFixed(0)}</TableCell>
<TableCell className="num text-right">{fmtPctPlain(r.ivx)}</TableCell>
<TableCell className="num text-right">
{fmtPctPlain(r.expectedMovePct)}
</TableCell>
<TableCell className="num text-right">
<span className="text-call-wall">{fmtStrike(r.callWall)}</span>
<span className="ml-1 text-[11px] text-muted-foreground">
{fmtPct(r.distanceToCallWall)}
</span>
</TableCell>
<TableCell className="num text-right">
<span className="text-put-wall">{fmtStrike(r.putWall)}</span>
<span className="ml-1 text-[11px] text-muted-foreground">
{fmtPct(r.distanceToPutWall)}
</span>
</TableCell>
<TableCell
className={cn(
"num text-right",
r.zeroDteNetGex >= 0 ? "text-pos" : "text-neg",
)}
>
{fmtCompactCurrency(r.zeroDteNetGex)}
</TableCell>
</TableRow>
))}
{rows.length === 0 && (
<TableRow>
<TableCell colSpan={11} className="py-8 text-center text-muted-foreground">
No symbols match this filter.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,165 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, AlertTriangle, ExternalLink } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import { METRIC_INFO } from "@/components/metric-tooltip";
interface OratsStatus {
configured: boolean;
baseUrl: string;
}
export default function SettingsPage() {
// Form state is React-only — values never touch localStorage/sessionStorage/cookies.
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("https://api.orats.io/datav2");
const { data: status } = useQuery<OratsStatus>({ queryKey: ["/api/orats/status"] });
return (
<div className="flex flex-col gap-5 p-4 sm:p-6">
<div>
<h2 className="text-base font-semibold tracking-tight">Settings &amp; API</h2>
<p className="mt-1 text-xs text-muted-foreground">
Wire up ORATS for live data, or keep using the deterministic mock dataset for development.
</p>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">ORATS connection</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{status && (
<Alert
variant={status.configured ? "default" : "destructive"}
data-testid={status.configured ? "alert-orats-live" : "alert-orats-mock"}
>
{status.configured ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<AlertTriangle className="h-4 w-4" />
)}
<AlertTitle>
{status.configured
? "Live ORATS credentials detected"
: "Running on deterministic mock data"}
</AlertTitle>
<AlertDescription className="text-xs">
{status.configured
? "Endpoints will attempt live ORATS calls. Verify network egress and rate limits."
: "Set the ORATS_API_KEY environment variable on the server to switch from mock data to live ORATS. The form below is for session-only inspection — values are never persisted to disk or browser storage."}
</AlertDescription>
</Alert>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="orats-base-url" className="text-xs font-medium">
Base URL
</Label>
<Input
id="orats-base-url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.orats.io/datav2"
data-testid="input-orats-base-url"
/>
<p className="text-[11px] text-muted-foreground">
The MVP calls /strikes, /summaries, /cores, and /monies under this root.
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="orats-api-key" className="text-xs font-medium">
API key (session only)
</Label>
<Input
id="orats-api-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk_orats_…"
data-testid="input-orats-api-key"
autoComplete="off"
/>
<p className="text-[11px] text-muted-foreground">
Kept in React state only. To use in production, set <code className="font-mono">ORATS_API_KEY</code> on the server environment.
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setApiKey("");
setBaseUrl("https://api.orats.io/datav2");
}}
data-testid="button-clear-credentials"
>
Clear form
</Button>
<Button
variant="ghost"
size="sm"
asChild
data-testid="button-orats-docs"
>
<a href="https://docs.orats.io/" target="_blank" rel="noopener noreferrer">
ORATS docs <ExternalLink className="ml-1 h-3.5 w-3.5" />
</a>
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Glossary</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
Reference definitions for every metric surfaced in the dashboard.
</p>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
{Object.entries(METRIC_INFO).map(([key, info]) => (
<div
key={key}
className="rounded-md border border-border bg-muted/30 p-3"
data-testid={`glossary-${key}`}
>
<div className="text-xs font-semibold">{info.label}</div>
<p className="mt-1 text-xs leading-snug text-muted-foreground">{info.body}</p>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Disclaimer</CardTitle>
</CardHeader>
<CardContent>
<p
className="text-xs leading-relaxed text-muted-foreground"
data-testid="text-disclaimer"
>
GammaDesk is provided for analytics and educational purposes only. Nothing in this
interface including gamma exposure estimates, expected moves, and IV statistics
constitutes financial, investment, tax, or trading advice. Options trading involves
significant risk and is not suitable for every investor. All values shown without
live ORATS credentials are simulated and intended for development use.
</p>
<Separator className="my-4" />
<p className="text-[11px] text-muted-foreground">
© GammaDesk clean-room implementation. Not affiliated with ORATS or any
third-party data vendor.
</p>
</CardContent>
</Card>
</div>
);
}

20
components.json Normal file
View File

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "client/src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

BIN
data.db Normal file

Binary file not shown.

55
docs/architecture.md Normal file
View File

@ -0,0 +1,55 @@
# GammaDesk Architecture
## Product layers
GammaDesk has four main layers:
1. **Data ingestion**: ORATS option-chain, volatility, summary, and IV-rank data.
2. **Gamma engine**: Per-strike GEX, net GEX, walls, HVL/gamma flip, expiration aggregation, and screener metrics.
3. **API layer**: Stable JSON routes used by the React dashboard, NinjaTrader 8 indicator, and future charting adapters.
4. **User interfaces**: Web dashboard now, NinjaTrader indicator now, optional TradingView-style overlay later.
## Frontend flow
The React app uses TanStack Query to fetch from the Express routes:
- `Dashboard` pulls summary and GEX profile data.
- `Gamma Levels` focuses on strike-level GEX levels.
- `Expiry Matrix` shows selected-alone and cumulative expiration views.
- `Screener` ranks symbols by gamma regime, IV rank, and distance to walls.
- `Settings` explains ORATS configuration and keeps any typed key in React state only.
Hash routing is used through Wouter so the app works correctly after deployment.
## Backend flow
The backend currently serves deterministic mock data:
```text
routes.ts -> marketData.ts -> shared schema-shaped JSON
```
The future live path is:
```text
routes.ts -> oratsClient.ts -> ORATS API -> normalizer -> gamma engine -> shared schema-shaped JSON
```
The frontend should not need to change when ORATS is connected.
## Data contracts
Keep these contracts stable:
- `MarketSummary`
- `GexProfile`
- `ExpirationRow`
- `ScreenerRow`
- `SymbolMeta`
If ORATS adds fields, add them as optional fields rather than breaking the existing route shape.
## Chart integrations
NinjaTrader 8 and future adapters should consume GammaDesks own API instead of calling ORATS directly. This keeps API keys private, centralizes caching, and lets the server enforce the chosen GEX sign convention.

71
docs/implementation.md Normal file
View File

@ -0,0 +1,71 @@
# GammaDesk Implementation Notes
## Editing guide
Start here for common changes:
| Task | File |
|---|---|
| Add a dashboard metric | `client/src/pages/dashboard.tsx` |
| Change the GEX chart | `client/src/components/gex-chart.tsx` |
| Change mock symbols or calculations | `server/marketData.ts` |
| Add an API route | `server/routes.ts` |
| Wire ORATS endpoints | `server/oratsClient.ts` |
| Change shared response shapes | `shared/schema.ts` |
| Change theme colors | `client/src/index.css` |
| Add app navigation | `client/src/components/app-sidebar.tsx` and `client/src/App.tsx` |
## Build order for future work
1. Update shared types in `shared/schema.ts`.
2. Update backend route or calculation logic.
3. Update frontend query and UI.
4. Run `npm run check`.
5. Run `npm run build`.
6. Test key screens: Dashboard, Gamma Levels, Expiry Matrix, Screener, Settings.
## ORATS wiring checklist
- Add HTTP helpers to `server/oratsClient.ts`.
- Inject `ORATS_API_KEY` as a `token` query parameter server-side.
- Support `ORATS_MODE`: delayed, live, intraday.
- Implement typed methods:
- `getTickers`
- `getExpirations`
- `getStrikes`
- `getStrikesByExpiry`
- `getMoniesImplied`
- `getSummaries`
- `getIvRank`
- Add normalizers from ORATS fields to GammaDesk models.
- Add short-lived cache per symbol and endpoint.
- Log missing fields without breaking the whole dashboard.
- Preserve mock fallback for local development.
## Calculation roadmap
Current MVP:
- Per-strike call and put GEX
- Net GEX
- Call wall
- Put wall
- HVL approximation by cumulative net-GEX crossing
- Expiration standalone/cumulative views
Next accuracy upgrade:
- Recalculate total gamma across a hypothetical spot grid.
- Find true zero-gamma crossing from the spot grid.
- Add transition zones around HVL.
- Add C1/P1 through C6/P6 ranked wall levels.
- Add absolute GEX levels Ab1 through Ab10.
## Product roadmap
1. Real ORATS connector.
2. Dedicated `/api/nt8/levels/:symbol` endpoint.
3. Watchlists and saved symbol groups.
4. Alerts when price nears HVL, call wall, or put wall.
5. Multi-user accounts and billing only after data licensing is clarified.

72
docs/orats-field-map.md Normal file
View File

@ -0,0 +1,72 @@
# ORATS Field Map
This is the short in-repo version of the full ORATS mapping spec. The goal is to wire ORATS rows into the existing GammaDesk response models without changing the frontend.
## Main endpoints
| GammaDesk need | ORATS endpoint family | Key fields |
|---|---|---|
| Symbol list | `/tickers` | `ticker`, `min`, `max` |
| Expiration list | `/live/expirations` | `expiration`, `strikes` |
| Option chain and Greeks | `/live/strikes`, `/live/strikes/monthly` | `ticker`, `tradeDate`, `expirDate`, `dte`, `strike`, `stockPrice`, `spotPrice`, `gamma`, `callOpenInterest`, `putOpenInterest`, `callVolume`, `putVolume`, `smvVol`, `updatedAt` |
| Volatility surface | `/live/monies/implied` | `ticker`, `tradeDate`, `expirDate`, `stockPrice`, `vol25`, `vol50`, `vol75`, `atmiv`, `slope`, `deriv`, `calVol`, `unadjVol`, `earnEffect`, `updatedAt` |
| Summary IV and term structure | `/live/summaries` | `ticker`, `tradeDate`, `stockPrice`, `iv10d`, `iv20d`, `iv30d`, `iv60d`, `iv90d`, `iv6m`, `iv1y`, `impliedMove`, `skewing`, `rSlp30`, `rDrv30`, `contango`, `confidence`, `updatedAt` |
| IV rank | IV Rank endpoint or historical-derived fallback | `ticker`, `tradeDate`, `iv`, `ivRank1m`, `ivPct1m`, `ivRank1y`, `ivPct1y`, `updatedAt` |
## Field mapping
### `MarketSummary`
| GammaDesk field | Preferred ORATS source |
|---|---|
| `ticker` | `ticker` |
| `spot` | `spotPrice`, fallback `stockPrice` |
| `netGex` | derived from strike rows |
| `gammaRegime` | derived from `netGex` and HVL relation |
| `hvl` | derived zero-gamma / cumulative net-GEX crossing |
| `callWall` | strike with largest positive call-side GEX |
| `putWall` | strike with largest absolute negative put-side GEX |
| `ivRank` | `ivRank1y` |
| `ivx` | `iv30d`, fallback `iv`, fallback nearest `atmiv` |
| `expectedMove` | `impliedMove`, fallback `spot * atmiv * sqrt(dte / 365)` |
| `skew` | `skewing`, fallback `vol25 - vol75` |
| `asOf` | latest `updatedAt` or `quoteDate` |
### `GexProfile`
| GammaDesk field | Preferred ORATS source |
|---|---|
| `bars[].strike` | `strike` |
| `bars[].callGex` | `gamma`, `callOpenInterest`, normalized spot |
| `bars[].putGex` | `gamma`, `putOpenInterest`, normalized spot |
| `bars[].netGex` | `callGex + putGex` |
| `hvl` | derived from grouped net GEX |
| `callWall` | max grouped call GEX |
| `putWall` | min grouped put GEX |
### `ExpirationRow`
| GammaDesk field | Preferred ORATS source |
|---|---|
| `expirDate` | `expirDate` |
| `dte` | `dte` |
| `netGex` | sum by expiration |
| `callWall` | max call GEX by expiration |
| `putWall` | min put GEX by expiration |
| `ivx` | `atmiv`, fallback `vol50` |
| `skew` | `vol25 - vol75` |
| `expectedMove` | `stockPrice * atmiv * sqrt(max(dte, 1) / 365)` |
## Default clean-room GEX convention
```ts
const contractMultiplier = 100;
const moveScalar = 0.01;
const callGex = +gamma * callOpenInterest * contractMultiplier * spot ** 2 * moveScalar;
const putGex = -gamma * putOpenInterest * contractMultiplier * spot ** 2 * moveScalar;
const netGex = callGex + putGex;
```
Keep this sign convention configurable internally.

10
drizzle.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./migrations",
schema: "./shared/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: "./data.db",
},
});

9273
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

110
package.json Normal file
View File

@ -0,0 +1,110 @@
{
"name": "rest-express",
"version": "1.0.0",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "NODE_ENV=development tsx server/index.ts",
"build": "tsx script/build.ts",
"start": "NODE_ENV=production node dist/index.cjs",
"check": "tsc",
"db:push": "drizzle-kit push"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@jridgewell/trace-mapping": "^0.3.25",
"@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.7",
"@radix-ui/react-aspect-ratio": "^1.1.3",
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-checkbox": "^1.1.5",
"@radix-ui/react-collapsible": "^1.1.4",
"@radix-ui/react-context-menu": "^2.2.7",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-hover-card": "^1.1.7",
"@radix-ui/react-label": "^2.1.3",
"@radix-ui/react-menubar": "^1.1.7",
"@radix-ui/react-navigation-menu": "^1.2.6",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-progress": "^1.1.3",
"@radix-ui/react-radio-group": "^1.2.4",
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slider": "^1.2.4",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.1.4",
"@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-toast": "^1.2.7",
"@radix-ui/react-toggle": "^1.1.3",
"@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@supabase/supabase-js": "^2.49.4",
"@tanstack/react-query": "^5.60.5",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
"drizzle-zod": "^0.7.0",
"embla-carousel-react": "^8.6.0",
"express": "^5.0.1",
"express-session": "^1.18.1",
"framer-motion": "^11.13.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.453.0",
"memorystore": "^1.6.7",
"next-themes": "^0.4.6",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.55.0",
"react-icons": "^5.4.0",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.2.5",
"vaul": "^1.1.2",
"wouter": "^3.3.5",
"ws": "^8.18.0",
"zod": "^3.24.2",
"zod-validation-error": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.18",
"@types/better-sqlite3": "^7.6.12",
"@types/express": "^5.0.0",
"@types/express-session": "^1.18.0",
"@types/node": "20.19.27",
"@types/passport": "^1.0.16",
"@types/passport-local": "^1.0.38",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@types/ws": "^8.5.13",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.31.8",
"esbuild": "^0.25.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.17",
"tsx": "^4.20.5",
"typescript": "5.6.3",
"vite": "^7.3.0"
},
"optionalDependencies": {
"bufferutil": "^4.0.8"
},
"overrides": {
"drizzle-kit": {
"@esbuild-kit/esm-loader": "npm:tsx@^4.20.4"
}
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

65
script/build.ts Normal file
View File

@ -0,0 +1,65 @@
import { build as esbuild } from "esbuild";
import { build as viteBuild } from "vite";
import { rm, readFile } from "node:fs/promises";
// server deps to bundle to reduce openat(2) syscalls
// which helps cold start times
const allowlist = [
"@google/generative-ai",
"axios",
"cors",
"date-fns",
"drizzle-orm",
"drizzle-zod",
"express",
"express-rate-limit",
"express-session",
"jsonwebtoken",
"memorystore",
"multer",
"nanoid",
"nodemailer",
"openai",
"passport",
"passport-local",
"stripe",
"uuid",
"ws",
"xlsx",
"zod",
"zod-validation-error",
];
async function buildAll() {
await rm("dist", { recursive: true, force: true });
console.log("building client...");
await viteBuild();
console.log("building server...");
const pkg = JSON.parse(await readFile("package.json", "utf-8"));
const allDeps = [
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {}),
];
const externals = allDeps.filter((dep) => !allowlist.includes(dep));
await esbuild({
entryPoints: ["server/index.ts"],
platform: "node",
bundle: true,
format: "cjs",
outfile: "dist/index.cjs",
define: {
"process.env.NODE_ENV": '"production"',
},
minify: true,
external: externals,
logLevel: "info",
});
}
buildAll().catch((err) => {
console.error(err);
process.exit(1);
});

99
server/authRoutes.ts Normal file
View File

@ -0,0 +1,99 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const bcrypt = require("bcryptjs");
import { createUser, getUserByEmail, getUserById, updateUser, updateUserPassword } from "./db";
export function requireAuth(req: any, res: any, next: any) {
if (!(req.session as any)?.userId) {
return res.status(401).json({ error: "Not authenticated" });
}
next();
}
export function registerAuthRoutes(app: any) {
// POST /api/auth/register
app.post("/api/auth/register", async (req: any, res: any) => {
const { name, email, password } = req.body;
if (!name || !email || !password || password.length < 6) {
return res.status(400).json({ error: "Name, email, and password (min 6 chars) are required" });
}
const existing = await getUserByEmail(email.toLowerCase());
if (existing) {
return res.status(409).json({ error: "Email already in use" });
}
const hash = await bcrypt.hash(password, 10);
const user = await createUser({ name, email: email.toLowerCase(), passwordHash: hash });
(req.session as any).userId = user.id;
res.status(201).json({ id: user.id, name: user.name, email: user.email });
});
// POST /api/auth/login
app.post("/api/auth/login", async (req: any, res: any) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: "Email and password are required" });
}
const user = await getUserByEmail(email.toLowerCase());
if (!user) {
return res.status(401).json({ error: "Invalid email or password" });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: "Invalid email or password" });
}
(req.session as any).userId = user.id;
res.json({ id: user.id, name: user.name, email: user.email });
});
// POST /api/auth/logout
app.post("/api/auth/logout", (req: any, res: any) => {
req.session.destroy(() => { res.json({ ok: true }); });
});
// GET /api/auth/me
app.get("/api/auth/me", requireAuth, async (req: any, res: any) => {
const user = await getUserById((req.session as any).userId);
if (!user) {
req.session.destroy();
return res.status(401).json({ error: "Not authenticated" });
}
res.json({ id: user.id, name: user.name, email: user.email });
});
// PUT /api/auth/profile
app.put("/api/auth/profile", requireAuth, async (req: any, res: any) => {
const { name, email } = req.body;
const updates: any = {};
if (name) updates.name = name;
if (email) {
const existing = await getUserByEmail(email.toLowerCase());
if (existing && existing.id !== (req.session as any).userId) {
return res.status(409).json({ error: "Email already in use" });
}
updates.email = email.toLowerCase();
}
const user = await updateUser((req.session as any).userId, updates);
res.json({ id: user.id, name: user.name, email: user.email });
});
// PUT /api/auth/password
app.put("/api/auth/password", requireAuth, async (req: any, res: any) => {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword || newPassword.length < 6) {
return res.status(400).json({ error: "Current password and new password (min 6 chars) required" });
}
const user = await getUserById((req.session as any).userId);
if (!user) return res.status(401).json({ error: "Not authenticated" });
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) return res.status(401).json({ error: "Current password is incorrect" });
const hash = await bcrypt.hash(newPassword, 10);
await updateUserPassword(user.id, hash);
res.json({ ok: true });
});
// POST /api/auth/request-reset (stub)
app.post("/api/auth/request-reset", async (req: any, res: any) => {
const { email } = req.body;
if (!email) return res.status(400).json({ error: "Email is required" });
res.json({ ok: true, message: "If an account exists, a reset link would be sent" });
});
}

46
server/db.ts Normal file
View File

@ -0,0 +1,46 @@
import { drizzle } from "drizzle-orm/better-sqlite3";
import { eq } from "drizzle-orm";
import { users, User, NewUser } from "../shared/schema";
import * as path from "node:path";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Database = require("better-sqlite3");
const dbPath = path.resolve(process.cwd(), "data.db");
const sqlite = new Database(dbPath);
export const db = drizzle(sqlite);
// Initialize users table if it doesn't exist
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
)
`);
export async function createUser(data: NewUser): Promise<User> {
const [user] = await db.insert(users).values(data).returning();
return user;
}
export async function getUserByEmail(email: string): Promise<User | undefined> {
const result = await db.select().from(users).where(eq(users.email, email.toLowerCase()));
return result[0];
}
export async function getUserById(id: number): Promise<User | undefined> {
const result = await db.select().from(users).where(eq(users.id, id));
return result[0];
}
export async function updateUser(id: number, data: Partial<Pick<User, "name" | "email">>): Promise<User> {
const result = await db.update(users).set(data).where(eq(users.id, id)).returning();
return result[0];
}
export async function updateUserPassword(id: number, passwordHash: string): Promise<User> {
const result = await db.update(users).set({ passwordHash }).where(eq(users.id, id)).returning();
return result[0];
}

105
server/index.ts Normal file
View File

@ -0,0 +1,105 @@
import "dotenv/config";
import express, { Response, NextFunction } from 'express';
import type { Request } from 'express';
import { registerRoutes } from "./routes";
import { serveStatic } from "./static";
import { createServer } from "node:http";
const app = express();
const httpServer = createServer(app);
declare module "http" {
interface IncomingMessage {
rawBody: unknown;
}
}
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
}),
);
app.use(express.urlencoded({ extended: false }));
export function log(message: string, source = "express") {
const formattedTime = new Date().toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
});
console.log(`${formattedTime} [${source}] ${message}`);
}
app.use((req, res, next) => {
const start = Date.now();
const path = req.path;
let capturedJsonResponse: Record<string, any> | undefined = undefined;
const originalResJson = res.json;
res.json = function (bodyJson, ...args) {
capturedJsonResponse = bodyJson;
return originalResJson.apply(res, [bodyJson, ...args]);
};
res.on("finish", () => {
const duration = Date.now() - start;
if (path.startsWith("/api")) {
let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`;
if (capturedJsonResponse) {
logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`;
}
log(logLine);
}
});
next();
});
(async () => {
await registerRoutes(httpServer, app);
app.use((err: any, _req: Request, res: Response, next: NextFunction) => {
const status = err.status || err.statusCode || 500;
const message = err.message || "Internal Server Error";
console.error("Internal Server Error:", err);
if (res.headersSent) {
return next(err);
}
return res.status(status).json({ message });
});
// importantly only setup vite in development and after
// setting up all the other routes so the catch-all route
// doesn't interfere with the other routes
if (process.env.NODE_ENV === "production") {
serveStatic(app);
} else {
const { setupVite } = await import("./vite");
await setupVite(httpServer, app);
}
// ALWAYS serve the app on the port specified in the environment variable PORT
// Other ports are firewalled. Default to 5000 if not specified.
// this serves both the API and the client.
// It is the only port that is not firewalled.
const port = parseInt(process.env.PORT || "5000", 10);
httpServer.listen(
{
port,
host: "127.0.0.1",
reusePort: true,
},
() => {
log(`serving on port ${port}`);
},
);
})();

287
server/marketData.ts Normal file
View File

@ -0,0 +1,287 @@
// Deterministic mock ORATS-style data generator for GammaDesk.
//
// The MVP runs without live credentials. Outputs are stable for a given
// (ticker, day) pair so the UI is reproducible across reloads. When ORATS is
// wired in via `oratsClient.ts`, replace these generators with live calls and
// keep the same response shapes from `shared/schema.ts`.
//
// Gamma exposure (GEX) primer (estimate used in mock model):
// gex_contribution = gamma * open_interest * 100 * spot^2 * 0.01
// where:
// - gamma: option gamma (Black-Scholes), per $1 underlying move
// - open_interest: contract count outstanding
// - 100: standard equity contract multiplier
// - spot^2: converts per-share gamma into dollar gamma per 1% move
// - 0.01: normalize to "$ change per 1% move in spot"
// Sign convention: in this MVP, dealer-positioning long-gamma in calls is
// counted positive, short-gamma in puts is counted negative. Toggle in code
// by flipping `PUT_SIGN`.
import type {
ExpirationRow,
ExpirationsResponse,
GexBar,
GexProfile,
ScreenerRow,
Summary,
SymbolInfo,
} from "@shared/schema";
const PUT_SIGN = -1; // dealer convention: puts contribute negative gamma
// Catalog of supported symbols for the MVP screener / selector.
export const SYMBOLS: SymbolInfo[] = [
{ ticker: "SPY", name: "SPDR S&P 500 ETF", type: "etf", sector: "Broad index" },
{ ticker: "QQQ", name: "Invesco QQQ Trust", type: "etf", sector: "Technology" },
{ ticker: "SPX", name: "S&P 500 Index", type: "index", sector: "Broad index" },
{ ticker: "AAPL", name: "Apple Inc.", type: "equity", sector: "Technology" },
{ ticker: "TSLA", name: "Tesla, Inc.", type: "equity", sector: "Consumer cyclical" },
{ ticker: "NVDA", name: "NVIDIA Corporation", type: "equity", sector: "Semiconductors" },
];
// Reference spot prices and characteristic IV ranges per ticker. These keep
// the mock realistic and let regime/skew vary by name.
const PROFILES: Record<
string,
{ spot: number; ivBase: number; ivRange: number; skewBias: number; gexBias: number }
> = {
SPY: { spot: 583.42, ivBase: 12.4, ivRange: 6, skewBias: 0.9, gexBias: 1 },
QQQ: { spot: 502.18, ivBase: 16.1, ivRange: 7, skewBias: 0.6, gexBias: 0.8 },
SPX: { spot: 5827.6, ivBase: 11.9, ivRange: 5, skewBias: 1.1, gexBias: 1.3 },
AAPL: { spot: 226.31, ivBase: 22.7, ivRange: 9, skewBias: 0.3, gexBias: -0.4 },
TSLA: { spot: 248.5, ivBase: 54.2, ivRange: 16, skewBias: -0.2, gexBias: -1.2 },
NVDA: { spot: 138.4, ivBase: 47.9, ivRange: 14, skewBias: 0.1, gexBias: -0.8 },
};
// Deterministic PRNG (mulberry32) seeded by ticker + day-bucket so values are
// stable across calls but evolve daily.
function dailySeed(ticker: string, salt = 0): number {
const day = Math.floor(Date.now() / 86_400_000);
let h = 2166136261;
const key = `${ticker}|${day}|${salt}`;
for (let i = 0; i < key.length; i++) {
h ^= key.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function rng(seed: number): () => number {
let s = seed || 1;
return () => {
s = (s + 0x6d2b79f5) | 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function gauss(rand: () => number): number {
// Box-Muller, clipped
const u = Math.max(rand(), 1e-6);
const v = Math.max(rand(), 1e-6);
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function getProfile(ticker: string) {
return PROFILES[ticker] ?? PROFILES.SPY;
}
function roundTo(value: number, step: number): number {
return Math.round(value / step) * step;
}
function strikeStep(spot: number): number {
if (spot >= 1000) return 25;
if (spot >= 300) return 5;
if (spot >= 100) return 2.5;
return 1;
}
// Gaussian-shaped open interest weight by moneyness, with a slight skew toward
// out-of-the-money puts (typical for index hedging flow).
function oiWeight(strike: number, spot: number, side: "call" | "put"): number {
const m = (strike - spot) / spot;
const center = side === "call" ? 0.02 : -0.04;
const sigma = side === "call" ? 0.06 : 0.08;
const x = (m - center) / sigma;
return Math.exp(-0.5 * x * x);
}
// Approximation of Black-Scholes gamma at strike for a short DTE. We don't
// need exact greeks for a mock; we need a believable bell-shaped curve peaked
// near ATM.
function approxGamma(strike: number, spot: number, dte: number, iv: number): number {
const t = Math.max(dte, 1) / 365;
const sigma = iv / 100;
const denom = spot * sigma * Math.sqrt(t);
const m = Math.log(strike / spot) / (sigma * Math.sqrt(t));
const pdf = Math.exp(-0.5 * m * m) / Math.sqrt(2 * Math.PI);
return pdf / Math.max(denom, 1e-6);
}
export function computeSummary(ticker: string): Summary {
const profile = getProfile(ticker);
const rand = rng(dailySeed(ticker, 1));
const spotJitter = (rand() - 0.5) * 0.01 * profile.spot;
const spot = profile.spot + spotJitter;
const spotChange = (rand() - 0.5) * profile.spot * 0.015;
const spotChangePct = (spotChange / spot) * 100;
// Net GEX in $ billions. Scale up for indices, allow negative regimes for
// single names.
const gexMagnitude = (profile.gexBias + (rand() - 0.5) * 0.6) * 1.4e9;
const netGex = gexMagnitude;
const gammaRegime = netGex >= 0 ? "positive" : "negative";
const step = strikeStep(spot);
const hvl = roundTo(spot * (1 + (rand() - 0.5) * 0.01), step);
const callWall = roundTo(spot * (1 + 0.012 + rand() * 0.025), step);
const putWall = roundTo(spot * (1 - 0.018 - rand() * 0.03), step);
const ivx = profile.ivBase + (rand() - 0.5) * profile.ivRange;
const ivRank = Math.max(2, Math.min(98, 30 + (rand() - 0.5) * 90));
const expectedMovePct = (ivx / 100) * Math.sqrt(1 / 252) * 100;
const expectedMove = (expectedMovePct / 100) * spot;
const skew = profile.skewBias + (rand() - 0.5) * 1.2;
return {
ticker,
spot: round2(spot),
spotChange: round2(spotChange),
spotChangePct: round2(spotChangePct),
netGex: Math.round(netGex),
gammaRegime,
hvl,
callWall,
putWall,
ivRank: round1(ivRank),
ivx: round2(ivx),
expectedMove: round2(expectedMove),
expectedMovePct: round2(expectedMovePct),
skew: round2(skew),
asOf: new Date().toISOString(),
};
}
export function computeGexProfile(ticker: string): GexProfile {
const profile = getProfile(ticker);
const summary = computeSummary(ticker);
const rand = rng(dailySeed(ticker, 2));
const spot = summary.spot;
const step = strikeStep(spot);
// Range: ~+/- 12% around spot
const minStrike = roundTo(spot * 0.88, step);
const maxStrike = roundTo(spot * 1.12, step);
const bars: GexBar[] = [];
const dte = 14;
const iv = summary.ivx;
for (let k = minStrike; k <= maxStrike + 1e-6; k += step) {
const strike = round2(k);
const gamma = approxGamma(strike, spot, dte, iv);
const callRegimeBoost = summary.gammaRegime === "positive" ? 1.55 : 0.75;
const putRegimeBoost = summary.gammaRegime === "positive" ? 0.65 : 1.55;
const callOi = 20_000 * callRegimeBoost * oiWeight(strike, spot, "call") * (0.85 + rand() * 0.35);
const putOi = 20_000 * putRegimeBoost * oiWeight(strike, spot, "put") * (0.85 + rand() * 0.35);
// gex = gamma * OI * 100 * spot^2 * 0.01
const callGex = gamma * callOi * 100 * spot * spot * 0.01;
const putGex = PUT_SIGN * gamma * putOi * 100 * spot * spot * 0.01;
// Bias toward call/put wall locations
const wallBoostCall = Math.exp(-Math.pow((strike - summary.callWall) / (step * 1.5), 2)) * 1.4;
const wallBoostPut = Math.exp(-Math.pow((strike - summary.putWall) / (step * 1.5), 2)) * 1.4;
const cg = callGex * (1 + wallBoostCall);
const pg = putGex * (1 + wallBoostPut);
bars.push({
strike,
callGex: Math.round(cg),
putGex: Math.round(pg),
netGex: Math.round(cg + pg),
});
}
return {
ticker,
spot,
hvl: summary.hvl,
callWall: summary.callWall,
putWall: summary.putWall,
bars,
asOf: new Date().toISOString(),
};
}
export function computeExpirations(ticker: string): ExpirationsResponse {
const summary = computeSummary(ticker);
const profile = getProfile(ticker);
const rand = rng(dailySeed(ticker, 3));
const baseDtes = [0, 1, 2, 7, 14, 30, 60, 90, 180];
const today = new Date();
const rows: ExpirationRow[] = baseDtes.map((dte) => {
const date = new Date(today);
date.setUTCDate(date.getUTCDate() + dte);
const ivx = profile.ivBase * (1 + (dte / 365) * 0.15) + (rand() - 0.5) * 4;
const expectedMovePct = (ivx / 100) * Math.sqrt(Math.max(dte, 1) / 365) * 100;
const expectedMove = (expectedMovePct / 100) * summary.spot;
const gexScale = Math.exp(-dte / 45);
const netGex = Math.round(summary.netGex * gexScale * (0.6 + rand() * 0.8));
const skewBase = profile.skewBias + (rand() - 0.5);
const callSkew = profile.ivBase - 1.5 + (rand() - 0.5) * 1.5;
const putSkew = profile.ivBase + 2.2 + (rand() - 0.5) * 1.5 + skewBase;
return {
expiry: date.toISOString().slice(0, 10),
dte,
netGex,
ivx: round2(ivx),
skew: round2(putSkew - callSkew),
expectedMove: round2(expectedMove),
expectedMovePct: round2(expectedMovePct),
callWall: roundTo(summary.spot * (1 + 0.01 + rand() * 0.03), strikeStep(summary.spot)),
putWall: roundTo(summary.spot * (1 - 0.012 - rand() * 0.035), strikeStep(summary.spot)),
callSkew: round2(callSkew),
putSkew: round2(putSkew),
};
});
return {
ticker,
spot: summary.spot,
rows,
asOf: new Date().toISOString(),
};
}
export function computeScreener(): ScreenerRow[] {
return SYMBOLS.map((s) => {
const summary = computeSummary(s.ticker);
const exp = computeExpirations(s.ticker);
const zero = exp.rows.find((r) => r.dte === 0)?.netGex ?? 0;
return {
ticker: s.ticker,
name: s.name,
spot: summary.spot,
spotChangePct: summary.spotChangePct,
netGex: summary.netGex,
gammaRegime: summary.gammaRegime,
ivRank: summary.ivRank,
ivx: summary.ivx,
callWall: summary.callWall,
putWall: summary.putWall,
distanceToCallWall: round2(((summary.callWall - summary.spot) / summary.spot) * 100),
distanceToPutWall: round2(((summary.putWall - summary.spot) / summary.spot) * 100),
expectedMovePct: summary.expectedMovePct,
skew: summary.skew,
zeroDteNetGex: zero,
};
});
}
function round1(n: number): number {
return Math.round(n * 10) / 10;
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}

54
server/oratsClient.ts Normal file
View File

@ -0,0 +1,54 @@
// ORATS connector placeholder.
//
// When ORATS credentials are supplied via the `ORATS_API_KEY` environment
// variable, this client should call the relevant ORATS Data API endpoints
// and return responses shaped exactly like the types in `shared/schema.ts`.
//
// Suggested ORATS endpoints for the MVP feature set:
// - GET https://api.orats.io/datav2/strikes?ticker=SPY (strike-level gamma + OI)
// - GET https://api.orats.io/datav2/summaries?ticker=SPY (IV, IVR, expected move)
// - GET https://api.orats.io/datav2/cores?ticker=SPY (spot, daily series)
// - GET https://api.orats.io/datav2/monies/implied?ticker=SPY (implied skew curves)
//
// Required transformations (left as TODO for future wiring):
// * Aggregate strike-level gamma * OI * 100 * spot^2 * 0.01 to produce the
// bars used by `GexProfile`.
// * Derive call wall = strike with max positive call gamma exposure.
// * Derive put wall = strike with min (most negative) put gamma exposure.
// * HVL/gamma flip = strike where cumulative net gamma crosses zero.
//
// Until ORATS is wired up the routes call into `marketData.ts` instead.
export interface OratsClientConfig {
apiKey?: string;
baseUrl?: string;
}
export class OratsClient {
private apiKey: string;
private baseUrl: string;
constructor(config: OratsClientConfig = {}) {
this.apiKey = config.apiKey ?? process.env.ORATS_API_KEY ?? "";
this.baseUrl = config.baseUrl ?? "https://api.orats.io/datav2";
}
isConfigured(): boolean {
return this.apiKey.length > 0;
}
// Future wiring: throws until implemented so callers can fall back to mocks.
async fetchStrikes(_ticker: string): Promise<never> {
throw new Error("OratsClient.fetchStrikes not yet implemented");
}
async fetchSummary(_ticker: string): Promise<never> {
throw new Error("OratsClient.fetchSummary not yet implemented");
}
async fetchMonies(_ticker: string): Promise<never> {
throw new Error("OratsClient.fetchMonies not yet implemented");
}
}
export const oratsClient = new OratsClient();

77
server/routes.ts Normal file
View File

@ -0,0 +1,77 @@
import type { Express, Request, Response } from "express";
import { createServer } from "node:http";
import type { Server } from "node:http";
import session from "express-session";
import MemoryStore from "memorystore";
import {
SYMBOLS,
computeExpirations,
computeGexProfile,
computeScreener,
computeSummary,
} from "./marketData";
import { oratsClient } from "./oratsClient";
import { registerAuthRoutes } from "./authRoutes";
const SessionMemoryStore = MemoryStore(session);
const KNOWN_TICKERS = new Set(SYMBOLS.map((s) => s.ticker));
function withTicker(req: Request, res: Response, fn: (ticker: string) => unknown) {
const raw = String(req.params.symbol || "").toUpperCase();
if (!KNOWN_TICKERS.has(raw)) {
res.status(404).json({ error: `Unknown symbol: ${raw}` });
return;
}
try {
res.json(fn(raw));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
}
export async function registerRoutes(httpServer: Server, app: Express): Promise<Server> {
// Session middleware
app.use(session({
store: new SessionMemoryStore({ checkPeriod: 86400000 }),
secret: process.env.SESSION_SECRET || "gammadesk-dev-secret-change-me",
resave: false,
saveUninitialized: false,
cookie: {
secure: false, // Set true in production with HTTPS
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
}));
// Auth routes
registerAuthRoutes(app);
app.get("/api/symbols", (_req, res) => {
res.json(SYMBOLS);
});
app.get("/api/orats/status", (_req, res) => {
res.json({
configured: oratsClient.isConfigured(),
baseUrl: "https://api.orats.io/datav2",
});
});
app.get("/api/market/:symbol/summary", (req, res) =>
withTicker(req, res, (t) => computeSummary(t)),
);
app.get("/api/market/:symbol/gex", (req, res) =>
withTicker(req, res, (t) => computeGexProfile(t)),
);
app.get("/api/market/:symbol/expirations", (req, res) =>
withTicker(req, res, (t) => computeExpirations(t)),
);
app.get("/api/screener", (_req, res) => {
res.json(computeScreener());
});
return httpServer;
}

20
server/static.ts Normal file
View File

@ -0,0 +1,20 @@
import express from 'express';
import type { Express } from 'express';
import fs from "node:fs";
import path from "node:path";
export function serveStatic(app: Express) {
const distPath = path.resolve(__dirname, "public");
if (!fs.existsSync(distPath)) {
throw new Error(
`Could not find the build directory: ${distPath}, make sure to build the client first`,
);
}
app.use(express.static(distPath));
// fall through to index.html if the file doesn't exist
app.use("/{*path}", (_req, res) => {
res.sendFile(path.resolve(distPath, "index.html"));
});
}

58
server/vite.ts Normal file
View File

@ -0,0 +1,58 @@
import type { Express } from 'express';
import { createServer as createViteServer, createLogger } from "vite";
import type { Server } from 'node:http';
import viteConfig from "../vite.config";
import fs from "node:fs";
import path from "node:path";
import { nanoid } from "nanoid";
const viteLogger = createLogger();
export async function setupVite(server: Server, app: Express) {
const serverOptions = {
middlewareMode: true,
hmr: { server, path: "/vite-hmr" },
allowedHosts: true as const,
};
const vite = await createViteServer({
...viteConfig,
configFile: false,
customLogger: {
...viteLogger,
error: (msg, options) => {
viteLogger.error(msg, options);
process.exit(1);
},
},
server: serverOptions,
appType: "custom",
});
app.use(vite.middlewares);
app.use("/{*path}", async (req, res, next) => {
const url = req.originalUrl;
try {
const clientTemplate = path.resolve(
import.meta.dirname,
"..",
"client",
"index.html",
);
// always reload the index.html file from disk incase it changes
let template = await fs.promises.readFile(clientTemplate, "utf-8");
template = template.replace(
`src="/src/main.tsx"`,
`src="/src/main.tsx?v=${nanoid()}"`,
);
const page = await vite.transformIndexHtml(url, template);
res.status(200).set({ "Content-Type": "text/html" }).end(page);
} catch (e) {
vite.ssrFixStacktrace(e as Error);
next(e);
}
});
}

144
shared/schema.ts Normal file
View File

@ -0,0 +1,144 @@
// GammaDesk shared data model
// ORATS-style options analytics types + Drizzle tables for auth.
import { z } from "zod";
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
// ---------------------------------------------------------------------------
// Auth - Users table (Drizzle)
// ---------------------------------------------------------------------------
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
});
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
// ---------------------------------------------------------------------------
// Symbols
// ---------------------------------------------------------------------------
export const symbolSchema = z.object({
ticker: z.string(),
name: z.string(),
type: z.enum(["index", "etf", "equity"]),
sector: z.string().optional(),
});
export type SymbolInfo = z.infer<typeof symbolSchema>;
// ---------------------------------------------------------------------------
// Summary card payload — what the Dashboard top row shows
// ---------------------------------------------------------------------------
export const summarySchema = z.object({
ticker: z.string(),
spot: z.number(),
spotChange: z.number(), // absolute $ change vs prior close
spotChangePct: z.number(),
netGex: z.number(), // signed, in $ per 1% move
gammaRegime: z.enum(["positive", "negative"]),
hvl: z.number(), // High Volume Level / gamma flip
callWall: z.number(),
putWall: z.number(),
ivRank: z.number(), // 0-100
ivx: z.number(), // implied vol index, percent
expectedMove: z.number(), // absolute $ expected move next session
expectedMovePct: z.number(),
skew: z.number(), // put skew minus call skew, percent points
asOf: z.string(), // ISO timestamp
});
export type Summary = z.infer<typeof summarySchema>;
// ---------------------------------------------------------------------------
// GEX profile — bars by strike for the gamma-by-strike visualization
// ---------------------------------------------------------------------------
export const gexBarSchema = z.object({
strike: z.number(),
callGex: z.number(), // $ gamma exposure attributable to call OI
putGex: z.number(), // negative or positive depending on sign convention
netGex: z.number(),
});
export const gexProfileSchema = z.object({
ticker: z.string(),
spot: z.number(),
hvl: z.number(),
callWall: z.number(),
putWall: z.number(),
bars: z.array(gexBarSchema),
asOf: z.string(),
});
export type GexBar = z.infer<typeof gexBarSchema>;
export type GexProfile = z.infer<typeof gexProfileSchema>;
// ---------------------------------------------------------------------------
// Expirations matrix
// ---------------------------------------------------------------------------
export const expirationRowSchema = z.object({
expiry: z.string(), // ISO date
dte: z.number(),
netGex: z.number(),
ivx: z.number(),
skew: z.number(),
expectedMove: z.number(),
expectedMovePct: z.number(),
callWall: z.number(),
putWall: z.number(),
callSkew: z.number(), // for IV/skew curve chart
putSkew: z.number(),
});
export const expirationsResponseSchema = z.object({
ticker: z.string(),
spot: z.number(),
rows: z.array(expirationRowSchema),
asOf: z.string(),
});
export type ExpirationRow = z.infer<typeof expirationRowSchema>;
export type ExpirationsResponse = z.infer<typeof expirationsResponseSchema>;
// ---------------------------------------------------------------------------
// Screener
// ---------------------------------------------------------------------------
export const screenerPresetSchema = z.enum([
"all",
"negative-gamma",
"high-iv-rank",
"near-call-wall",
"near-put-wall",
"zero-dte",
]);
export type ScreenerPreset = z.infer<typeof screenerPresetSchema>;
export const screenerRowSchema = z.object({
ticker: z.string(),
name: z.string(),
spot: z.number(),
spotChangePct: z.number(),
netGex: z.number(),
gammaRegime: z.enum(["positive", "negative"]),
ivRank: z.number(),
ivx: z.number(),
callWall: z.number(),
putWall: z.number(),
distanceToCallWall: z.number(), // % away from spot (signed: + if wall above)
distanceToPutWall: z.number(),
expectedMovePct: z.number(),
skew: z.number(),
zeroDteNetGex: z.number(),
});
export type ScreenerRow = z.infer<typeof screenerRowSchema>;

113
tailwind.config.ts Normal file
View File

@ -0,0 +1,113 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: ["./client/index.html", "./client/src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {
borderRadius: {
lg: ".5625rem", /* 9px */
md: ".375rem", /* 6px */
sm: ".1875rem", /* 3px */
},
colors: {
// Flat / base colors (regular buttons)
background: "hsl(var(--background) / <alpha-value>)",
foreground: "hsl(var(--foreground) / <alpha-value>)",
border: "hsl(var(--border) / <alpha-value>)",
input: "hsl(var(--input) / <alpha-value>)",
card: {
DEFAULT: "hsl(var(--card) / <alpha-value>)",
foreground: "hsl(var(--card-foreground) / <alpha-value>)",
border: "hsl(var(--card-border) / <alpha-value>)",
},
popover: {
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
foreground: "hsl(var(--popover-foreground) / <alpha-value>)",
border: "hsl(var(--popover-border) / <alpha-value>)",
},
primary: {
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
foreground: "hsl(var(--primary-foreground) / <alpha-value>)",
border: "var(--primary-border)",
},
secondary: {
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)",
border: "var(--secondary-border)",
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground) / <alpha-value>)",
border: "var(--muted-border)",
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
border: "var(--accent-border)",
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
border: "var(--destructive-border)",
},
ring: "hsl(var(--ring) / <alpha-value>)",
chart: {
"1": "hsl(var(--chart-1) / <alpha-value>)",
"2": "hsl(var(--chart-2) / <alpha-value>)",
"3": "hsl(var(--chart-3) / <alpha-value>)",
"4": "hsl(var(--chart-4) / <alpha-value>)",
"5": "hsl(var(--chart-5) / <alpha-value>)",
},
sidebar: {
ring: "hsl(var(--sidebar-ring) / <alpha-value>)",
DEFAULT: "hsl(var(--sidebar) / <alpha-value>)",
foreground: "hsl(var(--sidebar-foreground) / <alpha-value>)",
border: "hsl(var(--sidebar-border) / <alpha-value>)",
},
"sidebar-primary": {
DEFAULT: "hsl(var(--sidebar-primary) / <alpha-value>)",
foreground: "hsl(var(--sidebar-primary-foreground) / <alpha-value>)",
border: "var(--sidebar-primary-border)",
},
"sidebar-accent": {
DEFAULT: "hsl(var(--sidebar-accent) / <alpha-value>)",
foreground: "hsl(var(--sidebar-accent-foreground) / <alpha-value>)",
border: "var(--sidebar-accent-border)"
},
status: {
online: "rgb(34 197 94)",
away: "rgb(245 158 11)",
busy: "rgb(239 68 68)",
offline: "rgb(156 163 175)",
},
pos: "hsl(var(--pos) / <alpha-value>)",
neg: "hsl(var(--neg) / <alpha-value>)",
"call-wall": "hsl(var(--call-wall) / <alpha-value>)",
"put-wall": "hsl(var(--put-wall) / <alpha-value>)",
hvl: "hsl(var(--hvl) / <alpha-value>)",
spot: "hsl(var(--spot) / <alpha-value>)",
},
fontFamily: {
sans: ["var(--font-sans)"],
serif: ["var(--font-serif)"],
mono: ["var(--font-mono)"],
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
} satisfies Config;

23
tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"include": ["client/src/**/*", "shared/**/*", "server/**/*"],
"exclude": ["node_modules", "build", "dist", "**/*.test.ts"],
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./node_modules/typescript/tsbuildinfo",
"noEmit": true,
"module": "ESNext",
"strict": true,
"lib": ["esnext", "dom", "dom.iterable"],
"jsx": "preserve",
"esModuleInterop": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"moduleResolution": "bundler",
"baseUrl": ".",
"types": ["node", "vite/client"],
"paths": {
"@/*": ["./client/src/*"],
"@shared/*": ["./shared/*"]
}
}
}

Some files were not shown because too many files have changed in this diff Show More