commit
53dbc8fb43
@ -0,0 +1,63 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual Environment
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.npm
|
||||||
|
.yarn
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@ -0,0 +1,345 @@
|
|||||||
|
# Design System Inspired by Binance.US
|
||||||
|
|
||||||
|
## 1. Visual Theme & Atmosphere
|
||||||
|
|
||||||
|
Binance.US radiates the polished urgency of a digital trading floor — a space where money moves and decisions happen in seconds. The design is a two-tone composition that alternates between stark white trading surfaces and deep near-black panels (`#222126`), creating a visual rhythm that mirrors the bull-and-bear duality of crypto markets. Binance Yellow (`#F0B90B`) cuts through this monochrome foundation like a gold ingot on a steel desk — unmistakable, confident, and engineered to guide every eye toward the next action.
|
||||||
|
|
||||||
|
The interface speaks the language of fintech trust. Custom BinancePlex typography gives every headline and data point a proprietary gravitas, while generous whitespace and restrained decoration keep the focus on numbers, charts, and call-to-action buttons. The design avoids visual complexity in favor of operational clarity — every element exists to either inform or convert. Product screenshots of the mobile trading app dominate the middle sections, presented on floating device mockups against golden gradients, reinforcing that this is a platform you carry with you.
|
||||||
|
|
||||||
|
What makes Binance.US distinctive is the tension between warmth and precision. The golden yellow brand color — warm, optimistic, almost celebratory — lives inside a system of cold, clinical grey text and razor-sharp borders. This isn't a playful fintech like Robinhood or a corporate fortress like Fidelity — it's a crypto-native platform that wraps cutting-edge trading technology in the visual language of established finance.
|
||||||
|
|
||||||
|
**Key Characteristics:**
|
||||||
|
- Two-tone light/dark section alternation — white surfaces for trust, dark panels for depth
|
||||||
|
- Binance Yellow (`#F0B90B`) as the singular accent color driving all primary actions
|
||||||
|
- BinancePlex custom typeface providing proprietary brand identity at every text level
|
||||||
|
- Pill-shaped CTA buttons (50px radius) that demand attention
|
||||||
|
- Floating device mockups on golden gradients for product showcasing
|
||||||
|
- Crypto price tickers with real-time data prominently displayed
|
||||||
|
- Shadow-light elevation with subtle 5% opacity card shadows
|
||||||
|
|
||||||
|
## 2. Color Palette & Roles
|
||||||
|
|
||||||
|
### Primary
|
||||||
|
|
||||||
|
- **Binance Yellow** (`#F0B90B`): The signature — primary CTA backgrounds, brand accent, active states, link color. The single most important color in the system
|
||||||
|
- **Binance Gold** (`#FFD000`): Lighter gold variant used for pill button borders, secondary CTA fills, and golden gradient highlights
|
||||||
|
- **Light Gold** (`#F8D12F`): Soft gold for gradient endpoints and hover-adjacent states
|
||||||
|
|
||||||
|
### Secondary & Accent
|
||||||
|
|
||||||
|
- **Active Yellow** (`#D0980B`): Darkened yellow for active/pressed button states — the "clicked" gold
|
||||||
|
- **Focus Blue** (`#1EAEDB`): Accessibility focus state — appears on hover and focus for all interactive elements
|
||||||
|
|
||||||
|
### Surface & Background
|
||||||
|
|
||||||
|
- **Pure White** (`#FFFFFF`): Primary page canvas, card surfaces, light section backgrounds
|
||||||
|
- **Snow** (`#F5F5F5`): Subtle surface differentiation, input backgrounds, alternating row fills
|
||||||
|
- **Binance Dark** (`#222126`): Dark section backgrounds, footer canvas, "Trusted by millions" panel — a near-black with a faint purple undertone
|
||||||
|
- **Dark Card** (`#2B2F36`): Card surfaces within dark sections, elevated dark containers
|
||||||
|
- **Ink** (`#1E2026`): Button text on yellow backgrounds, deepest text color on light surfaces
|
||||||
|
|
||||||
|
### Neutrals & Text
|
||||||
|
|
||||||
|
- **Primary Text** (`#1E2026`): Main body text, headings on light backgrounds — near-black with slight warmth
|
||||||
|
- **Secondary Text** (`#32313A`): Navigation links, descriptive copy on light surfaces
|
||||||
|
- **Slate** (`#848E9C`): Tertiary text, metadata, timestamps, footer links — the workhorse grey
|
||||||
|
- **Steel** (`#686A6C`): Disabled-adjacent text, subtle labels
|
||||||
|
- **Muted** (`#777E90`): Secondary navigation links, less prominent footer text
|
||||||
|
- **Hover Dark** (`#1A1A1A`): Universal link hover color — text darkens on hover
|
||||||
|
|
||||||
|
### Semantic & Accent
|
||||||
|
|
||||||
|
- **Crypto Green** (`#0ECB81`): Positive price movement, success states, "up" indicators
|
||||||
|
- **Crypto Red** (`#F6465D`): Negative price movement, error states, "down" indicators
|
||||||
|
- **Border Light** (`#E6E8EA`): Standard card and section borders on light backgrounds
|
||||||
|
- **Border Gold** (`#FFD000`): Active/selected state borders, pill button outlines
|
||||||
|
|
||||||
|
### Gradient System
|
||||||
|
|
||||||
|
- **Golden Glow**: Radial gradient from `#F0B90B` center to `#F8D12F` edge — used behind product mockup screenshots
|
||||||
|
- **Dark Fade**: Linear gradient from `#222126` to transparent — used for dark section transitions
|
||||||
|
- **Hero Shimmer**: Subtle animated gold gradient on hero section accents
|
||||||
|
|
||||||
|
## 3. Typography Rules
|
||||||
|
|
||||||
|
### Font Family
|
||||||
|
|
||||||
|
**Primary:** BinancePlex (custom proprietary typeface designed by Binance)
|
||||||
|
- Fallbacks: Arial, sans-serif
|
||||||
|
- Replaced DIN Next to solve multi-language spacing issues
|
||||||
|
- Available in weights: 400 (Regular), 500 (Medium), 600 (SemiBold), 700 (Bold)
|
||||||
|
|
||||||
|
**System:** system-ui stack for cookie banners and third-party UI
|
||||||
|
- Fallbacks: Segoe UI, Roboto, Helvetica, Arial
|
||||||
|
|
||||||
|
### Hierarchy
|
||||||
|
|
||||||
|
| Role | Size | Weight | Line Height | Letter Spacing | Notes |
|
||||||
|
|------|------|--------|-------------|----------------|-------|
|
||||||
|
| Display Hero | 60px | 700 | 1.08 | — | Hero headlines, maximum impact |
|
||||||
|
| Display Secondary | 34px | 700 | 1.00 | — | Section titles on dark backgrounds |
|
||||||
|
| Heading 1 | 28px | 500 | 1.00 | — | Major section headings |
|
||||||
|
| Heading 2 | 24px | 700 | 1.00 | — | Feature headings, card titles |
|
||||||
|
| Heading 3 | 24px | 600 | 1.00 | — | Subsection headings |
|
||||||
|
| Heading 4 | 20px | 600 | 1.25 | — | Card headings, feature labels |
|
||||||
|
| Body Large | 20px | 500 | 1.50 | — | Hero subtitle, lead paragraphs |
|
||||||
|
| Body | 16px | 500 | 1.50 | — | Standard body text |
|
||||||
|
| Body SemiBold | 16px | 600 | 1.30 | — | Emphasized body, nav links |
|
||||||
|
| Body Bold | 16px | 700 | 1.50 | — | Strong emphasis text |
|
||||||
|
| Button | 16px | 600 | 1.25 | 0.16px | Primary button text |
|
||||||
|
| Button Small | 14.4px | 600 | 1.60 | 0.72px | Secondary buttons, wider tracking |
|
||||||
|
| Caption | 14px | 500 | 1.43 | — | Metadata, labels, prices |
|
||||||
|
| Caption SemiBold | 14px | 600 | 1.50 | — | Emphasized captions |
|
||||||
|
| Small | 12px | 600 | 1.00 | — | Tags, badges, fine print |
|
||||||
|
| Tiny | 11px | 500 | 1.00 | — | Micro-labels, chart annotations |
|
||||||
|
|
||||||
|
### Principles
|
||||||
|
|
||||||
|
BinancePlex is engineered for data-dense interfaces where numbers and text must coexist at multiple scales. The typeface has tabular numerals by default — critical for price columns and portfolio values that need perfect vertical alignment. Weights lean toward the heavier end (500-700), giving the interface a sense of authority and confidence that's essential for a financial platform. The tight line-heights (1.00-1.25) on headings create a stacked, compressed feel that mirrors the density of trading dashboards, while body text opens up to 1.50 for comfortable reading of educational and marketing content.
|
||||||
|
|
||||||
|
## 4. Component Stylings
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
**Primary (Yellow Fill)**
|
||||||
|
- Background: Binance Yellow (`#F0B90B`)
|
||||||
|
- Text: Ink (`#1E2026`), 16px/600, BinancePlex
|
||||||
|
- Border: none
|
||||||
|
- Border radius: slightly rounded (6px)
|
||||||
|
- Padding: 6px 32px
|
||||||
|
- Hover: shifts to Focus Blue (`#1EAEDB`) with white text
|
||||||
|
- Active: darkens to Active Yellow (`#D0980B`)
|
||||||
|
- Focus: Focus Blue (`#1EAEDB`) bg, 1px black border, 2px black outline, opacity 0.9
|
||||||
|
- Transition: background 200ms ease
|
||||||
|
|
||||||
|
**Primary Pill (Gold)**
|
||||||
|
- Background: Binance Gold (`#FFD000`)
|
||||||
|
- Text: White (`#FFFFFF`)
|
||||||
|
- Border: 1px solid `#FFD000`
|
||||||
|
- Border radius: full pill (50px)
|
||||||
|
- Padding: 10px horizontal
|
||||||
|
- Shadow: `rgb(153,153,153) 0px 2px 10px -3px`
|
||||||
|
- Hover: shifts to Focus Blue (`#1EAEDB`) with white text
|
||||||
|
|
||||||
|
**Secondary (White Outlined)**
|
||||||
|
- Background: White (`#FFFFFF`)
|
||||||
|
- Text: Binance Yellow (`#F0B90B`)
|
||||||
|
- Border: 1px solid `#F0B90B`
|
||||||
|
- Border radius: full pill (50px)
|
||||||
|
- Padding: 10px horizontal
|
||||||
|
- Shadow: `rgb(153,153,153) 0px 2px 10px -3px`
|
||||||
|
- Hover: shifts to Focus Blue bg, white text
|
||||||
|
|
||||||
|
**Disabled**
|
||||||
|
- Background: `#E6E8EA`
|
||||||
|
- Text: `#848E9C`
|
||||||
|
- Cursor: not-allowed
|
||||||
|
|
||||||
|
### Cards & Containers
|
||||||
|
|
||||||
|
- Background: White (`#FFFFFF`) on light sections, Dark Card (`#2B2F36`) on dark sections
|
||||||
|
- Border: 1px solid `#E6E8EA` on light cards
|
||||||
|
- Border radius: medium rounded (12px) for content cards, tight (8px) for data cards
|
||||||
|
- Shadow: `rgba(32, 32, 37, 0.05) 0px 3px 5px 0px` — barely visible, trust-building
|
||||||
|
- Hover: shadow intensifies to `rgba(8, 8, 8, 0.05) 0px 3px 5px 5px`
|
||||||
|
- Transition: box-shadow 200ms ease
|
||||||
|
|
||||||
|
### Inputs & Forms
|
||||||
|
|
||||||
|
- Background: White (`#FFFFFF`) or Snow (`#F5F5F5`)
|
||||||
|
- Text: Ink (`#1E2026`)
|
||||||
|
- Border: 1px solid `#E6E8EA`
|
||||||
|
- Border radius: 8px
|
||||||
|
- Padding: 0px 12px (compact for trading context)
|
||||||
|
- Focus: border shifts to black (`#000000`), 1px outline
|
||||||
|
- Placeholder: Slate (`#848E9C`)
|
||||||
|
- Transition: border-color 200ms ease
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
- Background: White (`#FFFFFF`), sticky
|
||||||
|
- Height: ~64px
|
||||||
|
- Left: Binance logo (SVG, yellow mark + dark wordmark)
|
||||||
|
- Center/Right: navigation links in 14px/600 BinancePlex, color `#32313A`
|
||||||
|
- CTA: Yellow pill button "Get Started" in nav right
|
||||||
|
- Hover: links darken to `#1A1A1A`
|
||||||
|
- Mobile: hamburger menu, full-height overlay
|
||||||
|
- Top: optional promotional banner bar
|
||||||
|
|
||||||
|
### Image Treatment
|
||||||
|
|
||||||
|
- Product mockups: device frames on golden gradient backgrounds, floating with subtle shadow
|
||||||
|
- Hero images: full-width contained within card-like areas with rounded corners (24px)
|
||||||
|
- Video sections: 24px radius with embedded player controls
|
||||||
|
- App screenshots: dark-themed trading UI shown within phone/tablet bezels
|
||||||
|
- Crypto icons: 48px circular with brand colors
|
||||||
|
|
||||||
|
### Trust Indicators
|
||||||
|
|
||||||
|
- Real-time crypto price ticker (BTC, BNB, SOL with green/red price change)
|
||||||
|
- "Trusted by millions" section with statistics on dark background
|
||||||
|
- Security badges and regulatory compliance mentions
|
||||||
|
- QR code for direct app download in footer
|
||||||
|
|
||||||
|
## 5. Layout Principles
|
||||||
|
|
||||||
|
### Spacing System
|
||||||
|
|
||||||
|
Base unit: 8px
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| space-1 | 4px | Tight inline gaps, icon padding |
|
||||||
|
| space-2 | 8px | Base unit, button icon gaps, tight margins |
|
||||||
|
| space-3 | 12px | Card internal padding, input padding |
|
||||||
|
| space-4 | 16px | Standard padding, section margins |
|
||||||
|
| space-5 | 20px | Card gaps, medium padding |
|
||||||
|
| space-6 | 24px | Section internal padding |
|
||||||
|
| space-7 | 32px | Section breaks, large padding |
|
||||||
|
| space-8 | 48px | Major section padding |
|
||||||
|
| space-9 | 64px | Hero section padding |
|
||||||
|
| space-10 | 80px | Large section spacing |
|
||||||
|
|
||||||
|
### Grid & Container
|
||||||
|
|
||||||
|
- Max container width: 1200px (centered)
|
||||||
|
- Hero area: single column with side-by-side text + image above 1024px
|
||||||
|
- Feature grid: 3-column on desktop, single column on mobile
|
||||||
|
- Product showcase: 2-column (text + device mockup)
|
||||||
|
- Horizontal padding: 32px desktop, 16px mobile
|
||||||
|
- Grid gap: 24px between feature cards
|
||||||
|
|
||||||
|
### Whitespace Philosophy
|
||||||
|
|
||||||
|
Binance.US uses whitespace as a trust signal. Generous padding around the hero section and between content blocks creates a sense of spaciousness that counters the information density typically associated with crypto exchanges. The light sections breathe — wide margins around headlines and ample spacing between cards — while dark sections compress, packing features into tighter grids to convey capability and depth. The overall rhythm alternates between "inviting entry" (light, spacious) and "deep functionality" (dark, dense).
|
||||||
|
|
||||||
|
### Border Radius Scale
|
||||||
|
|
||||||
|
| Value | Context |
|
||||||
|
|-------|---------|
|
||||||
|
| 1px | Subtle edge softening, fine UI elements |
|
||||||
|
| 2px | Close buttons, micro-interactive elements |
|
||||||
|
| 6px | Primary buttons (non-pill), small cards |
|
||||||
|
| 8px | Form inputs, data cards, image containers |
|
||||||
|
| 10px | Navigation pills, tag containers |
|
||||||
|
| 12px | Content cards, feature containers |
|
||||||
|
| 24px | Video containers, hero imagery, large cards |
|
||||||
|
| 50px | Pill buttons (CTA), search inputs, full-round elements |
|
||||||
|
|
||||||
|
## 6. Depth & Elevation
|
||||||
|
|
||||||
|
| Level | Treatment | Use |
|
||||||
|
|-------|-----------|-----|
|
||||||
|
| Flat | No shadow, solid background | Default for inline elements |
|
||||||
|
| Subtle | `rgba(32, 32, 37, 0.05) 0px 3px 5px` | Content cards, resting state |
|
||||||
|
| Medium | `rgba(8, 8, 8, 0.05) 0px 3px 5px 5px` | Hovered cards, elevated containers |
|
||||||
|
| Pill Shadow | `rgb(153,153,153) 0px 2px 10px -3px` | Pill CTA buttons, floating actions |
|
||||||
|
| Heavy | `rgba(0,0,0) 0px 32px 37px` | Modal overlays, dropdown menus |
|
||||||
|
|
||||||
|
Binance.US uses a whisper-light shadow system. Card shadows are barely perceptible at 5% opacity — they exist not for dramatic depth but as subtle ground cues that keep cards from feeling pasted onto the surface. The pill button shadow is the exception: slightly more visible to give CTAs a "floating" quality that invites clicks. The philosophy is pragmatic — in a financial context, heavy shadows feel frivolous, while no shadows at all feel flat and untrustworthy. The 5% sweet spot communicates professionalism.
|
||||||
|
|
||||||
|
### Decorative Depth
|
||||||
|
|
||||||
|
- **Golden gradient backgrounds**: Behind device mockup sections, radial golden glow centered on the product
|
||||||
|
- **Dark-to-light section transitions**: Hard cut (no gradient blend) between white and `#222126` sections
|
||||||
|
- **Price ticker strip**: Flat, borderless, reads as a data bar rather than a decorative element
|
||||||
|
|
||||||
|
## 7. Do's and Don'ts
|
||||||
|
|
||||||
|
### Do
|
||||||
|
|
||||||
|
- Use Binance Yellow (`#F0B90B`) exclusively for primary CTAs and brand accents — it's the single point of color
|
||||||
|
- Keep light and dark sections strictly alternating for visual rhythm
|
||||||
|
- Use BinancePlex at weight 500+ for all interactive elements — this is a confidence-forward design
|
||||||
|
- Apply 50px radius to all primary CTA pill buttons — the signature interactive shape
|
||||||
|
- Maintain 12px radius on content cards for a polished but not overly rounded feel
|
||||||
|
- Show real-time data prominently (prices, percentages, stats) — numbers build trust
|
||||||
|
- Use Slate (`#848E9C`) for all secondary/metadata text — the universal quiet voice
|
||||||
|
- Keep shadows at 5% opacity or less — barely there but present
|
||||||
|
|
||||||
|
### Don't
|
||||||
|
|
||||||
|
- Don't introduce additional brand colors — Binance Yellow is the only accent; all other color is data-driven (green up, red down)
|
||||||
|
- Don't use rounded corners above 12px on content cards — only CTAs and video containers go higher
|
||||||
|
- Don't add heavy shadows or hover lift effects — this is a restrained financial platform
|
||||||
|
- Don't use BinancePlex below weight 500 for headings — lighter weights undermine authority
|
||||||
|
- Don't place yellow text on yellow backgrounds — always ensure high contrast pairing
|
||||||
|
- Don't mix pill (50px) and square (6px) button styles in the same row
|
||||||
|
- Don't soften the dark sections — `#222126` should feel authoritative, not grey
|
||||||
|
- Don't use decorative illustrations — imagery should be product screenshots or data visualizations
|
||||||
|
- Don't add animation beyond subtle transitions (200ms ease) — financial platforms need stability
|
||||||
|
- Don't use colored backgrounds for semantic states in cards — keep cards white or dark, use text color for semantic meaning
|
||||||
|
|
||||||
|
## 8. Responsive Behavior
|
||||||
|
|
||||||
|
### Breakpoints
|
||||||
|
|
||||||
|
| Name | Width | Key Changes |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| Mobile | <425px | Single column, stacked hero, hamburger nav, 16px padding |
|
||||||
|
| Small Mobile | 425-599px | Wider mobile layout, price ticker wraps |
|
||||||
|
| Tablet Small | 600-768px | 2-column feature grid begins |
|
||||||
|
| Tablet | 769-896px | Hero side-by-side layout begins |
|
||||||
|
| Desktop Small | 897-1024px | Full nav expands, 3-column features |
|
||||||
|
| Desktop | 1024-1280px | Full layout, max content width |
|
||||||
|
| Large Desktop | 1280-1440px | Increased margins, centered container |
|
||||||
|
| XL Desktop | >1440px | Max-width container (1200px) with expanded margins |
|
||||||
|
|
||||||
|
### Touch Targets
|
||||||
|
|
||||||
|
- Minimum touch target: 44x44px (WCAG AAA)
|
||||||
|
- Pill CTA buttons: 48px height minimum
|
||||||
|
- Nav links: 44px touch area
|
||||||
|
- Crypto ticker items: full-width tappable rows on mobile
|
||||||
|
- App download buttons: large tap zones (50px+)
|
||||||
|
|
||||||
|
### Collapsing Strategy
|
||||||
|
|
||||||
|
- **Navigation**: Full horizontal links → hamburger menu below 897px; logo and "Get Started" CTA remain visible
|
||||||
|
- **Hero section**: Side-by-side (text left, image right) → stacked (text top, image below) at 768px
|
||||||
|
- **Feature grid**: 3-col → 2-col at 768px → 1-col at 600px
|
||||||
|
- **Price ticker**: Horizontal row → wrapping or scrollable at 600px
|
||||||
|
- **Section padding**: 64px → 48px → 32px → 16px as viewport narrows
|
||||||
|
- **Device mockups**: Scale down proportionally, maintain centered positioning
|
||||||
|
- **Footer**: Multi-column → stacked accordion sections on mobile
|
||||||
|
|
||||||
|
### Image Behavior
|
||||||
|
|
||||||
|
- Device mockups: CSS-scaled with max-width constraints, maintain aspect ratio
|
||||||
|
- Hero imagery: contained within rounded containers (24px), scale proportionally
|
||||||
|
- App screenshots: responsive width with fixed aspect ratio
|
||||||
|
- QR code: fixed 120px square, hidden on mobile (replaced with direct app store links)
|
||||||
|
|
||||||
|
## 9. Agent Prompt Guide
|
||||||
|
|
||||||
|
### Quick Color Reference
|
||||||
|
|
||||||
|
- Primary CTA: Binance Yellow (`#F0B90B`)
|
||||||
|
- Secondary CTA: Binance Gold (`#FFD000`)
|
||||||
|
- Background Light: Pure White (`#FFFFFF`)
|
||||||
|
- Background Dark: Binance Dark (`#222126`)
|
||||||
|
- Heading text: Ink (`#1E2026`)
|
||||||
|
- Body text: Slate (`#848E9C`)
|
||||||
|
- Border: Border Light (`#E6E8EA`)
|
||||||
|
- Positive: Crypto Green (`#0ECB81`)
|
||||||
|
- Negative: Crypto Red (`#F6465D`)
|
||||||
|
|
||||||
|
### Example Component Prompts
|
||||||
|
|
||||||
|
- "Create a hero section with white background, a 60px/700 bold headline in Ink (#1E2026), a 20px/500 subtitle in Slate (#848E9C), and a Binance Yellow (#F0B90B) pill button (50px radius) with dark text (#1E2026)"
|
||||||
|
- "Design a crypto price ticker strip showing BTC, BNB, SOL prices in 14px/600 Ink (#1E2026) with green (#0ECB81) or red (#F6465D) percentage changes, on a white background with #E6E8EA bottom border"
|
||||||
|
- "Build a feature card grid (3-column, 24px gap) with 12px radius white cards, subtle shadow (rgba(32,32,37,0.05) 0px 3px 5px), each containing a yellow (#F0B90B) icon, 20px/600 heading, and 14px/500 #848E9C description"
|
||||||
|
- "Create a dark section (#222126) with a 34px/700 white headline centered, and a 3-column feature grid using dark cards (#2B2F36) with 12px radius and yellow (#F0B90B) accent icons"
|
||||||
|
- "Design a sticky navigation bar with white background, Binance logo left, 14px/600 #32313A nav links center, and a yellow (#F0B90B) pill button (50px radius, 6px padding 32px) labeled 'Get Started' right"
|
||||||
|
|
||||||
|
### Iteration Guide
|
||||||
|
|
||||||
|
When refining existing screens generated with this design system:
|
||||||
|
1. Focus on ONE component at a time
|
||||||
|
2. Reference specific color names and hex codes from this document
|
||||||
|
3. Remember: Binance Yellow (#F0B90B) is the ONLY accent color — everything else is grey/dark/white
|
||||||
|
4. Use the dark/light section alternation for visual pacing
|
||||||
|
5. Numbers and data should be prominent — this is a financial platform
|
||||||
|
6. Pill buttons (50px radius) for CTAs, regular buttons (6px radius) for form actions
|
||||||
|
7. Keep shadows almost invisible (5% opacity) — trust comes from clarity, not depth
|
||||||
|
8. BinancePlex at 600+ weight for any text that needs to feel authoritative
|
||||||
@ -0,0 +1,289 @@
|
|||||||
|
# A股智投 - 技术规格文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 组件清单
|
||||||
|
|
||||||
|
### shadcn/ui 组件
|
||||||
|
| 组件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| Card | 数据卡片容器 |
|
||||||
|
| Button | 各类按钮 |
|
||||||
|
| Input | 搜索输入框 |
|
||||||
|
| Select | 数据源选择器 |
|
||||||
|
| Tabs | 页面标签切换 |
|
||||||
|
| Table | 股票列表展示 |
|
||||||
|
| Badge | 状态标签 |
|
||||||
|
| Switch | 实时订阅开关 |
|
||||||
|
| Dialog | 确认弹窗 |
|
||||||
|
| Tooltip | 悬浮提示 |
|
||||||
|
| Separator | 分割线 |
|
||||||
|
| Skeleton | 加载占位 |
|
||||||
|
|
||||||
|
### 第三方组件
|
||||||
|
| 组件 | 来源 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| Recharts | npm | 图表绘制 |
|
||||||
|
| react-tsparticles | npm | 背景粒子效果 |
|
||||||
|
|
||||||
|
### 自定义组件
|
||||||
|
| 组件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| GaugeChart | 情绪指标仪表盘 |
|
||||||
|
| Treemap | 版块热力图 |
|
||||||
|
| AnimatedNumber | 数字滚动动画 |
|
||||||
|
| StockCard | 股票信息卡片 |
|
||||||
|
| SentimentMeter | 情绪指示器 |
|
||||||
|
| PriceChangeBar | 涨跌幅分布柱状图 |
|
||||||
|
| MomentumList | 动量排名列表 |
|
||||||
|
| NewsFeed | 新闻资讯流 |
|
||||||
|
| DataSourceSelector | 数据源选择器 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 动画实现方案
|
||||||
|
|
||||||
|
| 动画 | 库 | 实现方式 | 复杂度 |
|
||||||
|
|------|------|----------|--------|
|
||||||
|
| 页面加载淡入 | Framer Motion | AnimatePresence + motion.div | 中 |
|
||||||
|
| 卡片依次入场 | Framer Motion | staggerChildren + variants | 中 |
|
||||||
|
| 数字滚动 | 自定义Hook | useCountUp + requestAnimationFrame | 中 |
|
||||||
|
| 仪表盘指针 | Framer Motion | animate + rotate | 中 |
|
||||||
|
| 图表数据更新 | Recharts | animationDuration | 低 |
|
||||||
|
| 表格行悬浮 | CSS/Tailwind | hover:bg-white/5 | 低 |
|
||||||
|
| 卡片悬浮 | CSS/Tailwind | hover:border-white/20 | 低 |
|
||||||
|
| 标签页切换 | Framer Motion | layoutId + motion.div | 中 |
|
||||||
|
| 实时数据脉冲 | CSS | animate-pulse | 低 |
|
||||||
|
| 背景粒子 | react-tsparticles | 配置粒子参数 | 高 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 项目文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── ui/ # shadcn/ui 组件
|
||||||
|
│ │ ├── charts/ # 图表组件
|
||||||
|
│ │ │ ├── GaugeChart.tsx
|
||||||
|
│ │ │ ├── Treemap.tsx
|
||||||
|
│ │ │ ├── PriceDistribution.tsx
|
||||||
|
│ │ │ └── SentimentTrend.tsx
|
||||||
|
│ │ ├── common/ # 通用组件
|
||||||
|
│ │ │ ├── AnimatedNumber.tsx
|
||||||
|
│ │ │ ├── StockCard.tsx
|
||||||
|
│ │ │ ├── Header.tsx
|
||||||
|
│ │ │ └── Footer.tsx
|
||||||
|
│ │ └── sections/ # 页面区块
|
||||||
|
│ │ ├── MarketOverview.tsx
|
||||||
|
│ │ ├── SentimentIndicator.tsx
|
||||||
|
│ │ ├── MomentumAnalysis.tsx
|
||||||
|
│ │ ├── HighLowStocks.tsx
|
||||||
|
│ │ ├── PriceChangeDistribution.tsx
|
||||||
|
│ │ ├── StockAnalysis.tsx
|
||||||
|
│ │ ├── SentimentAnalysis.tsx
|
||||||
|
│ │ └── DataSourcePanel.tsx
|
||||||
|
│ ├── hooks/ # 自定义Hooks
|
||||||
|
│ │ ├── useCountUp.ts
|
||||||
|
│ │ ├── useRealtime.ts
|
||||||
|
│ │ ├── useStockData.ts
|
||||||
|
│ │ └── useSentiment.ts
|
||||||
|
│ ├── lib/ # 工具函数
|
||||||
|
│ │ ├── utils.ts
|
||||||
|
│ │ ├── mockData.ts
|
||||||
|
│ │ └── formatters.ts
|
||||||
|
│ ├── types/ # TypeScript类型
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ ├── pages/ # 页面
|
||||||
|
│ │ ├── Dashboard.tsx # 大盘分析首页
|
||||||
|
│ │ └── StockDetail.tsx # 个股分析页
|
||||||
|
│ ├── App.tsx
|
||||||
|
│ ├── main.tsx
|
||||||
|
│ └── index.css
|
||||||
|
├── public/
|
||||||
|
├── index.html
|
||||||
|
├── tailwind.config.js
|
||||||
|
├── vite.config.ts
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 依赖清单
|
||||||
|
|
||||||
|
### 核心依赖
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.0",
|
||||||
|
"framer-motion": "^10.16.0",
|
||||||
|
"recharts": "^2.10.0",
|
||||||
|
"lucide-react": "^0.294.0",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"tailwind-merge": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发依赖
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"postcss": "^8.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 数据结构
|
||||||
|
|
||||||
|
### 股票基本信息
|
||||||
|
```typescript
|
||||||
|
interface Stock {
|
||||||
|
code: string; // 股票代码
|
||||||
|
name: string; // 股票名称
|
||||||
|
price: number; // 当前价格
|
||||||
|
change: number; // 涨跌额
|
||||||
|
changePercent: number; // 涨跌幅%
|
||||||
|
volume: number; // 成交量
|
||||||
|
marketCap: number; // 市值
|
||||||
|
industry: string; // 所属行业
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 市场概况
|
||||||
|
```typescript
|
||||||
|
interface MarketOverview {
|
||||||
|
upCount: number; // 上涨家数
|
||||||
|
downCount: number; // 下跌家数
|
||||||
|
flatCount: number; // 平盘家数
|
||||||
|
limitUpCount: number; // 涨停家数
|
||||||
|
limitDownCount: number; // 跌停家数
|
||||||
|
updateTime: string; // 更新时间
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 情绪指标
|
||||||
|
```typescript
|
||||||
|
interface SentimentData {
|
||||||
|
value: number; // 0-100
|
||||||
|
label: string; // 状态标签
|
||||||
|
description: string; // 描述
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 动量数据
|
||||||
|
```typescript
|
||||||
|
interface MomentumData {
|
||||||
|
sector: string; // 版块名称
|
||||||
|
changePercent: number; // 涨跌幅
|
||||||
|
momentum: number; // 动量值
|
||||||
|
stocks: Stock[]; // 包含股票
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI分析结果
|
||||||
|
```typescript
|
||||||
|
interface AIAnalysis {
|
||||||
|
stockCode: string;
|
||||||
|
insights: string; // 关键洞察
|
||||||
|
recommendation: 'buy' | 'sell' | 'hold';
|
||||||
|
trend: 'up' | 'down' | 'sideways';
|
||||||
|
targetPrice: {
|
||||||
|
idealBuy: number;
|
||||||
|
secondBuy: number;
|
||||||
|
stopLoss: number;
|
||||||
|
takeProfit: number;
|
||||||
|
};
|
||||||
|
confidence: number; // 置信度
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 实时订阅设计
|
||||||
|
|
||||||
|
### WebSocket连接
|
||||||
|
- 连接地址: `wss://api.example.com/realtime`
|
||||||
|
- 心跳间隔: 30秒
|
||||||
|
- 重连策略: 指数退避
|
||||||
|
|
||||||
|
### 订阅主题
|
||||||
|
```typescript
|
||||||
|
enum SubscribeTopic {
|
||||||
|
MARKET_OVERVIEW = 'market_overview',
|
||||||
|
STOCK_PRICE = 'stock_price',
|
||||||
|
SENTIMENT = 'sentiment',
|
||||||
|
MOMENTUM = 'momentum',
|
||||||
|
NEWS = 'news'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 消息格式
|
||||||
|
```typescript
|
||||||
|
interface WSMessage {
|
||||||
|
topic: SubscribeTopic;
|
||||||
|
data: any;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 多数据源设计
|
||||||
|
|
||||||
|
### 数据源接口
|
||||||
|
```typescript
|
||||||
|
interface DataSource {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
isAvailable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataSources: DataSource[] = [
|
||||||
|
{ id: 'eastmoney', name: '东方财富', icon: 'eastmoney', isAvailable: true },
|
||||||
|
{ id: 'ths', name: '同花顺', icon: 'ths', isAvailable: true },
|
||||||
|
{ id: 'xueqiu', name: '雪球', icon: 'xueqiu', isAvailable: true },
|
||||||
|
{ id: 'tencent', name: '腾讯财经', icon: 'tencent', isAvailable: true },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据适配器模式
|
||||||
|
```typescript
|
||||||
|
interface DataAdapter {
|
||||||
|
fetchMarketOverview(): Promise<MarketOverview>;
|
||||||
|
fetchStockDetail(code: string): Promise<Stock>;
|
||||||
|
fetchMomentumData(): Promise<MomentumData[]>;
|
||||||
|
fetchSentiment(): Promise<SentimentData>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 性能优化
|
||||||
|
|
||||||
|
### 策略
|
||||||
|
1. **虚拟滚动**: 长列表使用虚拟滚动
|
||||||
|
2. **数据缓存**: React Query缓存请求结果
|
||||||
|
3. **防抖节流**: 搜索输入防抖,滚动事件节流
|
||||||
|
4. **懒加载**: 图表组件懒加载
|
||||||
|
5. **代码分割**: 路由级别代码分割
|
||||||
|
|
||||||
|
### 指标
|
||||||
|
- 首屏加载: < 2s
|
||||||
|
- 交互响应: < 100ms
|
||||||
|
- 动画帧率: 60fps
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "postcss.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>A股智投</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
Using Node.js 20, Tailwind CSS v3.4.19, and Vite v7.2.4
|
||||||
|
|
||||||
|
Tailwind CSS has been set up with the shadcn theme
|
||||||
|
|
||||||
|
Setup complete: /mnt/okcomputer/output/app
|
||||||
|
|
||||||
|
Components (40+):
|
||||||
|
accordion, alert-dialog, alert, aspect-ratio, avatar, badge, breadcrumb,
|
||||||
|
button-group, button, calendar, card, carousel, chart, checkbox, collapsible,
|
||||||
|
command, context-menu, dialog, drawer, dropdown-menu, empty, field, form,
|
||||||
|
hover-card, input-group, input-otp, input, item, kbd, label, menubar,
|
||||||
|
navigation-menu, pagination, popover, progress, radio-group, resizable,
|
||||||
|
scroll-area, select, separator, sheet, sidebar, skeleton, slider, sonner,
|
||||||
|
spinner, switch, table, tabs, textarea, toggle-group, toggle, tooltip
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
src/sections/ Page sections
|
||||||
|
src/hooks/ Custom hooks
|
||||||
|
src/types/ Type definitions
|
||||||
|
src/App.css Styles specific to the Webapp
|
||||||
|
src/App.tsx Root React component
|
||||||
|
src/index.css Global styles
|
||||||
|
src/main.tsx Entry point for rendering the Webapp
|
||||||
|
index.html Entry point for the Webapp
|
||||||
|
tailwind.config.js Configures Tailwind's theme, plugins, etc.
|
||||||
|
vite.config.ts Main build and dev server settings for Vite
|
||||||
|
postcss.config.js Config file for CSS post-processing tools
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"name": "my-app",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-menubar": "^1.1.16",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-day-picker": "^9.13.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.70.0",
|
||||||
|
"react-resizable-panels": "^4.2.2",
|
||||||
|
"recharts": "^2.15.4",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
|
"zod": "^4.3.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"kimi-plugin-inspect-react": "^1.0.3",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
@ -0,0 +1,172 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { Header } from '@/components/common/Header';
|
||||||
|
import { Footer } from '@/components/common/Footer';
|
||||||
|
import { Dashboard } from '@/pages/Dashboard';
|
||||||
|
import { StockDetail } from '@/pages/StockDetail';
|
||||||
|
import { Settings } from '@/pages/Settings';
|
||||||
|
import { StockDetailModal } from '@/components/modals/StockDetailModal';
|
||||||
|
import { SectorDetailModal } from '@/components/modals/SectorDetailModal';
|
||||||
|
import { useRealtime } from '@/hooks/useRealtime';
|
||||||
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
import { formatDateTime } from '@/lib/formatters';
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
import type { Stock, MomentumData } from '@/types';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [currentPage, setCurrentPage] = useState('dashboard');
|
||||||
|
const [isRealtimeEnabled, setIsRealtimeEnabled] = useState(false);
|
||||||
|
const [currentDataSource, setCurrentDataSource] = useState('eastmoney');
|
||||||
|
|
||||||
|
const [selectedStock, setSelectedStock] = useState<Stock | null>(null);
|
||||||
|
const [selectedSector, setSelectedSector] = useState<MomentumData | null>(null);
|
||||||
|
const [isStockModalOpen, setIsStockModalOpen] = useState(false);
|
||||||
|
const [isSectorModalOpen, setIsSectorModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
const { isConnected, isConnecting } = useRealtime({
|
||||||
|
enabled: isRealtimeEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectionStatus: 'connected' | 'disconnected' | 'connecting' =
|
||||||
|
isConnecting ? 'connecting' : isConnected ? 'connected' : 'disconnected';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
|
const handlePageChange = (page: string) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRealtimeToggle = (enabled: boolean) => {
|
||||||
|
setIsRealtimeEnabled(enabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDataSourceChange = (sourceId: string) => {
|
||||||
|
setCurrentDataSource(sourceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStockClick = (stock: Stock) => {
|
||||||
|
setSelectedStock(stock);
|
||||||
|
setIsStockModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSectorClick = (sector: MomentumData) => {
|
||||||
|
setSelectedSector(sector);
|
||||||
|
setIsSectorModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPage = () => {
|
||||||
|
switch (currentPage) {
|
||||||
|
case 'dashboard':
|
||||||
|
case 'momentum':
|
||||||
|
case 'highlow':
|
||||||
|
case 'distribution':
|
||||||
|
return (
|
||||||
|
<Dashboard
|
||||||
|
onStockClick={handleStockClick}
|
||||||
|
onSectorClick={handleSectorClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'stock':
|
||||||
|
return <StockDetail />;
|
||||||
|
case 'settings':
|
||||||
|
return (
|
||||||
|
<Settings
|
||||||
|
isRealtimeEnabled={isRealtimeEnabled}
|
||||||
|
onRealtimeToggle={handleRealtimeToggle}
|
||||||
|
currentSource={currentDataSource}
|
||||||
|
onSourceChange={handleDataSourceChange}
|
||||||
|
connectionStatus={connectionStatus}
|
||||||
|
lastUpdateTime={formatDateTime(new Date().toISOString())}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Dashboard
|
||||||
|
onStockClick={handleStockClick}
|
||||||
|
onSectorClick={handleSectorClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageTitle = () => {
|
||||||
|
switch (currentPage) {
|
||||||
|
case 'dashboard': return '大盘概览';
|
||||||
|
case 'momentum': return '动量分析';
|
||||||
|
case 'highlow': return '新高新低';
|
||||||
|
case 'distribution': return '涨跌分布';
|
||||||
|
case 'stock': return '个股分析';
|
||||||
|
case 'settings': return '系统设置';
|
||||||
|
default: return '大盘概览';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageDescription = () => {
|
||||||
|
switch (currentPage) {
|
||||||
|
case 'dashboard': return '实时追踪A股市场整体表现';
|
||||||
|
case 'momentum': return '分析版块动量,把握市场热点';
|
||||||
|
case 'highlow': return '追踪创历史新高/新低的个股';
|
||||||
|
case 'distribution': return '查看市场涨跌分布情况';
|
||||||
|
case 'stock': return 'AI驱动的个股深度分析';
|
||||||
|
case 'settings': return '配置AI模型、数据源、通知和显示设置';
|
||||||
|
default: return '实时追踪A股市场整体表现';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bgClass = theme === 'dark' ? 'bg-black' : 'bg-white';
|
||||||
|
const textClass = theme === 'dark' ? 'text-white' : 'text-gray-900';
|
||||||
|
const textMutedClass = theme === 'dark' ? 'text-white/60' : 'text-gray-500';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`min-h-screen ${bgClass} ${textClass}`}>
|
||||||
|
<Header currentPage={currentPage} onPageChange={handlePageChange} />
|
||||||
|
|
||||||
|
<main className="pt-14 sm:pt-20 pb-4 sm:pb-8">
|
||||||
|
<div className="max-w-[1600px] mx-auto px-3 sm:px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
className="mb-4 sm:mb-8"
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<h1 className={`text-xl sm:text-2xl lg:text-3xl font-bold ${textClass}`}>{getPageTitle()}</h1>
|
||||||
|
<p className={`${textMutedClass} mt-1 sm:mt-2 text-sm sm:text-base`}>{getPageDescription()}</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={currentPage}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
{renderPage()}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
<Toaster />
|
||||||
|
|
||||||
|
<StockDetailModal
|
||||||
|
stock={selectedStock}
|
||||||
|
isOpen={isStockModalOpen}
|
||||||
|
onClose={() => setIsStockModalOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SectorDetailModal
|
||||||
|
sector={selectedSector}
|
||||||
|
isOpen={isSectorModalOpen}
|
||||||
|
onClose={() => setIsSectorModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@ -0,0 +1,181 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ComposedChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Line
|
||||||
|
} from 'recharts';
|
||||||
|
import type { KLineData } from '@/types';
|
||||||
|
|
||||||
|
interface KLineChartProps {
|
||||||
|
data: KLineData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简化的K线图 - 使用折线图模拟
|
||||||
|
export function KLineChart({ data }: KLineChartProps) {
|
||||||
|
const [timeRange, setTimeRange] = useState<'1D' | '5D' | '1M' | '3M' | '6M' | '1Y'>('1M');
|
||||||
|
|
||||||
|
// 处理数据,添加颜色标记
|
||||||
|
const chartData = data.map(item => ({
|
||||||
|
...item,
|
||||||
|
isUp: item.close >= item.open,
|
||||||
|
change: item.close - item.open,
|
||||||
|
changePercent: ((item.close - item.open) / item.open * 100).toFixed(2)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const d = payload[0].payload;
|
||||||
|
const isUp = d.close >= d.open;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#141414] border border-white/10 rounded-lg p-3 shadow-xl">
|
||||||
|
<div className="text-white/60 text-sm mb-2">{d.date}</div>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span className="text-white/60">开盘:</span>
|
||||||
|
<span className="text-white">{d.open.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span className="text-white/60">最高:</span>
|
||||||
|
<span className="text-white">{d.high.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span className="text-white/60">最低:</span>
|
||||||
|
<span className="text-white">{d.low.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span className="text-white/60">收盘:</span>
|
||||||
|
<span className={isUp ? 'text-red-400' : 'text-green-400'}>{d.close.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span className="text-white/60">涨跌:</span>
|
||||||
|
<span className={isUp ? 'text-red-400' : 'text-green-400'}>
|
||||||
|
{isUp ? '+' : ''}{d.changePercent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span className="text-white/60">成交量:</span>
|
||||||
|
<span className="text-white">{(d.volume / 10000).toFixed(0)}万</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算均线
|
||||||
|
const ma5 = chartData.map((_, index) => {
|
||||||
|
if (index < 4) return null;
|
||||||
|
const sum = chartData.slice(index - 4, index + 1).reduce((acc, d) => acc + d.close, 0);
|
||||||
|
return sum / 5;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ma10 = chartData.map((_, index) => {
|
||||||
|
if (index < 9) return null;
|
||||||
|
const sum = chartData.slice(index - 9, index + 1).reduce((acc, d) => acc + d.close, 0);
|
||||||
|
return sum / 10;
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartDataWithMA = chartData.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
ma5: ma5[index],
|
||||||
|
ma10: ma10[index]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const timeRanges: Array<'1D' | '5D' | '1M' | '3M' | '6M' | '1Y'> = ['1D', '5D', '1M', '3M', '6M', '1Y'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* 时间范围选择 */}
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
{timeRanges.map((range) => (
|
||||||
|
<button
|
||||||
|
key={range}
|
||||||
|
onClick={() => setTimeRange(range)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||||
|
timeRange === range
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-white/5 text-white/60 hover:bg-white/10 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{range}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* K线图 */}
|
||||||
|
<div className="h-[350px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart data={chartDataWithMA} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||||
|
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="rgba(255,255,255,0.3)"
|
||||||
|
tick={{ fill: 'rgba(255,255,255,0.5)', fontSize: 10 }}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(value) => value.slice(5)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<YAxis
|
||||||
|
domain={['auto', 'auto']}
|
||||||
|
stroke="rgba(255,255,255,0.3)"
|
||||||
|
tick={{ fill: 'rgba(255,255,255,0.5)', fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(value) => value.toFixed(2)}
|
||||||
|
orientation="right"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
|
||||||
|
{/* K线 - 使用Bar模拟 */}
|
||||||
|
<Bar
|
||||||
|
dataKey="close"
|
||||||
|
fill="#ef4444"
|
||||||
|
stroke="#ef4444"
|
||||||
|
barSize={8}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* MA5 */}
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="ma5"
|
||||||
|
stroke="#f97316"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* MA10 */}
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="ma10"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图例 */}
|
||||||
|
<div className="flex items-center justify-center gap-6 mt-4 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-0.5 bg-orange-500"></div>
|
||||||
|
<span className="text-white/50">MA5</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-0.5 bg-blue-500"></div>
|
||||||
|
<span className="text-white/50">MA10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import type { PriceDistribution } from '@/types';
|
||||||
|
|
||||||
|
interface PriceDistributionProps {
|
||||||
|
data: PriceDistribution[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceDistributionChart({ data }: PriceDistributionProps) {
|
||||||
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const maxCount = Math.max(...data.map(d => d.count));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||||
|
<div className="bg-[#0a0a0a] border border-white/10 rounded-xl p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-red-500">
|
||||||
|
{data.filter(d => d.range === '>10%')[0]?.count || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white/50 mt-1">涨停家数</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#0a0a0a] border border-white/10 rounded-xl p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{data.reduce((sum, d) => sum + d.count, 0)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white/50 mt-1">总家数</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#0a0a0a] border border-white/10 rounded-xl p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-500">
|
||||||
|
{data.filter(d => d.range === '<-10%')[0]?.count || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white/50 mt-1">跌停家数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 柱状图 */}
|
||||||
|
<div className="relative h-[300px] flex items-end justify-between gap-1 px-2">
|
||||||
|
{data.map((item, index) => {
|
||||||
|
const heightPercent = (item.count / maxCount) * 100;
|
||||||
|
const isHovered = hoveredIndex === index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex-1 flex flex-col items-center cursor-pointer group"
|
||||||
|
onMouseEnter={() => setHoveredIndex(index)}
|
||||||
|
onMouseLeave={() => setHoveredIndex(null)}
|
||||||
|
>
|
||||||
|
{/* 数值标签 */}
|
||||||
|
<motion.div
|
||||||
|
className="text-xs mb-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ color: item.isUp ? '#ef4444' : '#22c55e' }}
|
||||||
|
>
|
||||||
|
{item.count}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 柱形 */}
|
||||||
|
<motion.div
|
||||||
|
className="w-full rounded-t-sm transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.isUp ? '#ef4444' : '#22c55e',
|
||||||
|
opacity: isHovered ? 1 : 0.7
|
||||||
|
}}
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: `${Math.max(heightPercent, 2)}%` }}
|
||||||
|
transition={{ duration: 0.5, delay: index * 0.02 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* X轴标签 */}
|
||||||
|
<div className="text-[10px] text-white/40 mt-2 rotate-45 origin-left whitespace-nowrap">
|
||||||
|
{item.range}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 悬浮详情 */}
|
||||||
|
{hoveredIndex !== null && (
|
||||||
|
<motion.div
|
||||||
|
className="mt-4 bg-[#141414] border border-white/10 rounded-lg p-3"
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-white/60">区间:</span>
|
||||||
|
<span className="text-white font-medium">{data[hoveredIndex].range}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center mt-1">
|
||||||
|
<span className="text-white/60">股票数量:</span>
|
||||||
|
<span className={`font-bold ${data[hoveredIndex].isUp ? 'text-red-400' : 'text-green-400'}`}>
|
||||||
|
{data[hoveredIndex].count} 只
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,108 @@
|
|||||||
|
import {
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
ReferenceLine,
|
||||||
|
Area,
|
||||||
|
AreaChart
|
||||||
|
} from 'recharts';
|
||||||
|
import type { SentimentTrend } from '@/types';
|
||||||
|
|
||||||
|
interface SentimentTrendProps {
|
||||||
|
data: SentimentTrend[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SentimentTrendChart({ data }: SentimentTrendProps) {
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const value = payload[0].value;
|
||||||
|
let sentiment = '中性';
|
||||||
|
let color = '#f97316';
|
||||||
|
|
||||||
|
if (value <= 20) { sentiment = '极度恐惧'; color = '#dc2626'; }
|
||||||
|
else if (value <= 40) { sentiment = '恐惧'; color = '#ef4444'; }
|
||||||
|
else if (value <= 60) { sentiment = '中性'; color = '#f97316'; }
|
||||||
|
else if (value <= 80) { sentiment = '贪婪'; color = '#22c55e'; }
|
||||||
|
else { sentiment = '极度贪婪'; color = '#16a34a'; }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#141414] border border-white/10 rounded-lg p-3 shadow-xl">
|
||||||
|
<p className="text-white/60 text-sm">{label}</p>
|
||||||
|
<p className="text-white font-bold text-lg">{value}</p>
|
||||||
|
<p style={{ color }} className="text-sm">{sentiment}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sentimentGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#a855f7" stopOpacity={0.3}/>
|
||||||
|
<stop offset="95%" stopColor="#a855f7" stopOpacity={0}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||||
|
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="rgba(255,255,255,0.3)"
|
||||||
|
tick={{ fill: 'rgba(255,255,255,0.5)', fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 100]}
|
||||||
|
stroke="rgba(255,255,255,0.3)"
|
||||||
|
tick={{ fill: 'rgba(255,255,255,0.5)', fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(value) => `${value}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
|
||||||
|
{/* 参考线 */}
|
||||||
|
<ReferenceLine y={20} stroke="#dc2626" strokeDasharray="3 3" opacity={0.5} />
|
||||||
|
<ReferenceLine y={40} stroke="#ef4444" strokeDasharray="3 3" opacity={0.5} />
|
||||||
|
<ReferenceLine y={60} stroke="#22c55e" strokeDasharray="3 3" opacity={0.5} />
|
||||||
|
<ReferenceLine y={80} stroke="#16a34a" strokeDasharray="3 3" opacity={0.5} />
|
||||||
|
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke="#a855f7"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#sentimentGradient)"
|
||||||
|
animationDuration={1000}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
{/* 图例 */}
|
||||||
|
<div className="flex justify-center gap-6 mt-4 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-red-600"></div>
|
||||||
|
<span className="text-white/50">极度恐惧</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-orange-500"></div>
|
||||||
|
<span className="text-white/50">中性</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-600"></div>
|
||||||
|
<span className="text-white/50">极度贪婪</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,134 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import type { MomentumData } from '@/types';
|
||||||
|
|
||||||
|
interface TreemapProps {
|
||||||
|
data: MomentumData[];
|
||||||
|
onSectorClick?: (sector: MomentumData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Treemap({ data, onSectorClick }: TreemapProps) {
|
||||||
|
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 计算总面积和每个项目的面积比例
|
||||||
|
const totalMomentum = data.reduce((sum, item) => sum + Math.abs(item.momentum), 0);
|
||||||
|
|
||||||
|
// 根据涨跌幅获取颜色
|
||||||
|
const getColor = (changePercent: number) => {
|
||||||
|
if (changePercent >= 5) return '#dc2626';
|
||||||
|
if (changePercent >= 3) return '#ef4444';
|
||||||
|
if (changePercent >= 1) return '#f87171';
|
||||||
|
if (changePercent >= 0) return '#fca5a5';
|
||||||
|
if (changePercent >= -1) return '#86efac';
|
||||||
|
if (changePercent >= -3) return '#4ade80';
|
||||||
|
if (changePercent >= -5) return '#22c55e';
|
||||||
|
return '#16a34a';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简单的矩形布局算法
|
||||||
|
const layoutItems = () => {
|
||||||
|
const items: Array<{
|
||||||
|
name: string;
|
||||||
|
changePercent: number;
|
||||||
|
momentum: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
color: string;
|
||||||
|
data: MomentumData;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
let currentX = 0;
|
||||||
|
let currentY = 0;
|
||||||
|
let rowHeight = 0;
|
||||||
|
const containerWidth = 100; // 百分比
|
||||||
|
|
||||||
|
data.forEach((item, index) => {
|
||||||
|
const areaRatio = Math.abs(item.momentum) / totalMomentum;
|
||||||
|
const width = Math.max(areaRatio * containerWidth * 3, 15);
|
||||||
|
const height = Math.max(areaRatio * 200, 40);
|
||||||
|
|
||||||
|
if (currentX + width > containerWidth && index > 0) {
|
||||||
|
currentX = 0;
|
||||||
|
currentY += rowHeight + 2;
|
||||||
|
rowHeight = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
name: item.sector,
|
||||||
|
changePercent: item.changePercent,
|
||||||
|
momentum: item.momentum,
|
||||||
|
x: currentX,
|
||||||
|
y: currentY,
|
||||||
|
width: Math.min(width, containerWidth - currentX),
|
||||||
|
height,
|
||||||
|
color: getColor(item.changePercent),
|
||||||
|
data: item
|
||||||
|
});
|
||||||
|
|
||||||
|
currentX += width + 2;
|
||||||
|
rowHeight = Math.max(rowHeight, height);
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = layoutItems();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-[280px] bg-[#0a0a0a] rounded-xl overflow-hidden">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.name}
|
||||||
|
className="absolute cursor-pointer transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
left: `${item.x}%`,
|
||||||
|
top: `${item.y}%`,
|
||||||
|
width: `${item.width}%`,
|
||||||
|
height: `${item.height}%`,
|
||||||
|
backgroundColor: item.color,
|
||||||
|
opacity: hoveredItem === item.name ? 1 : hoveredItem ? 0.6 : 0.85
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: hoveredItem === item.name ? 1 : hoveredItem ? 0.6 : 0.85, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||||
|
onMouseEnter={() => setHoveredItem(item.name)}
|
||||||
|
onMouseLeave={() => setHoveredItem(null)}
|
||||||
|
onClick={() => onSectorClick?.(item.data)}
|
||||||
|
>
|
||||||
|
<div className="p-2 h-full flex flex-col justify-between">
|
||||||
|
<span className="text-xs font-medium text-white truncate">{item.name}</span>
|
||||||
|
<span className={`text-xs font-bold ${item.changePercent >= 0 ? 'text-white' : 'text-white'}`}>
|
||||||
|
{item.changePercent >= 0 ? '+' : ''}{item.changePercent.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 悬浮提示 */}
|
||||||
|
{hoveredItem && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute bottom-4 left-4 bg-[#141414] border border-white/10 rounded-lg p-3 z-10"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const item = data.find(d => d.sector === hoveredItem);
|
||||||
|
if (!item) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="font-medium text-white">{item.sector}</div>
|
||||||
|
<div className={`text-sm ${item.changePercent >= 0 ? 'text-red-400' : 'text-green-400'}`}>
|
||||||
|
涨跌幅: {item.changePercent >= 0 ? '+' : ''}{item.changePercent.toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-white/60">动量值: {item.momentum.toFixed(1)}</div>
|
||||||
|
<div className="text-xs text-white/40 mt-1">点击查看详情</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import { useCountUp } from '@/hooks/useCountUp';
|
||||||
|
|
||||||
|
interface AnimatedNumberProps {
|
||||||
|
value: number;
|
||||||
|
duration?: number;
|
||||||
|
decimals?: number;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnimatedNumber({
|
||||||
|
value,
|
||||||
|
duration = 1000,
|
||||||
|
decimals = 0,
|
||||||
|
prefix = '',
|
||||||
|
suffix = '',
|
||||||
|
className = '',
|
||||||
|
style
|
||||||
|
}: AnimatedNumberProps) {
|
||||||
|
const animatedValue = useCountUp({
|
||||||
|
end: value,
|
||||||
|
duration,
|
||||||
|
decimals
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={className} style={style}>
|
||||||
|
{prefix}{animatedValue}{suffix}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,257 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { TrendingUp, Search, Clock, Bell, User, Settings, Sun, Moon, Menu, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
currentPage: string;
|
||||||
|
onPageChange: (page: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ id: 'dashboard', label: '首页' },
|
||||||
|
{ id: 'momentum', label: '动量分析' },
|
||||||
|
{ id: 'highlow', label: '新高新低' },
|
||||||
|
{ id: 'distribution', label: '涨跌分布' },
|
||||||
|
{ id: 'stock', label: '个股分析' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Header({ currentPage, onPageChange }: HeaderProps) {
|
||||||
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCurrentTime(new Date());
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
setIsScrolled(window.scrollY > 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatTime = (date: Date) => {
|
||||||
|
return date.toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerBgClass = theme === 'dark'
|
||||||
|
? isScrolled ? 'bg-black/95 backdrop-blur-md shadow-lg' : 'bg-black'
|
||||||
|
: isScrolled ? 'bg-white/95 backdrop-blur-md shadow-lg' : 'bg-white';
|
||||||
|
|
||||||
|
const textColorClass = theme === 'dark' ? 'text-white' : 'text-gray-900';
|
||||||
|
const textMutedClass = theme === 'dark' ? 'text-white/60' : 'text-gray-500';
|
||||||
|
const hoverBgClass = theme === 'dark' ? 'hover:bg-white/10' : 'hover:bg-gray-100';
|
||||||
|
const cardBgClass = theme === 'dark' ? 'bg-[#0a0a0a]' : 'bg-gray-50';
|
||||||
|
const borderColorClass = theme === 'dark' ? 'border-white/10' : 'border-gray-200';
|
||||||
|
|
||||||
|
const handleNavClick = (page: string) => {
|
||||||
|
onPageChange(page);
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.header
|
||||||
|
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${headerBgClass}`}
|
||||||
|
initial={{ y: -100 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="max-w-[1600px] mx-auto px-3 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-14 sm:h-16">
|
||||||
|
<motion.div
|
||||||
|
className="flex items-center gap-2 sm:gap-3 cursor-pointer"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
onClick={() => handleNavClick('dashboard')}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg sm:rounded-xl bg-gradient-to-br from-orange-500 to-red-600 flex items-center justify-center">
|
||||||
|
<TrendingUp className="w-5 h-5 sm:w-6 sm:h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className={`text-lg sm:text-xl font-bold ${textColorClass}`}>A股智投</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<nav className="hidden lg:flex items-center gap-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleNavClick(item.id)}
|
||||||
|
className={`relative px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
currentPage === item.id
|
||||||
|
? textColorClass
|
||||||
|
: `${textMutedClass} hover:${textColorClass.replace('text-', 'text-').split(' ')[0]}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
{currentPage === item.id && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute bottom-0 left-0 right-0 h-0.5 bg-red-500"
|
||||||
|
layoutId="navIndicator"
|
||||||
|
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 sm:gap-3">
|
||||||
|
<Dialog open={searchOpen} onOpenChange={setSearchOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`w-8 h-8 sm:w-10 sm:h-10 ${textMutedClass} hover:${textColorClass} ${hoverBgClass}`}
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className={`${cardBgClass} ${borderColorClass} w-[90vw] max-w-md`}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className={textColorClass}>搜索股票</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<Input
|
||||||
|
placeholder="输入股票代码或名称..."
|
||||||
|
className={theme === 'dark' ? 'bg-[#141414] border-white/10 text-white placeholder:text-white/40' : 'bg-white border-gray-200 text-gray-900 placeholder:text-gray-400'}
|
||||||
|
/>
|
||||||
|
<Button className="bg-red-500 hover:bg-red-600">搜索</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<div className={`hidden md:flex items-center gap-2 ${textMutedClass} text-sm`}>
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className={`w-8 h-8 sm:w-10 sm:h-10 ${textMutedClass} hover:${textColorClass} ${hoverBgClass}`}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? <Sun className="w-4 h-4 sm:w-5 sm:h-5" /> : <Moon className="w-4 h-4 sm:w-5 sm:h-5" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleNavClick('settings')}
|
||||||
|
className={`w-8 h-8 sm:w-10 sm:h-10 ${textMutedClass} hover:${textColorClass} ${hoverBgClass} ${
|
||||||
|
currentPage === 'settings' ? `${textColorClass} ${theme === 'dark' ? 'bg-white/10' : 'bg-gray-100'}` : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`hidden sm:flex w-8 h-8 sm:w-10 sm:h-10 ${textMutedClass} hover:${textColorClass} ${hoverBgClass} relative`}
|
||||||
|
>
|
||||||
|
<Bell className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
|
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`hidden md:flex w-8 h-8 sm:w-10 sm:h-10 ${textMutedClass} hover:${textColorClass} ${hoverBgClass}`}
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="hidden lg:flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={`${textMutedClass} hover:${textColorClass} ${hoverBgClass}`}
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white text-sm px-3">
|
||||||
|
注册
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
className={`lg:hidden w-8 h-8 sm:w-10 sm:h-10 ${textMutedClass} hover:${textColorClass} ${hoverBgClass}`}
|
||||||
|
>
|
||||||
|
{mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<motion.div
|
||||||
|
className={`lg:hidden ${headerBgClass} ${borderColorClass} border-t`}
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
>
|
||||||
|
<div className="max-w-[1600px] mx-auto px-3 sm:px-6 py-3">
|
||||||
|
<nav className="flex flex-col gap-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleNavClick(item.id)}
|
||||||
|
className={`px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
currentPage === item.id
|
||||||
|
? `${textColorClass} ${theme === 'dark' ? 'bg-white/10' : 'bg-gray-100'}`
|
||||||
|
: `${textMutedClass} hover:${textColorClass} ${hoverBgClass}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className={`mt-3 pt-3 ${borderColorClass} border-t flex flex-col gap-2`}>
|
||||||
|
<div className={`flex items-center gap-2 px-4 py-2 ${textMutedClass} text-sm`}>
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 px-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={`flex-1 ${textMutedClass} hover:${textColorClass} ${hoverBgClass}`}
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white">
|
||||||
|
注册
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
|
import type { Stock } from '@/types';
|
||||||
|
import { formatPrice, formatChange, formatVolume, formatMarketCap } from '@/lib/formatters';
|
||||||
|
|
||||||
|
interface StockCardProps {
|
||||||
|
stock: Stock;
|
||||||
|
index?: number;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StockCard({ stock, index = 0, onClick }: StockCardProps) {
|
||||||
|
const changeInfo = formatChange(stock.changePercent);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="bg-[#0a0a0a] border border-white/10 rounded-xl p-4 cursor-pointer hover:border-white/20 transition-colors"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||||
|
onClick={onClick}
|
||||||
|
whileHover={{ scale: 1.01 }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white font-medium">{stock.name}</span>
|
||||||
|
<span className="text-white/40 text-sm">{stock.code}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-white/60 text-xs mt-1">{stock.industry}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xl font-bold text-white">
|
||||||
|
{formatPrice(stock.price)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-end gap-1 text-sm"
|
||||||
|
style={{ color: changeInfo.color }}
|
||||||
|
>
|
||||||
|
{stock.changePercent >= 0 ? (
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span>{changeInfo.text}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 mt-4 pt-4 border-t border-white/5">
|
||||||
|
<div>
|
||||||
|
<div className="text-white/40 text-xs">成交量</div>
|
||||||
|
<div className="text-white text-sm">{formatVolume(stock.volume)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-white/40 text-xs">市值</div>
|
||||||
|
<div className="text-white text-sm">{formatMarketCap(stock.marketCap)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,426 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
X, TrendingUp, TrendingDown, BarChart3, Activity,
|
||||||
|
Layers, Clock, ChevronRight, Flame, Newspaper
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { KLineChart } from '@/components/charts/KLineChart';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import type { MomentumData, KLineData, Stock, NewsItem } from '@/types';
|
||||||
|
import { formatPrice, formatVolume } from '@/lib/formatters';
|
||||||
|
|
||||||
|
interface SectorDetailModalProps {
|
||||||
|
sector: MomentumData | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateMockSectorKLine = (days: number, baseChange: number): KLineData[] => {
|
||||||
|
const data: KLineData[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
let value = 100;
|
||||||
|
|
||||||
|
for (let i = days; i >= 0; i--) {
|
||||||
|
const date = new Date(now);
|
||||||
|
date.setDate(date.getDate() - i);
|
||||||
|
if (date.getDay() === 0 || date.getDay() === 6) continue;
|
||||||
|
|
||||||
|
const change = (Math.random() - 0.5) * 2 + baseChange / 10;
|
||||||
|
const open = value;
|
||||||
|
const close = value + change;
|
||||||
|
const high = Math.max(open, close) + Math.random() * 1;
|
||||||
|
const low = Math.min(open, close) - Math.random() * 1;
|
||||||
|
|
||||||
|
data.push({
|
||||||
|
date: date.toISOString().split('T')[0],
|
||||||
|
open: Math.round(open * 100) / 100,
|
||||||
|
high: Math.round(high * 100) / 100,
|
||||||
|
low: Math.round(low * 100) / 100,
|
||||||
|
close: Math.round(close * 100) / 100,
|
||||||
|
volume: Math.floor(Math.random() * 10000000) + 1000000
|
||||||
|
});
|
||||||
|
|
||||||
|
value = close;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateMockMomentumHistory = () => {
|
||||||
|
const history = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (let i = 30; i >= 0; i--) {
|
||||||
|
const date = new Date(now);
|
||||||
|
date.setDate(date.getDate() - i);
|
||||||
|
history.push({
|
||||||
|
date: date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }),
|
||||||
|
momentum: Math.floor(Math.random() * 40) + 40
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return history;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateMockSectorStocks = (sectorName: string): Stock[] => {
|
||||||
|
const stocks: Stock[] = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const price = Math.random() * 200 + 20;
|
||||||
|
const changePercent = Math.random() * 10 - 2;
|
||||||
|
stocks.push({
|
||||||
|
code: `${Math.floor(Math.random() * 600000 + 100000)}`,
|
||||||
|
name: `${sectorName}龙头${i + 1}`,
|
||||||
|
price: Math.round(price * 100) / 100,
|
||||||
|
change: Math.round(price * changePercent / 100 * 100) / 100,
|
||||||
|
changePercent: Math.round(changePercent * 100) / 100,
|
||||||
|
volume: Math.floor(Math.random() * 1000000) + 100000,
|
||||||
|
marketCap: Math.floor(Math.random() * 100000000000) + 10000000000,
|
||||||
|
industry: sectorName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return stocks;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateMockSectorNews = (sectorName: string): NewsItem[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: `${sectorName}板块迎来政策利好,多家机构看好后续走势`,
|
||||||
|
source: '证券时报',
|
||||||
|
time: '10分钟前',
|
||||||
|
sentiment: 'positive',
|
||||||
|
sentimentText: '正面'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: `${sectorName}行业整合加速,头部企业市场份额提升`,
|
||||||
|
source: '中国证券报',
|
||||||
|
time: '1小时前',
|
||||||
|
sentiment: 'positive',
|
||||||
|
sentimentText: '正面'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: `${sectorName}板块资金流入明显,主力布局迹象显现`,
|
||||||
|
source: '财新网',
|
||||||
|
time: '2小时前',
|
||||||
|
sentiment: 'neutral',
|
||||||
|
sentimentText: '中性'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SectorDetailModal({ sector, isOpen, onClose }: SectorDetailModalProps) {
|
||||||
|
const [period, setPeriod] = useState<'1D' | '5D' | '1M' | '3M' | '6M' | '1Y'>('1M');
|
||||||
|
const [klineData, setKlineData] = useState<KLineData[]>([]);
|
||||||
|
const [momentumHistory, setMomentumHistory] = useState<{ date: string; momentum: number }[]>([]);
|
||||||
|
const [topStocks, setTopStocks] = useState<Stock[]>([]);
|
||||||
|
const [news, setNews] = useState<NewsItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sector && isOpen) {
|
||||||
|
setLoading(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
const days = period === '1D' ? 1 : period === '5D' ? 5 : period === '1M' ? 30 : period === '3M' ? 90 : period === '6M' ? 180 : 365;
|
||||||
|
setKlineData(generateMockSectorKLine(days, sector.changePercent));
|
||||||
|
setMomentumHistory(generateMockMomentumHistory());
|
||||||
|
setTopStocks(generateMockSectorStocks(sector.sector));
|
||||||
|
setNews(generateMockSectorNews(sector.sector));
|
||||||
|
setLoading(false);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}, [sector, isOpen, period]);
|
||||||
|
|
||||||
|
if (!sector) return null;
|
||||||
|
|
||||||
|
const changeColor = sector.changePercent >= 0 ? '#ef4444' : '#22c55e';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="bg-[#0a0a0a] border border-white/10 rounded-xl w-full max-w-5xl max-h-[90vh] overflow-hidden"
|
||||||
|
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-orange-500 to-red-500 flex items-center justify-center">
|
||||||
|
<Flame className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">{sector.sector}</h2>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<span style={{ color: changeColor }} className="text-lg font-medium">
|
||||||
|
{sector.changePercent >= 0 ? '+' : ''}{sector.changePercent.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="border-white/20 text-white/60">
|
||||||
|
动量: {sector.momentum.toFixed(1)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setLoading(true)} disabled={loading}>
|
||||||
|
<Activity className={`w-4 h-4 ${loading ? 'animate-pulse' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-lg transition-colors">
|
||||||
|
<X className="w-5 h-5 text-white/60" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
<Tabs defaultValue="kline" className="w-full">
|
||||||
|
<TabsList className="bg-[#141414] border border-white/10 mb-4">
|
||||||
|
<TabsTrigger value="kline" className="data-[state=active]:bg-blue-500/20">
|
||||||
|
<BarChart3 className="w-4 h-4 mr-2" />K线图
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="momentum" className="data-[state=active]:bg-blue-500/20">
|
||||||
|
<Activity className="w-4 h-4 mr-2" />动量趋势
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="stocks" className="data-[state=active]:bg-blue-500/20">
|
||||||
|
<TrendingUp className="w-4 h-4 mr-2" />强势个股
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="info" className="data-[state=active]:bg-blue-500/20">
|
||||||
|
<Layers className="w-4 h-4 mr-2" />基本信息
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="news" className="data-[state=active]:bg-blue-500/20">
|
||||||
|
<Newspaper className="w-4 h-4 mr-2" />版块新闻
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="kline">
|
||||||
|
<div className="bg-[#141414] rounded-xl p-6 border border-white/5">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
{(['1D', '5D', '1M', '3M', '6M', '1Y'] as const).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||||
|
period === p
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-white/5 text-white/60 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{klineData.length > 0 && <KLineChart data={klineData} />}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="momentum">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-[#141414] rounded-xl p-6 border border-white/5">
|
||||||
|
<h3 className="text-white font-medium mb-4">动量历史走势</h3>
|
||||||
|
<div className="h-[200px] flex items-end gap-1">
|
||||||
|
{momentumHistory.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
className="flex-1 bg-gradient-to-t from-blue-500 to-purple-500 rounded-t min-w-[8px]"
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: `${item.momentum}%` }}
|
||||||
|
transition={{ delay: index * 0.02 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-2 text-white/40 text-xs">
|
||||||
|
<span>30天前</span>
|
||||||
|
<span>今日</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-[#141414] rounded-xl p-6 border border-white/5 text-center">
|
||||||
|
<div className="text-white/60 text-sm mb-2">当前动量</div>
|
||||||
|
<div className="text-3xl font-bold text-blue-400">{sector.momentum.toFixed(1)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#141414] rounded-xl p-6 border border-white/5 text-center">
|
||||||
|
<div className="text-white/60 text-sm mb-2">动量变化</div>
|
||||||
|
<div className="text-3xl font-bold text-green-400">+5.2</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#141414] rounded-xl p-6 border border-white/5 text-center">
|
||||||
|
<div className="text-white/60 text-sm mb-2">排名</div>
|
||||||
|
<div className="text-3xl font-bold text-orange-400">#3</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="stocks">
|
||||||
|
<div className="bg-[#141414] rounded-xl p-6 border border-white/5">
|
||||||
|
<h3 className="text-white font-medium mb-4">版块内强势个股</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{topStocks.map((stock, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={stock.code}
|
||||||
|
className="flex items-center justify-between p-4 bg-[#0a0a0a] rounded-lg hover:bg-[#1a1a1a] transition-colors cursor-pointer"
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold ${
|
||||||
|
index === 0 ? 'bg-yellow-500 text-black' :
|
||||||
|
index === 1 ? 'bg-gray-400 text-black' :
|
||||||
|
index === 2 ? 'bg-orange-600 text-white' :
|
||||||
|
'bg-white/10 text-white/60'
|
||||||
|
}`}>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-medium">{stock.name}</div>
|
||||||
|
<div className="text-white/40 text-sm">{stock.code}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-white">{formatPrice(stock.price)}</div>
|
||||||
|
<div style={{ color: stock.changePercent >= 0 ? '#ef4444' : '#22c55e' }}>
|
||||||
|
{stock.changePercent >= 0 ? '+' : ''}{stock.changePercent.toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-white/30" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="info">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-[#141414] rounded-xl p-6 border border-white/5">
|
||||||
|
<h3 className="text-white font-medium mb-4">版块基本信息</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-white/60">版块名称</span>
|
||||||
|
<span className="text-white">{sector.sector}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-white/60">涨跌幅</span>
|
||||||
|
<span style={{ color: changeColor }}>
|
||||||
|
{sector.changePercent >= 0 ? '+' : ''}{sector.changePercent.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-white/60">动量值</span>
|
||||||
|
<span className="text-blue-400">{sector.momentum.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-white/60">成分股数量</span>
|
||||||
|
<span className="text-white">{sector.stocks.length}只</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#141414] rounded-xl p-6 border border-white/5">
|
||||||
|
<h3 className="text-white font-medium mb-4">涨跌分布</h3>
|
||||||
|
<div className="flex items-end justify-center gap-6 h-[120px]">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-red-400 font-bold">{sector.upCount}</span>
|
||||||
|
<motion.div
|
||||||
|
className="w-16 bg-red-500 rounded-t mt-2"
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: `${(sector.upCount / (sector.upCount + sector.downCount + sector.flatCount)) * 80}px` }}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 mt-2">
|
||||||
|
<TrendingUp className="w-4 h-4 text-red-500" />
|
||||||
|
<span className="text-white/60 text-sm">上涨</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-gray-400 font-bold">{sector.flatCount}</span>
|
||||||
|
<motion.div
|
||||||
|
className="w-16 bg-gray-500 rounded-t mt-2"
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: `${Math.max((sector.flatCount / (sector.upCount + sector.downCount + sector.flatCount)) * 80, 10)}px` }}
|
||||||
|
/>
|
||||||
|
<span className="text-white/60 text-sm mt-2">平盘</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-green-400 font-bold">{sector.downCount}</span>
|
||||||
|
<motion.div
|
||||||
|
className="w-16 bg-green-500 rounded-t mt-2"
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: `${(sector.downCount / (sector.upCount + sector.downCount + sector.flatCount)) * 80}px` }}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 mt-2">
|
||||||
|
<TrendingDown className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="text-white/60 text-sm">下跌</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 bg-[#141414] rounded-xl p-6 border border-white/5">
|
||||||
|
<h3 className="text-white font-medium mb-3">代表个股</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{sector.stocks.map((name) => (
|
||||||
|
<Badge key={name} variant="outline" className="border-white/20 text-white/60">
|
||||||
|
{name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="news">
|
||||||
|
<div className="bg-[#141414] rounded-xl p-6 border border-white/5">
|
||||||
|
<h3 className="text-white font-medium mb-4">版块相关新闻</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{news.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.id}
|
||||||
|
className="p-4 bg-[#0a0a0a] rounded-lg hover:bg-[#1a1a1a] transition-colors cursor-pointer"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-medium mb-2">{item.title}</h4>
|
||||||
|
<div className="flex items-center gap-2 text-white/40 text-sm">
|
||||||
|
<span>{item.source}</span>
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>{item.time}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className={
|
||||||
|
item.sentiment === 'positive' ? 'bg-red-500/20 text-red-400' :
|
||||||
|
item.sentiment === 'negative' ? 'bg-green-500/20 text-green-400' :
|
||||||
|
'bg-gray-500/20 text-gray-400'
|
||||||
|
}>
|
||||||
|
{item.sentimentText}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,405 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
X, TrendingUp, TrendingDown, Target, Shield, Crosshair,
|
||||||
|
RefreshCw, BarChart3, Info, Clock, Layers, Activity
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { KLineChart } from '@/components/charts/KLineChart';
|
||||||
|
import { GaugeChart } from '@/components/charts/GaugeChart';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import type { Stock, AIAnalysis, KLineData, SentimentData } from '@/types';
|
||||||
|
import { formatPrice, formatVolume } from '@/lib/formatters';
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
|
||||||
|
interface StockDetailModalProps {
|
||||||
|
stock: Stock | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockSentimentData: SentimentData = {
|
||||||
|
value: 52,
|
||||||
|
label: '中性',
|
||||||
|
description: '市场情绪平稳',
|
||||||
|
color: '#f97316'
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateMockKLineData = (days: number, basePrice: number): KLineData[] => {
|
||||||
|
const data: KLineData[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
let price = basePrice;
|
||||||
|
|
||||||
|
for (let i = days; i >= 0; i--) {
|
||||||
|
const date = new Date(now);
|
||||||
|
date.setDate(date.getDate() - i);
|
||||||
|
if (date.getDay() === 0 || date.getDay() === 6) continue;
|
||||||
|
|
||||||
|
const change = (Math.random() - 0.5) * 0.1;
|
||||||
|
const open = price;
|
||||||
|
const close = price * (1 + change);
|
||||||
|
const high = Math.max(open, close) * (1 + Math.random() * 0.02);
|
||||||
|
const low = Math.min(open, close) * (1 - Math.random() * 0.02);
|
||||||
|
|
||||||
|
data.push({
|
||||||
|
date: date.toISOString().split('T')[0],
|
||||||
|
open: Math.round(open * 100) / 100,
|
||||||
|
high: Math.round(high * 100) / 100,
|
||||||
|
low: Math.round(low * 100) / 100,
|
||||||
|
close: Math.round(close * 100) / 100,
|
||||||
|
volume: Math.floor(Math.random() * 1000000) + 100000
|
||||||
|
});
|
||||||
|
|
||||||
|
price = close;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateMockAIAnalysis = (stock: Stock): AIAnalysis => {
|
||||||
|
const trendOptions = ['up', 'down', 'sideways'] as const;
|
||||||
|
const recOptions = ['buy', 'sell', 'hold'] as const;
|
||||||
|
const trend = trendOptions[Math.floor(Math.random() * 3)];
|
||||||
|
const recommendation = recOptions[Math.floor(Math.random() * 3)];
|
||||||
|
|
||||||
|
return {
|
||||||
|
stockCode: stock.code,
|
||||||
|
stockName: stock.name,
|
||||||
|
currentPrice: stock.price,
|
||||||
|
changePercent: stock.changePercent,
|
||||||
|
insights: `${stock.name}近期走势分析:该股处于${trend === 'up' ? '上升' : trend === 'down' ? '下降' : '震荡'}趋势中,成交量${Math.random() > 0.5 ? '放大' : '萎缩'}。技术指标显示${Math.random() > 0.5 ? '多头' : '空头'}排列,建议${recommendation === 'buy' ? '逢低布局' : recommendation === 'sell' ? '谨慎观望' : '持有待变'}.关注${stock.industry}行业整体走势及政策面变化.`,
|
||||||
|
recommendation,
|
||||||
|
recommendationText: recommendation === 'buy' ? '买入' : recommendation === 'sell' ? '卖出' : '持有',
|
||||||
|
trend,
|
||||||
|
trendText: trend === 'up' ? '上涨' : trend === 'down' ? '下跌' : '震荡',
|
||||||
|
targetPrice: {
|
||||||
|
idealBuy: Math.round(stock.price * 0.92 * 100) / 100,
|
||||||
|
secondBuy: Math.round(stock.price * 0.88 * 100) / 100,
|
||||||
|
stopLoss: Math.round(stock.price * 0.85 * 100) / 100,
|
||||||
|
takeProfit: Math.round(stock.price * 1.18 * 100) / 100
|
||||||
|
},
|
||||||
|
confidence: Math.floor(Math.random() * 30) + 60
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const multiPeriodTrends = [
|
||||||
|
{ period: '日线', trend: '震荡', strength: 65, color: '#f97316' },
|
||||||
|
{ period: '周线', trend: '上涨', strength: 72, color: '#22c55e' },
|
||||||
|
{ period: '月线', trend: '下跌', strength: 45, color: '#ef4444' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function StockDetailModal({ stock, isOpen, onClose }: StockDetailModalProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [period, setPeriod] = useState<'1D' | '5D' | '1M' | '3M' | '6M' | '1Y'>('1M');
|
||||||
|
const [klineData, setKlineData] = useState<KLineData[]>([]);
|
||||||
|
const [aiAnalysis, setAiAnalysis] = useState<AIAnalysis | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (stock && isOpen) {
|
||||||
|
setLoading(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
const days = period === '1D' ? 1 : period === '5D' ? 5 : period === '1M' ? 30 : period === '3M' ? 90 : period === '6M' ? 180 : 365;
|
||||||
|
setKlineData(generateMockKLineData(days, stock.price));
|
||||||
|
setAiAnalysis(generateMockAIAnalysis(stock));
|
||||||
|
setLoading(false);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}, [stock, isOpen, period]);
|
||||||
|
|
||||||
|
if (!stock) return null;
|
||||||
|
|
||||||
|
const changeColor = stock.changePercent >= 0 ? '#ef4444' : '#22c55e';
|
||||||
|
|
||||||
|
const cardBgClass = theme === 'dark' ? 'bg-[#0a0a0a]' : 'bg-gray-50';
|
||||||
|
const innerBgClass = theme === 'dark' ? 'bg-[#141414]' : 'bg-gray-100';
|
||||||
|
const borderColorClass = theme === 'dark' ? 'border-white/10' : 'border-gray-200';
|
||||||
|
const textColorClass = theme === 'dark' ? 'text-white' : 'text-gray-900';
|
||||||
|
const textMutedClass = theme === 'dark' ? 'text-white/60' : 'text-gray-500';
|
||||||
|
const textSubMutedClass = theme === 'dark' ? 'text-white/40' : 'text-gray-400';
|
||||||
|
|
||||||
|
const strategyPoints = aiAnalysis ? [
|
||||||
|
{ label: '理想买入', value: aiAnalysis.targetPrice.idealBuy, icon: Target, color: '#22c55e' },
|
||||||
|
{ label: '二次买入', value: aiAnalysis.targetPrice.secondBuy, icon: Crosshair, color: '#3b82f6' },
|
||||||
|
{ label: '止损价位', value: aiAnalysis.targetPrice.stopLoss, icon: Shield, color: '#ef4444' },
|
||||||
|
{ label: '止盈目标', value: aiAnalysis.targetPrice.takeProfit, icon: TrendingUp, color: '#f97316' },
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-2 sm:p-4 bg-black/80 backdrop-blur-sm"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className={`${cardBgClass} border ${borderColorClass} rounded-xl w-full max-w-5xl max-h-[95vh] overflow-hidden`}
|
||||||
|
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-3 sm:p-6 border-b ${borderColorClass}">
|
||||||
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className={`text-lg sm:text-2xl font-bold ${textColorClass}`}>{stock.name}</h2>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className={`${textSubMutedClass} text-xs sm:text-sm`}>{stock.code}</span>
|
||||||
|
<Badge variant="outline" className={`${borderColorClass} ${textMutedClass} text-xs`}>
|
||||||
|
{stock.industry}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 sm:ml-6">
|
||||||
|
<div className={`text-xl sm:text-3xl font-bold ${textColorClass}`}>{formatPrice(stock.price)}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{stock.changePercent >= 0 ? (
|
||||||
|
<TrendingUp className="w-3 h-3 sm:w-4 sm:h-4" style={{ color: changeColor }} />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="w-3 h-3 sm:w-4 sm:h-4" style={{ color: changeColor }} />
|
||||||
|
)}
|
||||||
|
<span style={{ color: changeColor }} className="text-sm sm:text-base">
|
||||||
|
{stock.changePercent >= 0 ? '+' : ''}{stock.changePercent.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setLoading(true)} disabled={loading}>
|
||||||
|
<RefreshCw className={`w-3 h-3 sm:w-4 sm:h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
<button onClick={onClose} className={`p-1 sm:p-2 ${theme === 'dark' ? 'hover:bg-white/10' : 'hover:bg-gray-100'} rounded-lg transition-colors`}>
|
||||||
|
<X className={`w-4 h-4 sm:w-5 sm:h-5 ${textMutedClass}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-3 sm:p-6">
|
||||||
|
<Tabs defaultValue="kline" className="w-full">
|
||||||
|
<TabsList className={`${innerBgClass} border ${borderColorClass} mb-2 sm:mb-4`}>
|
||||||
|
<TabsTrigger value="kline" className="data-[state=active]:bg-blue-500/20 text-xs sm:text-sm">
|
||||||
|
<BarChart3 className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2" />K线图
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="ai" className="data-[state=active]:bg-blue-500/20 text-xs sm:text-sm">
|
||||||
|
<Activity className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2" />AI研判
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="trend" className="data-[state=active]:bg-blue-500/20 text-xs sm:text-sm">
|
||||||
|
<Layers className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2" />多周期趋势
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="info" className="data-[state=active]:bg-blue-500/20 text-xs sm:text-sm">
|
||||||
|
<Info className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2" />基本信息
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="kline">
|
||||||
|
<div className={`${innerBgClass} rounded-xl p-3 sm:p-6 border ${borderColorClass}`}>
|
||||||
|
<div className="flex items-center gap-1 sm:gap-2 mb-2 sm:mb-4">
|
||||||
|
{(['1D', '5D', '1M', '3M', '6M', '1Y'] as const).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
className={`px-2 sm:px-3 py-1 sm:py-1.5 rounded-lg text-xs sm:text-sm transition-colors ${
|
||||||
|
period === p
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: `${theme === 'dark' ? 'bg-white/5 text-white/60 hover:bg-white/10' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{klineData.length > 0 && <KLineChart data={klineData} />}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="ai">
|
||||||
|
{aiAnalysis && (
|
||||||
|
<div className="space-y-2 sm:space-y-4">
|
||||||
|
<div className={`${innerBgClass} rounded-xl p-3 sm:p-6 border ${borderColorClass}`}>
|
||||||
|
<h3 className={`text-blue-400 text-xs sm:text-sm font-medium mb-2 sm:mb-3`}>AI分析结论</h3>
|
||||||
|
<p className={`${textMutedClass} text-xs sm:text-sm leading-relaxed`}>{aiAnalysis.insights}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:gap-4">
|
||||||
|
<div className={`${innerBgClass} rounded-xl p-3 sm:p-6 border ${borderColorClass}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-2 sm:mb-3">
|
||||||
|
<TrendingUp className="w-3 h-3 sm:w-4 sm:h-4 text-green-500" />
|
||||||
|
<span className={`${textMutedClass} text-xs sm:text-sm`}>操作建议</span>
|
||||||
|
</div>
|
||||||
|
<Badge className={`text-sm sm:text-lg px-2 sm:px-4 py-1 sm:py-2 ${
|
||||||
|
aiAnalysis.recommendation === 'buy' ? 'bg-red-500' :
|
||||||
|
aiAnalysis.recommendation === 'sell' ? 'bg-green-500' :
|
||||||
|
'bg-orange-500'
|
||||||
|
}`}>
|
||||||
|
{aiAnalysis.recommendationText}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${innerBgClass} rounded-xl p-3 sm:p-6 border ${borderColorClass}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-2 sm:mb-3">
|
||||||
|
<Activity className="w-3 h-3 sm:w-4 sm:h-4 text-orange-500" />
|
||||||
|
<span className={`${textMutedClass} text-xs sm:text-sm`}>趋势预测</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className={`text-sm sm:text-lg px-2 sm:px-4 py-1 sm:py-2 border-orange-500 text-orange-400`}>
|
||||||
|
{aiAnalysis.trendText}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${innerBgClass} rounded-xl p-3 sm:p-6 border ${borderColorClass}`}>
|
||||||
|
<h3 className={`${textColorClass} font-medium mb-2 sm:mb-4 text-sm sm:text-base`}>
|
||||||
|
<span className="text-blue-400">关键位置</span>
|
||||||
|
<span className="mx-1 sm:mx-2 ${textSubMutedClass}">|</span>
|
||||||
|
狙击点位
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-4">
|
||||||
|
{strategyPoints.map((point) => (
|
||||||
|
<div key={point.label} className={`${cardBgClass} rounded-lg p-2 sm:p-4 text-center`}>
|
||||||
|
<point.icon className="w-3 h-3 sm:w-5 sm:h-5 mx-auto mb-1 sm:mb-2" style={{ color: point.color }} />
|
||||||
|
<div className={`${textMutedClass} text-xs sm:text-sm mb-1`}>{point.label}</div>
|
||||||
|
<div className="text-sm sm:text-xl font-bold" style={{ color: point.color }}>
|
||||||
|
{formatPrice(point.value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${innerBgClass} rounded-xl p-3 sm:p-6 border ${borderColorClass}`}>
|
||||||
|
<h3 className={`${textMutedClass} text-xs sm:text-sm mb-2 sm:mb-3`}>AI分析置信度</h3>
|
||||||
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
|
<div className={`flex-1 h-2 sm:h-3 ${theme === 'dark' ? 'bg-white/10' : 'bg-gray-200'} rounded-full overflow-hidden`}>
|
||||||
|
<motion.div
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${aiAnalysis.confidence}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={`${textColorClass} font-bold text-sm sm:text-base`}>{aiAnalysis.confidence}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="trend">
|
||||||
|
<div className="space-y-2 sm:space-y-4">
|
||||||
|
<div className={`${innerBgClass} rounded-xl p-3 sm:p-6 border ${borderColorClass}`}>
|
||||||
|
<h3 className={`${textColorClass} font-medium mb-2 sm:mb-4 text-sm sm:text-base`}>多周期趋势分析</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 sm:gap-4">
|
||||||
|
{multiPeriodTrends.map((item) => (
|
||||||
|
<div key={item.period} className={`${cardBgClass} rounded-lg p-2 sm:p-4`}>
|
||||||
|
<div className={`${textMutedClass} text-xs sm:text-sm mb-1 sm:mb-2`}>{item.period}</div>
|
||||||
|
<div className="flex items-center gap-2 mb-2 sm:mb-3">
|
||||||
|
<Badge style={{ backgroundColor: `${item.color}20`, color: item.color }} className="text-xs sm:text-sm">
|
||||||
|
{item.trend}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`flex-1 h-1.5 sm:h-2 ${theme === 'dark' ? 'bg-white/10' : 'bg-gray-200'} rounded-full overflow-hidden`}>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{ width: `${item.strength}%`, backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={`${textMutedClass} text-xs sm:text-sm`}>{item.strength}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${innerBgClass} rounded-xl p-3 sm:p-6 border ${borderColorClass}`}>
|
||||||
|
<h3 className={`${textColorClass} font-medium mb-2 sm:mb-4 text-sm sm:text-base`}>趋势强度对比</h3>
|
||||||
|
<div className="flex items-end justify-center gap-4 sm:gap-8 h-[100px] sm:h-[150px]">
|
||||||
|
{multiPeriodTrends.map((item) => (
|
||||||
|
<div key={item.period} className="flex flex-col items-center">
|
||||||
|
<motion.div
|
||||||
|
className="w-8 sm:w-12 rounded-t"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: `${item.strength * 1}px` }}
|
||||||
|
/>
|
||||||
|
<div className={`${textMutedClass} text-xs sm:text-sm mt-2`}>{item.period}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="info">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-4">
|
||||||
|
<div className={`${innerBgClass} rounded-xl p-3 sm:p-6 border ${borderColorClass}`}>
|
||||||
|
<h3 className={`${textColorClass} font-medium mb-2 sm:mb-4 text-sm sm:text-base`}>基本信息</h3>
|
||||||
|
<div className="space-y-2 sm:space-y-3">
|
||||||
|
<div className="flex justify-between text-xs sm:text-sm">
|
||||||
|
<span className={textMutedClass}>股票代码</span>
|
||||||
|
<span className={textColorClass}>{stock.code}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs sm:text-sm">
|
||||||
|
<span className={textMutedClass}>股票名称</span>
|
||||||
|
<span className={textColorClass}>{stock.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs sm:text-sm">
|
||||||
|
<span className={textMutedClass}>所属行业</span>
|
||||||
|
<span className={textColorClass}>{stock.industry}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs sm:text-sm">
|
||||||
|
<span className={textMutedClass}>当前价格</span>
|
||||||
|
<span className={textColorClass}>{formatPrice(stock.price)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs sm:text-sm">
|
||||||
|
<span className={textMutedClass}>涨跌幅</span>
|
||||||
|
<span style={{ color: changeColor }}>
|
||||||
|
{stock.changePercent >= 0 ? '+' : ''}{stock.changePercent.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${innerBgClass} rounded-xl p-3 sm:p-6 border ${borderColorClass}`}>
|
||||||
|
<h3 className={`${textColorClass} font-medium mb-2 sm:mb-4 text-sm sm:text-base`}>交易数据</h3>
|
||||||
|
<div className="space-y-2 sm:space-y-3">
|
||||||
|
<div className="flex justify-between text-xs sm:text-sm">
|
||||||
|
<span className={textMutedClass}>成交量</span>
|
||||||
|
<span className={textColorClass}>{formatVolume(stock.volume)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs sm:text-sm">
|
||||||
|
<span className={textMutedClass}>市值</span>
|
||||||
|
<span className={textColorClass}>{formatVolume(stock.marketCap)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs sm:text-sm">
|
||||||
|
<span className={textMutedClass}>涨跌额</span>
|
||||||
|
<span style={{ color: changeColor }}>
|
||||||
|
{stock.change >= 0 ? '+' : ''}{formatPrice(stock.change)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`col-span-1 sm:col-span-2 ${innerBgClass} rounded-xl p-3 sm:p-6 border ${borderColorClass}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className={`${textColorClass} font-medium mb-1 sm:mb-2 text-sm sm:text-base`}>市场情绪</h3>
|
||||||
|
<p className={`${textMutedClass} text-xs sm:text-sm`}>{mockSentimentData.description}</p>
|
||||||
|
</div>
|
||||||
|
<GaugeChart data={mockSentimentData} size={60} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export { StockDetailModal } from './StockDetailModal';
|
||||||
|
export { SectorDetailModal } from './SectorDetailModal';
|
||||||
@ -0,0 +1,167 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Database,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
RefreshCw,
|
||||||
|
Check,
|
||||||
|
ChevronDown
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import type { DataSource } from '@/types';
|
||||||
|
import { mockDataSources } from '@/lib/mockData';
|
||||||
|
|
||||||
|
interface DataSourcePanelProps {
|
||||||
|
isRealtimeEnabled: boolean;
|
||||||
|
onRealtimeToggle: (enabled: boolean) => void;
|
||||||
|
currentSource: string;
|
||||||
|
onSourceChange: (sourceId: string) => void;
|
||||||
|
connectionStatus: 'connected' | 'disconnected' | 'connecting';
|
||||||
|
lastUpdateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataSourcePanel({
|
||||||
|
isRealtimeEnabled,
|
||||||
|
onRealtimeToggle,
|
||||||
|
currentSource,
|
||||||
|
onSourceChange,
|
||||||
|
connectionStatus,
|
||||||
|
lastUpdateTime
|
||||||
|
}: DataSourcePanelProps) {
|
||||||
|
const [dataSources] = useState<DataSource[]>(mockDataSources);
|
||||||
|
const currentSourceData = dataSources.find(s => s.id === currentSource);
|
||||||
|
|
||||||
|
const getStatusIcon = () => {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case 'connected':
|
||||||
|
return <Wifi className="w-4 h-4 text-green-400" />;
|
||||||
|
case 'connecting':
|
||||||
|
return <RefreshCw className="w-4 h-4 text-orange-400 animate-spin" />;
|
||||||
|
default:
|
||||||
|
return <WifiOff className="w-4 h-4 text-red-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case 'connected':
|
||||||
|
return '已连接';
|
||||||
|
case 'connecting':
|
||||||
|
return '连接中...';
|
||||||
|
default:
|
||||||
|
return '已断开';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case 'connected':
|
||||||
|
return 'bg-green-500/20 text-green-400 border-green-500/30';
|
||||||
|
case 'connecting':
|
||||||
|
return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
|
||||||
|
default:
|
||||||
|
return 'bg-red-500/20 text-red-400 border-red-500/30';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.section
|
||||||
|
className="mb-8"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Database className="w-5 h-5 text-purple-500" />
|
||||||
|
<h2 className="text-xl font-semibold text-white">数据源设置</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#0a0a0a] border border-white/10 rounded-xl p-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* 数据源选择 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-white/60 text-sm mb-2 block">当前数据源</label>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-between bg-[#141414] border-white/10 text-white hover:bg-[#1a1a1a] hover:border-white/20"
|
||||||
|
>
|
||||||
|
<span>{currentSourceData?.name || '选择数据源'}</span>
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="bg-[#141414] border-white/10">
|
||||||
|
{dataSources.map(source => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={source.id}
|
||||||
|
onClick={() => onSourceChange(source.id)}
|
||||||
|
className="text-white hover:bg-white/10 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<span>{source.name}</span>
|
||||||
|
{currentSource === source.id && (
|
||||||
|
<Check className="w-4 h-4 text-green-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 实时订阅开关 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-white/60 text-sm mb-2 block">实时数据订阅</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Switch
|
||||||
|
checked={isRealtimeEnabled}
|
||||||
|
onCheckedChange={onRealtimeToggle}
|
||||||
|
className="data-[state=checked]:bg-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-white">
|
||||||
|
{isRealtimeEnabled ? '已开启' : '已关闭'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 连接状态 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-white/60 text-sm mb-2 block">连接状态</label>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 ${getStatusColor()}`}
|
||||||
|
>
|
||||||
|
{getStatusIcon()}
|
||||||
|
<span>{getStatusText()}</span>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 数据更新时间 */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-white/5 flex items-center justify-between">
|
||||||
|
<div className="text-white/40 text-sm">
|
||||||
|
最后更新: {lastUpdateTime}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isRealtimeEnabled && connectionStatus === 'connected' && (
|
||||||
|
<span className="flex items-center gap-2 text-green-400 text-sm">
|
||||||
|
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></span>
|
||||||
|
实时推送中
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,182 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ArrowUp, ArrowDown, TrendingUp, Calendar } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import type { HighLowStock, Stock } from '@/types';
|
||||||
|
import { formatPrice, formatChange } from '@/lib/formatters';
|
||||||
|
|
||||||
|
interface HighLowStocksProps {
|
||||||
|
highStocks: HighLowStock[];
|
||||||
|
lowStocks: HighLowStock[];
|
||||||
|
onStockClick?: (stock: Stock) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertToStock = (highLowStock: HighLowStock): Stock => ({
|
||||||
|
code: highLowStock.code,
|
||||||
|
name: highLowStock.name,
|
||||||
|
price: highLowStock.price,
|
||||||
|
change: highLowStock.price * highLowStock.changePercent / 100,
|
||||||
|
changePercent: highLowStock.changePercent,
|
||||||
|
volume: 0,
|
||||||
|
marketCap: 0,
|
||||||
|
industry: highLowStock.industry
|
||||||
|
});
|
||||||
|
|
||||||
|
export function HighLowStocks({ highStocks, lowStocks, onStockClick }: HighLowStocksProps) {
|
||||||
|
return (
|
||||||
|
<section className="mb-8">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<TrendingUp className="w-5 h-5 text-red-500" />
|
||||||
|
<h2 className="text-xl font-semibold text-white">新高新低个股</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<motion.div
|
||||||
|
className="bg-[#0a0a0a] border border-red-500/30 rounded-xl overflow-hidden"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="p-4 border-b border-red-500/20 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArrowUp className="w-5 h-5 text-red-500" />
|
||||||
|
<h3 className="text-white font-medium">创历史新高</h3>
|
||||||
|
</div>
|
||||||
|
<span className="text-red-400 text-sm">共 {highStocks.length} 只</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-white/5 hover:bg-transparent">
|
||||||
|
<TableHead className="text-white/60">股票</TableHead>
|
||||||
|
<TableHead className="text-white/60 text-right">价格</TableHead>
|
||||||
|
<TableHead className="text-white/60 text-right">涨跌幅</TableHead>
|
||||||
|
<TableHead className="text-white/60 text-right">新高价</TableHead>
|
||||||
|
<TableHead className="text-white/60">天数</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{highStocks.map((stock, index) => {
|
||||||
|
const changeInfo = formatChange(stock.changePercent);
|
||||||
|
return (
|
||||||
|
<motion.tr
|
||||||
|
key={stock.code}
|
||||||
|
className="border-white/5 hover:bg-white/5 cursor-pointer transition-colors"
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||||
|
onClick={() => onStockClick?.(convertToStock(stock))}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-medium">{stock.name}</div>
|
||||||
|
<div className="text-white/40 text-sm">{stock.code}</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-white">
|
||||||
|
{formatPrice(stock.price)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<span style={{ color: changeInfo.color }}>
|
||||||
|
{changeInfo.text}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-red-400">
|
||||||
|
{formatPrice(stock.highLowPrice)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3 text-white/40" />
|
||||||
|
<span className="text-white/60 text-sm">
|
||||||
|
{stock.daysToHighLow === 0 ? '今日' : `${stock.daysToHighLow}天`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</motion.tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="bg-[#0a0a0a] border border-green-500/30 rounded-xl overflow-hidden"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="p-4 border-b border-green-500/20 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArrowDown className="w-5 h-5 text-green-500" />
|
||||||
|
<h3 className="text-white font-medium">创历史新低</h3>
|
||||||
|
</div>
|
||||||
|
<span className="text-green-400 text-sm">共 {lowStocks.length} 只</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-white/5 hover:bg-transparent">
|
||||||
|
<TableHead className="text-white/60">股票</TableHead>
|
||||||
|
<TableHead className="text-white/60 text-right">价格</TableHead>
|
||||||
|
<TableHead className="text-white/60 text-right">涨跌幅</TableHead>
|
||||||
|
<TableHead className="text-white/60 text-right">新低价</TableHead>
|
||||||
|
<TableHead className="text-white/60">天数</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{lowStocks.map((stock, index) => {
|
||||||
|
const changeInfo = formatChange(stock.changePercent);
|
||||||
|
return (
|
||||||
|
<motion.tr
|
||||||
|
key={stock.code}
|
||||||
|
className="border-white/5 hover:bg-white/5 cursor-pointer transition-colors"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||||
|
onClick={() => onStockClick?.(convertToStock(stock))}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-medium">{stock.name}</div>
|
||||||
|
<div className="text-white/40 text-sm">{stock.code}</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-white">
|
||||||
|
{formatPrice(stock.price)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<span style={{ color: changeInfo.color }}>
|
||||||
|
{changeInfo.text}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-green-400">
|
||||||
|
{formatPrice(stock.highLowPrice)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3 text-white/40" />
|
||||||
|
<span className="text-white/60 text-sm">
|
||||||
|
{stock.daysToHighLow === 0 ? '今日' : `${stock.daysToHighLow}天`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</motion.tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Flame, ExternalLink } from 'lucide-react';
|
||||||
|
import type { HotNews } from '@/types';
|
||||||
|
|
||||||
|
interface HotNewsProps {
|
||||||
|
news: HotNews[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HotNewsSection({ news }: HotNewsProps) {
|
||||||
|
return (
|
||||||
|
<section className="mb-8">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Flame className="w-5 h-5 text-orange-500" />
|
||||||
|
<h2 className="text-xl font-semibold text-white">当日热点</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#0a0a0a] border border-white/10 rounded-xl overflow-hidden">
|
||||||
|
<div className="divide-y divide-white/5">
|
||||||
|
{news.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.id}
|
||||||
|
className="p-4 hover:bg-white/5 cursor-pointer transition-colors group"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className={`
|
||||||
|
w-6 h-6 rounded flex items-center justify-center text-xs font-bold
|
||||||
|
${index === 0 ? 'bg-red-500 text-white' :
|
||||||
|
index === 1 ? 'bg-orange-500 text-white' :
|
||||||
|
index === 2 ? 'bg-yellow-500 text-black' :
|
||||||
|
'bg-white/10 text-white/60'}
|
||||||
|
`}>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<h4 className="text-white font-medium group-hover:text-blue-400 transition-colors">
|
||||||
|
{item.title}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 ml-8">
|
||||||
|
<div className="flex items-center gap-1 text-orange-400">
|
||||||
|
<Flame className="w-3 h-3" />
|
||||||
|
<span className="text-sm">{item.heat.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.relatedStocks.map((stock) => (
|
||||||
|
<span
|
||||||
|
key={stock}
|
||||||
|
className="px-2 py-0.5 bg-blue-500/20 text-blue-400 rounded text-xs"
|
||||||
|
>
|
||||||
|
{stock}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-white/40 text-sm">{item.time}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExternalLink className="w-4 h-4 text-white/30 group-hover:text-white/60 transition-colors" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { TrendingUp, TrendingDown, BarChart3, ChevronRight } from 'lucide-react';
|
||||||
|
import type { MarketIndex } from '@/types';
|
||||||
|
import { formatPrice, formatChange } from '@/lib/formatters';
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
|
||||||
|
interface MarketIndicesProps {
|
||||||
|
indices: MarketIndex[];
|
||||||
|
onSelectIndex?: (index: MarketIndex) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarketIndices({ indices, onSelectIndex }: MarketIndicesProps) {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState<string | null>(null);
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
const handleSelect = (index: MarketIndex) => {
|
||||||
|
setSelectedIndex(index.code);
|
||||||
|
onSelectIndex?.(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardBgClass = theme === 'dark' ? 'bg-[#0a0a0a]' : 'bg-gray-50';
|
||||||
|
const borderColorClass = theme === 'dark' ? 'border-white/10' : 'border-gray-200';
|
||||||
|
const textColorClass = theme === 'dark' ? 'text-white' : 'text-gray-900';
|
||||||
|
const textMutedClass = theme === 'dark' ? 'text-white/60' : 'text-gray-500';
|
||||||
|
const textSubMutedClass = theme === 'dark' ? 'text-white/40' : 'text-gray-400';
|
||||||
|
const hoverBorderClass = theme === 'dark' ? 'hover:border-white/20' : 'hover:border-gray-300';
|
||||||
|
const selectedBgClass = theme === 'dark' ? 'bg-blue-500/10' : 'bg-blue-50';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-4 sm:mb-8">
|
||||||
|
<div className="flex items-center gap-2 mb-3 sm:mb-4">
|
||||||
|
<BarChart3 className="w-4 h-4 sm:w-5 sm:h-5 text-blue-500" />
|
||||||
|
<h2 className={`text-lg sm:text-xl font-semibold ${textColorClass}`}>市场指数</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2 sm:gap-4">
|
||||||
|
{indices.map((index, i) => {
|
||||||
|
const changeInfo = formatChange(index.changePercent);
|
||||||
|
const isSelected = selectedIndex === index.code;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={index.code}
|
||||||
|
className={`
|
||||||
|
${cardBgClass} border rounded-xl p-3 sm:p-4 cursor-pointer transition-all
|
||||||
|
${isSelected
|
||||||
|
? `border-blue-500/50 ${selectedBgClass}`
|
||||||
|
: `${borderColorClass} ${hoverBorderClass}`}
|
||||||
|
`}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: i * 0.05 }}
|
||||||
|
onClick={() => handleSelect(index)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1 sm:mb-2">
|
||||||
|
<span className={`${textColorClass} font-medium text-sm sm:text-base`}>{index.name}</span>
|
||||||
|
<ChevronRight className={`w-3 h-3 sm:w-4 sm:h-4 ${textSubMutedClass} transition-transform ${isSelected ? 'rotate-90' : ''}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`text-xl sm:text-2xl font-bold ${textColorClass} mb-1`}>
|
||||||
|
{formatPrice(index.price)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 text-xs sm:text-sm"
|
||||||
|
style={{ color: changeInfo.color }}
|
||||||
|
>
|
||||||
|
{index.changePercent >= 0 ? (
|
||||||
|
<TrendingUp className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||||
|
)}
|
||||||
|
<span>{changeInfo.text}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`mt-2 sm:mt-3 pt-2 sm:pt-3 border-t ${borderColorClass}`}>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-1.5 sm:w-2 h-1.5 sm:h-2 rounded-full bg-red-500"></div>
|
||||||
|
<span className={`${textSubMutedClass}`}>{index.upCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-1.5 sm:w-2 h-1.5 sm:h-2 rounded-full bg-gray-500"></div>
|
||||||
|
<span className={`${textSubMutedClass}`}>{index.flatCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-1.5 sm:w-2 h-1.5 sm:h-2 rounded-full bg-green-500"></div>
|
||||||
|
<span className={`${textSubMutedClass}`}>{index.downCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`mt-1 sm:mt-2 h-1 sm:h-1.5 ${theme === 'dark' ? 'bg-white/10' : 'bg-gray-200'} rounded-full overflow-hidden flex`}>
|
||||||
|
<div
|
||||||
|
className="h-full bg-red-500"
|
||||||
|
style={{ width: `${(index.upCount / (index.upCount + index.downCount + index.flatCount)) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-gray-500"
|
||||||
|
style={{ width: `${(index.flatCount / (index.upCount + index.downCount + index.flatCount)) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-green-500"
|
||||||
|
style={{ width: `${(index.downCount / (index.upCount + index.downCount + index.flatCount)) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,179 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { TrendingUp, TrendingDown, Minus, RefreshCw } from 'lucide-react';
|
||||||
|
import { AnimatedNumber } from '@/components/common/AnimatedNumber';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import type { MarketOverview, PriceDistribution } from '@/types';
|
||||||
|
import { formatDateTime } from '@/lib/formatters';
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
|
||||||
|
interface MarketOverviewProps {
|
||||||
|
data: MarketOverview;
|
||||||
|
distributionData: PriceDistribution[];
|
||||||
|
loading?: boolean;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarketOverviewSection({ data, distributionData, loading, onRefresh }: MarketOverviewProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const total = data.upCount + data.downCount + data.flatCount;
|
||||||
|
const upPercent = (data.upCount / total) * 100;
|
||||||
|
const downPercent = (data.downCount / total) * 100;
|
||||||
|
const flatPercent = (data.flatCount / total) * 100;
|
||||||
|
|
||||||
|
const maxCount = Math.max(...distributionData.map(d => d.count));
|
||||||
|
|
||||||
|
const cardBgClass = theme === 'dark' ? 'bg-[#0a0a0a]' : 'bg-gray-50';
|
||||||
|
const borderColorClass = theme === 'dark' ? 'border-white/10' : 'border-gray-200';
|
||||||
|
const textColorClass = theme === 'dark' ? 'text-white' : 'text-gray-900';
|
||||||
|
const textMutedClass = theme === 'dark' ? 'text-white/60' : 'text-gray-500';
|
||||||
|
const textSubMutedClass = theme === 'dark' ? 'text-white/40' : 'text-gray-400';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-4 sm:mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-3 sm:mb-4">
|
||||||
|
<h2 className={`text-lg sm:text-xl font-semibold ${textColorClass}`}>涨跌分布</h2>
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
|
<span className={`${textSubMutedClass} text-xs sm:text-sm`}>
|
||||||
|
更新: {formatDateTime(data.updateTime)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className={`${textMutedClass} hover:${textColorClass} ${theme === 'dark' ? 'hover:bg-white/10' : 'hover:bg-gray-100'}`}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3 h-3 sm:w-4 sm:h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 sm:gap-6">
|
||||||
|
<motion.div
|
||||||
|
className={`${cardBgClass} border ${borderColorClass} rounded-xl p-4 sm:p-6`}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<h3 className={`${textMutedClass} text-xs sm:text-sm mb-3 sm:mb-4`}>涨跌家数</h3>
|
||||||
|
<div className="flex items-end justify-center gap-4 sm:gap-6 h-[150px] sm:h-[200px]">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="text-red-500 font-bold text-base sm:text-lg mb-1 sm:mb-2">
|
||||||
|
<AnimatedNumber value={data.upCount} duration={1000} />
|
||||||
|
</div>
|
||||||
|
<motion.div
|
||||||
|
className="w-12 sm:w-20 bg-gradient-to-t from-red-600 to-red-400 rounded-t-lg"
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: `${upPercent * 1.2}px` }}
|
||||||
|
transition={{ duration: 0.8, ease: [0.4, 0, 0.2, 1] }}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 sm:gap-2 mt-2 sm:mt-3">
|
||||||
|
<TrendingUp className="w-3 h-3 sm:w-4 sm:h-4 text-red-500" />
|
||||||
|
<span className={`${textMutedClass} text-xs sm:text-sm`}>上涨</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-red-400 text-xs mt-1">{upPercent.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="text-gray-400 font-bold text-base sm:text-lg mb-1 sm:mb-2">
|
||||||
|
<AnimatedNumber value={data.flatCount} duration={1000} />
|
||||||
|
</div>
|
||||||
|
<motion.div
|
||||||
|
className="w-12 sm:w-20 bg-gradient-to-t from-gray-600 to-gray-400 rounded-t-lg"
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: `${Math.max(flatPercent * 1.2, 10)}px` }}
|
||||||
|
transition={{ duration: 0.8, ease: [0.4, 0, 0.2, 1], delay: 0.1 }}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 sm:gap-2 mt-2 sm:mt-3">
|
||||||
|
<Minus className="w-3 h-3 sm:w-4 sm:h-4 text-gray-400" />
|
||||||
|
<span className={`${textMutedClass} text-xs sm:text-sm`}>平盘</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-400 text-xs mt-1">{flatPercent.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="text-green-500 font-bold text-base sm:text-lg mb-1 sm:mb-2">
|
||||||
|
<AnimatedNumber value={data.downCount} duration={1000} />
|
||||||
|
</div>
|
||||||
|
<motion.div
|
||||||
|
className="w-12 sm:w-20 bg-gradient-to-t from-green-600 to-green-400 rounded-t-lg"
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: `${downPercent * 1.2}px` }}
|
||||||
|
transition={{ duration: 0.8, ease: [0.4, 0, 0.2, 1], delay: 0.2 }}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 sm:gap-2 mt-2 sm:mt-3">
|
||||||
|
<TrendingDown className="w-3 h-3 sm:w-4 sm:h-4 text-green-500" />
|
||||||
|
<span className={`${textMutedClass} text-xs sm:text-sm`}>下跌</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-green-400 text-xs mt-1">{downPercent.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:gap-3 mt-4 sm:mt-6">
|
||||||
|
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-2 sm:p-3 text-center">
|
||||||
|
<div className={`${textMutedClass} text-xs mb-1`}>涨停</div>
|
||||||
|
<div className="text-lg sm:text-xl font-bold text-red-500">{data.limitUpCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-500/10 border border-green-500/30 rounded-lg p-2 sm:p-3 text-center">
|
||||||
|
<div className={`${textMutedClass} text-xs mb-1`}>跌停</div>
|
||||||
|
<div className="text-lg sm:text-xl font-bold text-green-500">{data.limitDownCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className={`lg:col-span-2 ${cardBgClass} border ${borderColorClass} rounded-xl p-4 sm:p-6`}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<h3 className={`${textMutedClass} text-xs sm:text-sm mb-3 sm:mb-4`}>涨跌幅区间分布</h3>
|
||||||
|
|
||||||
|
<div className="relative h-[200px] sm:h-[280px] flex items-end justify-between gap-0.5 sm:gap-1 overflow-x-auto scrollbar-thin">
|
||||||
|
{distributionData.map((item, index) => {
|
||||||
|
const heightPercent = (item.count / maxCount) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex-1 min-w-[20px] sm:min-w-[30px] flex flex-col items-center group cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className={`text-xs mb-1 opacity-0 group-hover:opacity-100 transition-opacity ${
|
||||||
|
item.isUp ? 'text-red-400' : 'text-green-400'
|
||||||
|
}`}>
|
||||||
|
{item.count}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className={`w-full rounded-t-sm transition-all duration-200 ${
|
||||||
|
item.isUp ? 'bg-red-500' : 'bg-green-500'
|
||||||
|
}`}
|
||||||
|
style={{ opacity: 0.7 }}
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: `${Math.max(heightPercent * 1.5, 3)}px` }}
|
||||||
|
transition={{ duration: 0.5, delay: index * 0.02 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="text-[8px] sm:text-[9px] text-white/40 mt-1 sm:mt-2 rotate-45 origin-left whitespace-nowrap">
|
||||||
|
{item.range}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-4 sm:gap-6 mt-4 sm:mt-6 text-xs">
|
||||||
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
|
<div className="w-2 sm:w-3 h-2 sm:h-3 rounded bg-red-500"></div>
|
||||||
|
<span className={`${textSubMutedClass}`}>上涨区间</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
|
<div className="w-2 sm:w-3 h-2 sm:h-3 rounded bg-green-500"></div>
|
||||||
|
<span className={`${textSubMutedClass}`}>下跌区间</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,281 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Trophy, TrendingDown, Flame, ChevronRight, TrendingUp, BarChart3, X } from 'lucide-react';
|
||||||
|
import { Treemap } from '@/components/charts/Treemap';
|
||||||
|
import { StockCard } from '@/components/common/StockCard';
|
||||||
|
import type { MomentumData, Stock } from '@/types';
|
||||||
|
import { mockHotStocks } from '@/lib/mockData';
|
||||||
|
|
||||||
|
interface MomentumAnalysisProps {
|
||||||
|
data: MomentumData[];
|
||||||
|
onSectorClick?: (sector: MomentumData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MomentumAnalysis({ data, onSectorClick }: MomentumAnalysisProps) {
|
||||||
|
const [selectedSector, setSelectedSector] = useState<MomentumData | null>(null);
|
||||||
|
const top5 = data.slice(0, 5);
|
||||||
|
const bottom5 = data.slice(-5).reverse();
|
||||||
|
|
||||||
|
const handleSectorClick = (sector: MomentumData) => {
|
||||||
|
if (onSectorClick) {
|
||||||
|
onSectorClick(sector);
|
||||||
|
} else {
|
||||||
|
setSelectedSector(sector);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-8">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Flame className="w-5 h-5 text-orange-500" />
|
||||||
|
<h2 className="text-xl font-semibold text-white">动量版块分析</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<motion.div
|
||||||
|
className="bg-[#0a0a0a] border border-white/10 rounded-xl p-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-white font-medium">版块热力图</h3>
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded bg-red-600"></div>
|
||||||
|
<span className="text-white/50">大涨</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded bg-red-400"></div>
|
||||||
|
<span className="text-white/50">小涨</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded bg-green-400"></div>
|
||||||
|
<span className="text-white/50">小跌</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded bg-green-600"></div>
|
||||||
|
<span className="text-white/50">大跌</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Treemap data={data} onSectorClick={handleSectorClick} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<motion.div
|
||||||
|
className="bg-[#0a0a0a] border border-white/10 rounded-xl p-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Trophy className="w-5 h-5 text-yellow-500" />
|
||||||
|
<h3 className="text-white font-medium">动量排名 TOP5</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{top5.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.sector}
|
||||||
|
className="flex items-center justify-between p-3 bg-[#141414] rounded-lg hover:bg-[#1a1a1a] transition-colors cursor-pointer group"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||||
|
onClick={() => handleSectorClick(item)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`w-6 h-6 rounded flex items-center justify-center text-xs font-bold ${
|
||||||
|
index === 0 ? 'bg-yellow-500 text-black' :
|
||||||
|
index === 1 ? 'bg-gray-400 text-black' :
|
||||||
|
index === 2 ? 'bg-orange-600 text-white' :
|
||||||
|
'bg-white/10 text-white/60'
|
||||||
|
}`}>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-white">{item.sector}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-white/40 text-sm">动量: {item.momentum.toFixed(1)}</span>
|
||||||
|
<span className="text-red-400 font-medium">+{item.changePercent.toFixed(2)}%</span>
|
||||||
|
<ChevronRight className="w-4 h-4 text-white/30 group-hover:text-white/60" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="bg-[#0a0a0a] border border-white/10 rounded-xl p-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<TrendingDown className="w-5 h-5 text-green-500" />
|
||||||
|
<h3 className="text-white font-medium">动量排名 BOTTOM5</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{bottom5.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.sector}
|
||||||
|
className="flex items-center justify-between p-3 bg-[#141414] rounded-lg hover:bg-[#1a1a1a] transition-colors cursor-pointer group"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||||
|
onClick={() => handleSectorClick(item)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-6 h-6 rounded bg-white/10 text-white/60 flex items-center justify-center text-xs font-bold">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-white">{item.sector}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-white/40 text-sm">动量: {item.momentum.toFixed(1)}</span>
|
||||||
|
<span className="text-green-400 font-medium">{item.changePercent.toFixed(2)}%</span>
|
||||||
|
<ChevronRight className="w-4 h-4 text-white/30 group-hover:text-white/60" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="mt-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Trophy className="w-4 h-4 text-yellow-500" />
|
||||||
|
<h3 className="text-white font-medium">热门个股</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{mockHotStocks.slice(0, 4).map((stock, index) => (
|
||||||
|
<StockCard key={stock.code} stock={stock} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{!onSectorClick && (
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedSector && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setSelectedSector(null)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="bg-[#0a0a0a] border border-white/10 rounded-xl p-6 w-full max-w-2xl max-h-[80vh] overflow-auto"
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-bold text-white">{selectedSector.sector}</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span
|
||||||
|
className={`text-lg font-medium ${
|
||||||
|
selectedSector.changePercent >= 0 ? 'text-red-400' : 'text-green-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedSector.changePercent >= 0 ? '+' : ''}
|
||||||
|
{selectedSector.changePercent.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-white/40">动量: {selectedSector.momentum.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSector(null)}
|
||||||
|
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-white/60" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#141414] rounded-lg p-4 mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<BarChart3 className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-white font-medium">涨跌分布</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end justify-center gap-6 h-[120px]">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-red-400 font-bold">{selectedSector.upCount}</span>
|
||||||
|
<motion.div
|
||||||
|
className="w-16 bg-red-500 rounded-t mt-2"
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: `${(selectedSector.upCount / (selectedSector.upCount + selectedSector.downCount + selectedSector.flatCount)) * 80}px` }}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 mt-2">
|
||||||
|
<TrendingUp className="w-4 h-4 text-red-500" />
|
||||||
|
<span className="text-white/60 text-sm">上涨</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-gray-400 font-bold">{selectedSector.flatCount}</span>
|
||||||
|
<motion.div
|
||||||
|
className="w-16 bg-gray-500 rounded-t mt-2"
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: `${Math.max((selectedSector.flatCount / (selectedSector.upCount + selectedSector.downCount + selectedSector.flatCount)) * 80, 10)}px` }}
|
||||||
|
/>
|
||||||
|
<span className="text-white/60 text-sm mt-2">平盘</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-green-400 font-bold">{selectedSector.downCount}</span>
|
||||||
|
<motion.div
|
||||||
|
className="w-16 bg-green-500 rounded-t mt-2"
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: `${(selectedSector.downCount / (selectedSector.upCount + selectedSector.downCount + selectedSector.flatCount)) * 80}px` }}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 mt-2">
|
||||||
|
<TrendingDown className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="text-white/60 text-sm">下跌</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSector.topStocks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-medium mb-3">领涨个股</h4>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{selectedSector.topStocks.map((stock) => (
|
||||||
|
<div
|
||||||
|
key={stock.code}
|
||||||
|
className="bg-[#141414] rounded-lg p-3 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-medium">{stock.name}</div>
|
||||||
|
<div className="text-white/40 text-sm">{stock.code}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-white">{stock.price.toFixed(2)}</div>
|
||||||
|
<div className={`text-sm ${stock.changePercent >= 0 ? 'text-red-400' : 'text-green-400'}`}>
|
||||||
|
{stock.changePercent >= 0 ? '+' : ''}{stock.changePercent.toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { BarChart3 } from 'lucide-react';
|
||||||
|
import { PriceDistributionChart } from '@/components/charts/PriceDistribution';
|
||||||
|
import type { PriceDistribution } from '@/types';
|
||||||
|
|
||||||
|
interface PriceChangeDistributionProps {
|
||||||
|
data: PriceDistribution[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceChangeDistribution({ data }: PriceChangeDistributionProps) {
|
||||||
|
return (
|
||||||
|
<section className="mb-8">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<BarChart3 className="w-5 h-5 text-blue-500" />
|
||||||
|
<h2 className="text-xl font-semibold text-white">涨跌幅分布</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="bg-[#0a0a0a] border border-white/10 rounded-xl p-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<PriceDistributionChart data={data} />
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,177 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Newspaper, ThumbsUp, ThumbsDown, Minus, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type { NewsItem } from '@/types';
|
||||||
|
|
||||||
|
interface SentimentAnalysisProps {
|
||||||
|
news: NewsItem[];
|
||||||
|
loading?: boolean;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SentimentAnalysis({ news, loading, onRefresh }: SentimentAnalysisProps) {
|
||||||
|
const [filter, setFilter] = useState<'all' | 'positive' | 'negative' | 'neutral'>('all');
|
||||||
|
|
||||||
|
const filteredNews = filter === 'all'
|
||||||
|
? news
|
||||||
|
: news.filter(item => item.sentiment === filter);
|
||||||
|
|
||||||
|
const sentimentStats = {
|
||||||
|
positive: news.filter(n => n.sentiment === 'positive').length,
|
||||||
|
negative: news.filter(n => n.sentiment === 'negative').length,
|
||||||
|
neutral: news.filter(n => n.sentiment === 'neutral').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSentimentIcon = (sentiment: string) => {
|
||||||
|
switch (sentiment) {
|
||||||
|
case 'positive': return <ThumbsUp className="w-4 h-4 text-red-400" />;
|
||||||
|
case 'negative': return <ThumbsDown className="w-4 h-4 text-green-400" />;
|
||||||
|
default: return <Minus className="w-4 h-4 text-orange-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSentimentColor = (sentiment: string) => {
|
||||||
|
switch (sentiment) {
|
||||||
|
case 'positive': return 'bg-red-500/20 text-red-400 border-red-500/30';
|
||||||
|
case 'negative': return 'bg-green-500/20 text-green-400 border-green-500/30';
|
||||||
|
default: return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Newspaper className="w-5 h-5 text-blue-500" />
|
||||||
|
<h2 className="text-xl font-semibold text-white">舆情分析</h2>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-white/60 hover:text-white"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 情绪统计 */}
|
||||||
|
<motion.div
|
||||||
|
className="grid grid-cols-3 gap-4 mb-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('positive')}
|
||||||
|
className={`p-4 rounded-xl border transition-all ${
|
||||||
|
filter === 'positive'
|
||||||
|
? 'bg-red-500/20 border-red-500/50'
|
||||||
|
: 'bg-[#0a0a0a] border-white/10 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-2">
|
||||||
|
<ThumbsUp className="w-5 h-5 text-red-400" />
|
||||||
|
<span className="text-white/60">正面</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-red-400">{sentimentStats.positive}</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('neutral')}
|
||||||
|
className={`p-4 rounded-xl border transition-all ${
|
||||||
|
filter === 'neutral'
|
||||||
|
? 'bg-orange-500/20 border-orange-500/50'
|
||||||
|
: 'bg-[#0a0a0a] border-white/10 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-2">
|
||||||
|
<Minus className="w-5 h-5 text-orange-400" />
|
||||||
|
<span className="text-white/60">中性</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-orange-400">{sentimentStats.neutral}</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('negative')}
|
||||||
|
className={`p-4 rounded-xl border transition-all ${
|
||||||
|
filter === 'negative'
|
||||||
|
? 'bg-green-500/20 border-green-500/50'
|
||||||
|
: 'bg-[#0a0a0a] border-white/10 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-2">
|
||||||
|
<ThumbsDown className="w-5 h-5 text-green-400" />
|
||||||
|
<span className="text-white/60">负面</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-green-400">{sentimentStats.negative}</div>
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 新闻列表 */}
|
||||||
|
<motion.div
|
||||||
|
className="bg-[#0a0a0a] border border-white/10 rounded-xl overflow-hidden"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="p-4 border-b border-white/5 flex items-center justify-between">
|
||||||
|
<h3 className="text-white font-medium">
|
||||||
|
<span className="text-blue-400">NEWS FEED</span>
|
||||||
|
<span className="mx-2 text-white/40">|</span>
|
||||||
|
<span>相关资讯</span>
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
className="text-blue-400 hover:text-blue-300"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-white/5">
|
||||||
|
{filteredNews.length > 0 ? (
|
||||||
|
filteredNews.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.id}
|
||||||
|
className="p-4 hover:bg-white/5 cursor-pointer transition-colors"
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-white font-medium mb-2 hover:text-blue-400 transition-colors">
|
||||||
|
{item.title}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-white/40">{item.source}</span>
|
||||||
|
<span className="text-white/30">|</span>
|
||||||
|
<span className="text-white/40">{item.time}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`flex items-center gap-1 ${getSentimentColor(item.sentiment)}`}
|
||||||
|
>
|
||||||
|
{getSentimentIcon(item.sentiment)}
|
||||||
|
<span>{item.sentimentText}</span>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center text-white/40">
|
||||||
|
暂无相关资讯
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
@ -0,0 +1,155 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||||
|
|
||||||
|
function AspectRatio({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||||
|
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AspectRatio }
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
const buttonGroupVariants = cva(
|
||||||
|
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
horizontal:
|
||||||
|
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||||
|
vertical:
|
||||||
|
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function ButtonGroup({
|
||||||
|
className,
|
||||||
|
orientation,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="button-group"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupText({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "div"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupSeparator({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="button-group-separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupSeparator,
|
||||||
|
ButtonGroupText,
|
||||||
|
buttonGroupVariants,
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, 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 transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@ -0,0 +1,220 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
|
DayPicker,
|
||||||
|
getDefaultClassNames,
|
||||||
|
type DayButton,
|
||||||
|
} from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString("default", { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"flex gap-4 flex-col md:flex-row relative",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"absolute bg-popover inset-0 opacity-0",
|
||||||
|
defaultClassNames.dropdown
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"select-none font-medium",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"select-none w-(--cell-size)",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-[0.8rem] select-none text-muted-foreground",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||||
|
props.showWeekNumber
|
||||||
|
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||||
|
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"rounded-l-md bg-accent",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus()
|
||||||
|
}, [modifiers.focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton }
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@ -0,0 +1,239 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import useEmblaCarousel, {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function Carousel({
|
||||||
|
orientation = "horizontal",
|
||||||
|
opts,
|
||||||
|
setApi,
|
||||||
|
plugins,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||||
|
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
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
data-slot="carousel"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { carouselRef, orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={carouselRef}
|
||||||
|
className="overflow-hidden"
|
||||||
|
data-slot="carousel-content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
data-slot="carousel-item"
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 shrink-0 grow-0 basis-full",
|
||||||
|
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselPrevious({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-previous"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "top-1/2 -left-12 -translate-y-1/2"
|
||||||
|
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
<span className="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselNext({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-next"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "top-1/2 -right-12 -translate-y-1/2"
|
||||||
|
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowRight />
|
||||||
|
<span className="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CarouselApi,
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselPrevious,
|
||||||
|
CarouselNext,
|
||||||
|
}
|
||||||
@ -0,0 +1,357 @@
|
|||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"]
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId()
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"[&_.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-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 flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean
|
||||||
|
hideIndicator?: boolean
|
||||||
|
indicator?: "line" | "dot" | "dashed"
|
||||||
|
nameKey?: string
|
||||||
|
labelKey?: string
|
||||||
|
}) {
|
||||||
|
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
|
||||||
|
className={cn(
|
||||||
|
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.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(
|
||||||
|
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||||
|
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="text-foreground font-mono font-medium tabular-nums">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
|
hideIcon?: boolean
|
||||||
|
nameKey?: string
|
||||||
|
}) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
@ -0,0 +1,182 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn("overflow-hidden p-0", className)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
className="flex h-9 items-center gap-2 border-b px-3"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
@ -0,0 +1,252 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ContextMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||||
|
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||||
|
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioGroup
|
||||||
|
data-slot="context-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
data-slot="context-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
data-slot="context-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover 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 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
data-slot="context-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover 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 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
data-slot="context-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="context-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
data-slot="context-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
data-slot="context-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
data-slot="context-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="context-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
}
|
||||||
@ -0,0 +1,141 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
@ -0,0 +1,135 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||||
|
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||||
|
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||||
|
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
|
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
||||||
@ -0,0 +1,255 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty"
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-header"
|
||||||
|
className={cn(
|
||||||
|
"flex max-w-sm flex-col items-center gap-2 text-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMediaVariants = cva(
|
||||||
|
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function EmptyMedia({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-icon"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(emptyMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-title"
|
||||||
|
className={cn("text-lg font-medium tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-content"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Empty,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyTitle,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyMedia,
|
||||||
|
}
|
||||||
@ -0,0 +1,246 @@
|
|||||||
|
import { useMemo } from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-6",
|
||||||
|
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLegend({
|
||||||
|
className,
|
||||||
|
variant = "legend",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||||
|
return (
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"mb-3 font-medium",
|
||||||
|
"data-[variant=legend]:text-base",
|
||||||
|
"data-[variant=label]:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn(
|
||||||
|
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldVariants = cva(
|
||||||
|
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||||
|
horizontal: [
|
||||||
|
"flex-row items-center",
|
||||||
|
"[&>[data-slot=field-label]]:flex-auto",
|
||||||
|
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
],
|
||||||
|
responsive: [
|
||||||
|
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||||
|
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||||
|
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
className={cn(
|
||||||
|
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Label>) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||||
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||||
|
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||||
|
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||||
|
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={!!children}
|
||||||
|
className={cn(
|
||||||
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Separator className="absolute inset-0 top-1/2" />
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
errors?: Array<{ message?: string } | undefined>
|
||||||
|
}) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (children) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueErrors = [
|
||||||
|
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (uniqueErrors?.length == 1) {
|
||||||
|
return uniqueErrors[0]?.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{uniqueErrors.map(
|
||||||
|
(error, index) =>
|
||||||
|
error?.message && <li key={index}>{error.message}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}, [children, errors])
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
className={cn("text-destructive text-sm font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
}
|
||||||
@ -0,0 +1,167 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import type * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
useFormState,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type 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 } = useFormContext()
|
||||||
|
const formState = useFormState({ name: fieldContext.name })
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="form-label"
|
||||||
|
data-error={!!error}
|
||||||
|
className={cn("data-[error=true]:text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
data-slot="form-control"
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-description"
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message ?? "") : props.children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-message"
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-destructive text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function HoverCard({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||||
|
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
data-slot="hover-card-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover 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 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</HoverCardPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||||
@ -0,0 +1,170 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
||||||
|
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||||
|
|
||||||
|
// Variants based on alignment.
|
||||||
|
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||||
|
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||||
|
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||||
|
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||||
|
|
||||||
|
// Focus state.
|
||||||
|
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||||
|
|
||||||
|
// Error state.
|
||||||
|
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||||
|
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
"inline-start":
|
||||||
|
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||||
|
"inline-end":
|
||||||
|
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||||
|
"block-start":
|
||||||
|
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||||
|
"block-end":
|
||||||
|
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "inline-start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = "inline-start",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
"text-sm shadow-none flex gap-2 items-center",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||||
|
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||||
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "xs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
variant = "ghost",
|
||||||
|
size = "xs",
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp"
|
||||||
|
import { MinusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function InputOTP({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof OTPInput> & {
|
||||||
|
containerClassName?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OTPInput
|
||||||
|
data-slot="input-otp"
|
||||||
|
containerClassName={cn(
|
||||||
|
"flex items-center gap-2 has-disabled:opacity-50",
|
||||||
|
containerClassName
|
||||||
|
)}
|
||||||
|
className={cn("disabled:cursor-not-allowed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-group"
|
||||||
|
className={cn("flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSlot({
|
||||||
|
index,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
index: number
|
||||||
|
}) {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext)
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-slot"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||||
|
<MinusIcon />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@ -0,0 +1,193 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="list"
|
||||||
|
data-slot="item-group"
|
||||||
|
className={cn("group/item-group flex flex-col", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="item-separator"
|
||||||
|
orientation="horizontal"
|
||||||
|
className={cn("my-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemVariants = cva(
|
||||||
|
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline: "border-border",
|
||||||
|
muted: "bg-muted/50",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "p-4 gap-4 ",
|
||||||
|
sm: "py-3 px-4 gap-2.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Item({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> &
|
||||||
|
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "div"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="item"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(itemVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemMediaVariants = cva(
|
||||||
|
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
image:
|
||||||
|
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function ItemMedia({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-media"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(itemMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-content"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-title"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="item-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
||||||
|
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-actions"
|
||||||
|
className={cn("flex items-center gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-header"
|
||||||
|
className={cn(
|
||||||
|
"flex basis-full items-center justify-between gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex basis-full items-center justify-between gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Item,
|
||||||
|
ItemMedia,
|
||||||
|
ItemContent,
|
||||||
|
ItemActions,
|
||||||
|
ItemGroup,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
ItemDescription,
|
||||||
|
ItemHeader,
|
||||||
|
ItemFooter,
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
|
||||||
|
"[&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd-group"
|
||||||
|
className={cn("inline-flex items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Kbd, KbdGroup }
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@ -0,0 +1,274 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Menubar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Root
|
||||||
|
data-slot="menubar"
|
||||||
|
className={cn(
|
||||||
|
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||||
|
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||||
|
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||||
|
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Trigger
|
||||||
|
data-slot="menubar-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarContent({
|
||||||
|
className,
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -4,
|
||||||
|
sideOffset = 8,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<MenubarPortal>
|
||||||
|
<MenubarPrimitive.Content
|
||||||
|
data-slot="menubar-content"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground 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 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenubarPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Item
|
||||||
|
data-slot="menubar-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.CheckboxItem
|
||||||
|
data-slot="menubar-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.RadioItem
|
||||||
|
data-slot="menubar-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Label
|
||||||
|
data-slot="menubar-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Separator
|
||||||
|
data-slot="menubar-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="menubar-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||||
|
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.SubTrigger
|
||||||
|
data-slot="menubar-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||||
|
</MenubarPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.SubContent
|
||||||
|
data-slot="menubar-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover 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 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Menubar,
|
||||||
|
MenubarPortal,
|
||||||
|
MenubarMenu,
|
||||||
|
MenubarTrigger,
|
||||||
|
MenubarContent,
|
||||||
|
MenubarGroup,
|
||||||
|
MenubarSeparator,
|
||||||
|
MenubarLabel,
|
||||||
|
MenubarItem,
|
||||||
|
MenubarShortcut,
|
||||||
|
MenubarCheckboxItem,
|
||||||
|
MenubarRadioGroup,
|
||||||
|
MenubarRadioItem,
|
||||||
|
MenubarSub,
|
||||||
|
MenubarSubTrigger,
|
||||||
|
MenubarSubContent,
|
||||||
|
}
|
||||||
@ -0,0 +1,168 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function NavigationMenu({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
viewport = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||||
|
viewport?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
data-slot="navigation-menu"
|
||||||
|
data-viewport={viewport}
|
||||||
|
className={cn(
|
||||||
|
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{viewport && <NavigationMenuViewport />}
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
data-slot="navigation-menu-list"
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Item
|
||||||
|
data-slot="navigation-menu-item"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
function NavigationMenuTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
data-slot="navigation-menu-trigger"
|
||||||
|
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{" "}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
data-slot="navigation-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"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 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||||
|
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuViewport({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
data-slot="navigation-menu-viewport"
|
||||||
|
className={cn(
|
||||||
|
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuLink({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Link
|
||||||
|
data-slot="navigation-menu-link"
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuIndicator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
data-slot="navigation-menu-indicator"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport,
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
}
|
||||||
@ -0,0 +1,127 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants, type Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn("flex flex-row items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean
|
||||||
|
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
React.ComponentProps<"a">
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? "outline" : "ghost",
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
<span className="hidden sm:block">Previous</span>
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:block">Next</span>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationPrevious,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationEllipsis,
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover 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 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { GripVerticalIcon } from "lucide-react"
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ResizablePanelGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<ResizablePrimitive.Group
|
||||||
|
data-slot="resizable-panel-group"
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizablePanel({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||||
|
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizableHandle({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||||
|
withHandle?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ResizablePrimitive.Separator
|
||||||
|
data-slot="resizable-handle"
|
||||||
|
className={cn(
|
||||||
|
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden 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-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||||
|
<GripVerticalIcon className="size-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.Separator>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
@ -0,0 +1,188 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
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}
|
||||||
|
align={align}
|
||||||
|
{...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)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-slot="select-item-indicator"
|
||||||
|
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
side === "right" &&
|
||||||
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
|
side === "left" &&
|
||||||
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
|
side === "top" &&
|
||||||
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
|
side === "bottom" &&
|
||||||
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
@ -0,0 +1,726 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type 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-(--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-(--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-(--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)+(--spacing(4)))]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-container"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 z-10 hidden h-svh w-(--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)+(--spacing(4))+2px)]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--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("size-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()
|
||||||
|
|
||||||
|
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]:size-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]:size-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-(--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-hidden 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,
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
className,
|
||||||
|
defaultValue,
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||||
|
const _values = React.useMemo(
|
||||||
|
() =>
|
||||||
|
Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: Array.isArray(defaultValue)
|
||||||
|
? defaultValue
|
||||||
|
: [min, max],
|
||||||
|
[value, defaultValue, min, max]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
data-slot="slider"
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track
|
||||||
|
data-slot="slider-track"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Range
|
||||||
|
data-slot="slider-range"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{Array.from({ length: _values.length }, (_, index) => (
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
data-slot="slider-thumb"
|
||||||
|
key={index}
|
||||||
|
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
CircleCheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
OctagonXIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: <CircleCheckIcon className="size-4" />,
|
||||||
|
info: <InfoIcon className="size-4" />,
|
||||||
|
warning: <TriangleAlertIcon className="size-4" />,
|
||||||
|
error: <OctagonXIcon className="size-4" />,
|
||||||
|
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
"--border-radius": "var(--radius)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
import { Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<Loader2Icon
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading"
|
||||||
|
className={cn("size-4 animate-spin", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Spinner }
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
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> & {
|
||||||
|
spacing?: number
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
size: "default",
|
||||||
|
variant: "default",
|
||||||
|
spacing: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ToggleGroup({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
spacing = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants> & {
|
||||||
|
spacing?: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive.Root
|
||||||
|
data-slot="toggle-group"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
data-spacing={spacing}
|
||||||
|
style={{ "--gap": spacing } as React.CSSProperties}
|
||||||
|
className={cn(
|
||||||
|
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleGroupItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||||
|
VariantProps<typeof toggleVariants>) {
|
||||||
|
const context = React.useContext(ToggleGroupContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive.Item
|
||||||
|
data-slot="toggle-group-item"
|
||||||
|
data-variant={context.variant || variant}
|
||||||
|
data-size={context.size || size}
|
||||||
|
data-spacing={context.spacing}
|
||||||
|
className={cn(
|
||||||
|
toggleVariants({
|
||||||
|
variant: context.variant || variant,
|
||||||
|
size: context.size || size,
|
||||||
|
}),
|
||||||
|
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
||||||
|
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ToggleGroup, ToggleGroupItem }
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const toggleVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-2 min-w-9",
|
||||||
|
sm: "h-8 px-1.5 min-w-8",
|
||||||
|
lg: "h-10 px-2.5 min-w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Toggle({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>) {
|
||||||
|
return (
|
||||||
|
<TogglePrimitive.Root
|
||||||
|
data-slot="toggle"
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants }
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-foreground text-background 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light';
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
theme: Theme;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface ThemeProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(() => {
|
||||||
|
const saved = localStorage.getItem('theme');
|
||||||
|
return (saved as Theme) || 'dark';
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.remove('light', 'dark');
|
||||||
|
root.classList.add(theme);
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setThemeState(prev => prev === 'dark' ? 'light' : 'dark');
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTheme = (newTheme: Theme) => {
|
||||||
|
setThemeState(newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useTheme must be used within a ThemeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { ThemeProvider, useTheme } from './ThemeContext';
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface UseCountUpOptions {
|
||||||
|
start?: number;
|
||||||
|
end: number;
|
||||||
|
duration?: number;
|
||||||
|
delay?: number;
|
||||||
|
decimals?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCountUp({
|
||||||
|
start = 0,
|
||||||
|
end,
|
||||||
|
duration = 1000,
|
||||||
|
delay = 0,
|
||||||
|
decimals = 0
|
||||||
|
}: UseCountUpOptions) {
|
||||||
|
const [value, setValue] = useState(start);
|
||||||
|
const startTimeRef = useRef<number | null>(null);
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
const animate = (timestamp: number) => {
|
||||||
|
if (startTimeRef.current === null) {
|
||||||
|
startTimeRef.current = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = Math.min((timestamp - startTimeRef.current) / duration, 1);
|
||||||
|
|
||||||
|
// easeOutQuart
|
||||||
|
const easeProgress = 1 - Math.pow(1 - progress, 4);
|
||||||
|
|
||||||
|
const currentValue = start + (end - start) * easeProgress;
|
||||||
|
setValue(Number(currentValue.toFixed(decimals)));
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
rafRef.current = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rafRef.current = requestAnimationFrame(animate);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (rafRef.current) {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [start, end, duration, delay, decimals]);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import type { MarketOverview, Stock, AIAnalysis, NewsItem } from '@/types';
|
||||||
|
import {
|
||||||
|
mockMarketOverview,
|
||||||
|
mockHotStocks,
|
||||||
|
mockAIAnalysis,
|
||||||
|
mockNewsData
|
||||||
|
} from '@/lib/mockData';
|
||||||
|
|
||||||
|
// 市场概况数据Hook
|
||||||
|
export function useMarketOverview() {
|
||||||
|
const [data, setData] = useState<MarketOverview>(mockMarketOverview);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
// 模拟API调用
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
setData({
|
||||||
|
...mockMarketOverview,
|
||||||
|
upCount: Math.floor(2800 + Math.random() * 200),
|
||||||
|
downCount: Math.floor(1900 + Math.random() * 200),
|
||||||
|
updateTime: new Date().toISOString()
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return { data, loading, refresh };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 热门股票数据Hook
|
||||||
|
export function useHotStocks() {
|
||||||
|
const [data, setData] = useState<Stock[]>(mockHotStocks);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
setData(mockHotStocks.map(stock => ({
|
||||||
|
...stock,
|
||||||
|
price: stock.price * (1 + (Math.random() - 0.5) * 0.02),
|
||||||
|
changePercent: stock.changePercent + (Math.random() - 0.5) * 0.5
|
||||||
|
})));
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { data, loading, refresh };
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI分析数据Hook
|
||||||
|
export function useAIAnalysis(stockCode: string) {
|
||||||
|
const [data, setData] = useState<AIAnalysis>(mockAIAnalysis);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
|
setData({
|
||||||
|
...mockAIAnalysis,
|
||||||
|
currentPrice: mockAIAnalysis.currentPrice * (1 + (Math.random() - 0.5) * 0.02),
|
||||||
|
changePercent: mockAIAnalysis.changePercent + (Math.random() - 0.5) * 0.5
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return { data, loading, refresh };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新闻数据Hook
|
||||||
|
export function useNews() {
|
||||||
|
const [data, setData] = useState<NewsItem[]>(mockNewsData);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 600));
|
||||||
|
setData(mockNewsData);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { data, loading, refresh };
|
||||||
|
}
|
||||||
@ -0,0 +1,248 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 0%;
|
||||||
|
--foreground: 0 0% 100%;
|
||||||
|
--card: 0 0% 4%;
|
||||||
|
--card-foreground: 0 0% 100%;
|
||||||
|
--popover: 0 0% 8%;
|
||||||
|
--popover-foreground: 0 0% 100%;
|
||||||
|
--primary: 0 84% 60%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 0 0% 8%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 0 0% 12%;
|
||||||
|
--muted-foreground: 0 0% 60%;
|
||||||
|
--accent: 0 0% 12%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 142 71% 45%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 0 0% 15%;
|
||||||
|
--input: 0 0% 12%;
|
||||||
|
--ring: 0 84% 60%;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 0 0% 7%;
|
||||||
|
--card: 0 0% 98%;
|
||||||
|
--card-foreground: 0 0% 7%;
|
||||||
|
--popover: 0 0% 96%;
|
||||||
|
--popover-foreground: 0 0% 7%;
|
||||||
|
--primary: 0 84% 60%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 0 0% 96%;
|
||||||
|
--secondary-foreground: 0 0% 7%;
|
||||||
|
--muted: 0 0% 92%;
|
||||||
|
--muted-foreground: 0 0% 40%;
|
||||||
|
--accent: 0 0% 92%;
|
||||||
|
--accent-foreground: 0 0% 7%;
|
||||||
|
--destructive: 142 71% 45%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 0 0% 88%;
|
||||||
|
--input: 0 0% 92%;
|
||||||
|
--ring: 0 84% 60%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark body {
|
||||||
|
@apply bg-black text-white antialiased;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light body {
|
||||||
|
@apply bg-white text-gray-900 antialiased;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.scrollbar-thin {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .scrollbar-thin {
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light .scrollbar-thin {
|
||||||
|
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-mono-numbers {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-feature-settings: "tnum";
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gradient {
|
||||||
|
background: linear-gradient(135deg, #f97316 0%, #ef4444 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .glass {
|
||||||
|
background: rgba(10, 10, 10, 0.8);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light .glass {
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-delay-100 { animation-delay: 100ms; }
|
||||||
|
.animation-delay-200 { animation-delay: 200ms; }
|
||||||
|
.animation-delay-300 { animation-delay: 300ms; }
|
||||||
|
.animation-delay-400 { animation-delay: 400ms; }
|
||||||
|
.animation-delay-500 { animation-delay: 500ms; }
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark [data-slot="table-header"] {
|
||||||
|
@apply bg-[#141414];
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light [data-slot="table-header"] {
|
||||||
|
@apply bg-gray-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark [data-slot="table-row"] {
|
||||||
|
@apply border-b border-white/5 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light [data-slot="table-row"] {
|
||||||
|
@apply border-b border-gray-200 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark [data-slot="table-row"]:hover {
|
||||||
|
@apply bg-white/5;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light [data-slot="table-row"]:hover {
|
||||||
|
@apply bg-gray-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="button"] {
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark [data-slot="input"] {
|
||||||
|
@apply bg-[#141414] border-white/10 text-white placeholder:text-white/40;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light [data-slot="input"] {
|
||||||
|
@apply bg-gray-50 border-gray-200 text-gray-900 placeholder:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark [data-slot="input"]:focus {
|
||||||
|
@apply border-blue-500 ring-1 ring-blue-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light [data-slot="input"]:focus {
|
||||||
|
@apply border-blue-500 ring-1 ring-blue-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-radix-popper-content-wrapper] {
|
||||||
|
z-index: 100 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover {
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .card-hover:hover {
|
||||||
|
@apply border-white/20;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light .card-hover:hover {
|
||||||
|
@apply border-gray-300;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-ring {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.8);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-ring {
|
||||||
|
animation: pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flash {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash {
|
||||||
|
animation: flash 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes count-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import { ThemeProvider } from './contexts/ThemeContext'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { MarketOverviewSection } from '@/components/sections/MarketOverview';
|
||||||
|
import { MarketIndices } from '@/components/sections/MarketIndices';
|
||||||
|
import { SentimentIndicator } from '@/components/sections/SentimentIndicator';
|
||||||
|
import { MomentumAnalysis } from '@/components/sections/MomentumAnalysis';
|
||||||
|
import { HighLowStocks } from '@/components/sections/HighLowStocks';
|
||||||
|
import { HotNewsSection } from '@/components/sections/HotNews';
|
||||||
|
import { useMarketOverview } from '@/hooks/useStockData';
|
||||||
|
import {
|
||||||
|
mockSentimentData,
|
||||||
|
mockMomentumData,
|
||||||
|
mockHighStocks,
|
||||||
|
mockLowStocks,
|
||||||
|
mockPriceDistribution,
|
||||||
|
mockSentimentTrend,
|
||||||
|
mockMarketIndices,
|
||||||
|
mockHotNews
|
||||||
|
} from '@/lib/mockData';
|
||||||
|
import type { Stock, MomentumData } from '@/types';
|
||||||
|
|
||||||
|
interface DashboardProps {
|
||||||
|
onStockClick?: (stock: Stock) => void;
|
||||||
|
onSectorClick?: (sector: MomentumData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dashboard({ onStockClick, onSectorClick }: DashboardProps) {
|
||||||
|
const { data: marketData, loading, refresh } = useMarketOverview();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
<MarketIndices indices={mockMarketIndices} />
|
||||||
|
|
||||||
|
<MarketOverviewSection
|
||||||
|
data={marketData}
|
||||||
|
distributionData={mockPriceDistribution}
|
||||||
|
loading={loading}
|
||||||
|
onRefresh={refresh}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HotNewsSection news={mockHotNews} />
|
||||||
|
|
||||||
|
<SentimentIndicator
|
||||||
|
data={mockSentimentData}
|
||||||
|
trendData={mockSentimentTrend}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MomentumAnalysis
|
||||||
|
data={mockMomentumData}
|
||||||
|
onSectorClick={onSectorClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HighLowStocks
|
||||||
|
highStocks={mockHighStocks}
|
||||||
|
lowStocks={mockLowStocks}
|
||||||
|
onStockClick={onStockClick}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { StockAnalysis } from '@/components/sections/StockAnalysis';
|
||||||
|
import { SentimentAnalysis } from '@/components/sections/SentimentAnalysis';
|
||||||
|
import { useAIAnalysis, useNews } from '@/hooks/useStockData';
|
||||||
|
import { mockSentimentData, mockKLineData } from '@/lib/mockData';
|
||||||
|
|
||||||
|
interface StockDetailProps {
|
||||||
|
stockCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StockDetail({ stockCode = '600126' }: StockDetailProps) {
|
||||||
|
const { data: aiData, loading: aiLoading, refresh: refreshAI } = useAIAnalysis(stockCode);
|
||||||
|
const { data: newsData, loading: newsLoading, refresh: refreshNews } = useNews();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
{/* AI个股分析 */}
|
||||||
|
<StockAnalysis
|
||||||
|
data={aiData}
|
||||||
|
sentimentData={mockSentimentData}
|
||||||
|
kLineData={mockKLineData}
|
||||||
|
loading={aiLoading}
|
||||||
|
onRefresh={refreshAI}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 舆情分析 */}
|
||||||
|
<SentimentAnalysis
|
||||||
|
news={newsData}
|
||||||
|
loading={newsLoading}
|
||||||
|
onRefresh={refreshNews}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,163 @@
|
|||||||
|
// 股票基本信息
|
||||||
|
export interface Stock {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
change: number;
|
||||||
|
changePercent: number;
|
||||||
|
volume: number;
|
||||||
|
marketCap: number;
|
||||||
|
industry: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 市场概况
|
||||||
|
export interface MarketOverview {
|
||||||
|
upCount: number;
|
||||||
|
downCount: number;
|
||||||
|
flatCount: number;
|
||||||
|
limitUpCount: number;
|
||||||
|
limitDownCount: number;
|
||||||
|
updateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指数信息
|
||||||
|
export interface MarketIndex {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
change: number;
|
||||||
|
changePercent: number;
|
||||||
|
volume: number;
|
||||||
|
upCount: number;
|
||||||
|
downCount: number;
|
||||||
|
flatCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 情绪指标
|
||||||
|
export interface SentimentData {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动量数据
|
||||||
|
export interface MomentumData {
|
||||||
|
sector: string;
|
||||||
|
changePercent: number;
|
||||||
|
momentum: number;
|
||||||
|
stocks: string[];
|
||||||
|
upCount: number;
|
||||||
|
downCount: number;
|
||||||
|
flatCount: number;
|
||||||
|
topStocks: Stock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新高新低股票
|
||||||
|
export interface HighLowStock {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
changePercent: number;
|
||||||
|
highLowPrice: number;
|
||||||
|
industry: string;
|
||||||
|
daysToHighLow: number; // 距离新高/新低的天数
|
||||||
|
}
|
||||||
|
|
||||||
|
// 涨跌幅分布
|
||||||
|
export interface PriceDistribution {
|
||||||
|
range: string;
|
||||||
|
count: number;
|
||||||
|
isUp: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI分析结果
|
||||||
|
export interface AIAnalysis {
|
||||||
|
stockCode: string;
|
||||||
|
stockName: string;
|
||||||
|
currentPrice: number;
|
||||||
|
changePercent: number;
|
||||||
|
insights: string;
|
||||||
|
recommendation: 'buy' | 'sell' | 'hold';
|
||||||
|
recommendationText: string;
|
||||||
|
trend: 'up' | 'down' | 'sideways';
|
||||||
|
trendText: string;
|
||||||
|
targetPrice: {
|
||||||
|
idealBuy: number | null;
|
||||||
|
secondBuy: number | null;
|
||||||
|
stopLoss: number | null;
|
||||||
|
takeProfit: number | null;
|
||||||
|
};
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// K线数据
|
||||||
|
export interface KLineData {
|
||||||
|
date: string;
|
||||||
|
open: number;
|
||||||
|
high: number;
|
||||||
|
low: number;
|
||||||
|
close: number;
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新闻资讯
|
||||||
|
export interface NewsItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
source: string;
|
||||||
|
time: string;
|
||||||
|
sentiment: 'positive' | 'negative' | 'neutral';
|
||||||
|
sentimentText: string;
|
||||||
|
isHot?: boolean;
|
||||||
|
readCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 热点新闻
|
||||||
|
export interface HotNews {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
heat: number; // 热度值
|
||||||
|
relatedStocks: string[];
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据源
|
||||||
|
export interface DataSource {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
isAvailable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实时订阅主题
|
||||||
|
export const SubscribeTopic = {
|
||||||
|
MARKET_OVERVIEW: 'market_overview',
|
||||||
|
STOCK_PRICE: 'stock_price',
|
||||||
|
SENTIMENT: 'sentiment',
|
||||||
|
MOMENTUM: 'momentum',
|
||||||
|
NEWS: 'news'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SubscribeTopicType = typeof SubscribeTopic[keyof typeof SubscribeTopic];
|
||||||
|
|
||||||
|
// WebSocket消息
|
||||||
|
export interface WSMessage {
|
||||||
|
topic: SubscribeTopicType;
|
||||||
|
data: any;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 狙击点位
|
||||||
|
export interface StrategyPoint {
|
||||||
|
label: string;
|
||||||
|
value: number | null;
|
||||||
|
type: 'buy' | 'sell' | 'stop' | 'target';
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 情绪趋势
|
||||||
|
export interface SentimentTrend {
|
||||||
|
date: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue