first commit
This commit is contained in:
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# Node modules
|
||||
node_modules/
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
out/
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# Mac / Linux / Windows system files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary
|
||||
*.tmp
|
||||
11
README.md
Normal file
11
README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
# (PR) KLC Learners Portal
|
||||
|
||||
This is a code bundle for (PR) KLC Learners Portal. The original project is available at https://www.figma.com/design/skyhldFw1yua0kljiJ4Iba/-PR--KLC-Learners-Portal.
|
||||
|
||||
## Running the code
|
||||
|
||||
Run `npm i` to install the dependencies.
|
||||
|
||||
Run `npm run dev` to start the development server.
|
||||
|
||||
15
index.html
Normal file
15
index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>(PR) KLC Learners Portal</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
4179
package-lock.json
generated
Normal file
4179
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
63
package.json
Normal file
63
package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "(PR) KLC Learners Portal",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
"@radix-ui/react-context-menu": "^2.2.6",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-hover-card": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-menubar": "^1.1.6",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.5",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-progress": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.2.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "*",
|
||||
"cmdk": "^1.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.487.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "^2.15.2",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "*",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"vite": "6.3.5"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
}
|
||||
}
|
||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
}
|
||||
}
|
||||
150
src/App.tsx
Normal file
150
src/App.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AppShell } from './components/AppShell';
|
||||
import { MyCourses } from './components/MyCourses';
|
||||
import { Leaderboard } from './components/Leaderboard';
|
||||
import { ReportsAndCertificates } from './components/ReportsAndCertificates';
|
||||
import { Notes } from './components/Notes';
|
||||
import { AIMentor } from './components/AIMentor';
|
||||
import { Blog } from './components/Blog';
|
||||
import { DiscussionForums } from './components/DiscussionForums';
|
||||
import { Notifications } from './components/Notifications';
|
||||
import { Settings } from './components/Settings';
|
||||
import { CourseDetailPage } from './components/CourseDetailPage';
|
||||
import { ProgramDetailPage } from './components/ProgramDetailPage';
|
||||
import { WebinarDetailPage } from './components/WebinarDetailPage';
|
||||
import { OfflineVideoDetailPage } from './components/OfflineVideoDetailPage';
|
||||
import { DocumentDetailPage } from './components/DocumentDetailPage';
|
||||
|
||||
// Mock user data - in real app this would come from authentication
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
firstName: 'Priya',
|
||||
lastName: 'Sharma',
|
||||
email: 'priya.sharma@techsolutions.com',
|
||||
persona: 'corporate' as const,
|
||||
orgName: 'Tech Solutions Pvt Ltd',
|
||||
canSwitchMode: true,
|
||||
canSwitchAccount: true,
|
||||
avatar: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?w=64&h=64&fit=crop&crop=face'
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [currentPage, setCurrentPage] = useState('my-courses');
|
||||
const [selectedContentId, setSelectedContentId] = useState<string | null>(null);
|
||||
const [contentType, setContentType] = useState<'course' | 'program' | 'webinar' | 'video' | 'document'>('course');
|
||||
|
||||
const handleNavigateToContent = (contentId: string, type: 'course' | 'program' | 'webinar' | 'video' | 'document') => {
|
||||
setSelectedContentId(contentId);
|
||||
setContentType(type);
|
||||
setCurrentPage(`${type}-detail`);
|
||||
};
|
||||
|
||||
const handleNavigateBack = () => {
|
||||
setCurrentPage('my-courses');
|
||||
setSelectedContentId(null);
|
||||
};
|
||||
|
||||
const renderCurrentPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'my-courses':
|
||||
return <MyCourses user={mockUser} onNavigateToContent={handleNavigateToContent} />;
|
||||
case 'course-detail':
|
||||
return selectedContentId ? (
|
||||
<CourseDetailPage
|
||||
courseId={selectedContentId}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
/>
|
||||
) : (
|
||||
<MyCourses user={mockUser} onNavigateToContent={handleNavigateToContent} />
|
||||
);
|
||||
case 'program-detail':
|
||||
return selectedContentId ? (
|
||||
<ProgramDetailPage
|
||||
programId={selectedContentId}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
/>
|
||||
) : (
|
||||
<MyCourses user={mockUser} onNavigateToContent={handleNavigateToContent} />
|
||||
);
|
||||
case 'webinar-detail':
|
||||
return selectedContentId ? (
|
||||
<WebinarDetailPage
|
||||
webinarId={selectedContentId}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
/>
|
||||
) : (
|
||||
<MyCourses user={mockUser} onNavigateToContent={handleNavigateToContent} />
|
||||
);
|
||||
case 'video-detail':
|
||||
return selectedContentId ? (
|
||||
<OfflineVideoDetailPage
|
||||
videoId={selectedContentId}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
/>
|
||||
) : (
|
||||
<MyCourses user={mockUser} onNavigateToContent={handleNavigateToContent} />
|
||||
);
|
||||
case 'document-detail':
|
||||
return selectedContentId ? (
|
||||
<DocumentDetailPage
|
||||
documentId={selectedContentId}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
/>
|
||||
) : (
|
||||
<MyCourses user={mockUser} onNavigateToContent={handleNavigateToContent} />
|
||||
);
|
||||
case 'leaderboard':
|
||||
return <Leaderboard user={mockUser} />;
|
||||
case 'reports-certificates':
|
||||
return <ReportsAndCertificates />;
|
||||
case 'notes':
|
||||
return <Notes />;
|
||||
case 'blog':
|
||||
return <Blog />;
|
||||
case 'forums':
|
||||
return <DiscussionForums user={mockUser} />;
|
||||
case 'notifications':
|
||||
return <Notifications />;
|
||||
case 'settings':
|
||||
return <Settings user={mockUser} />;
|
||||
default:
|
||||
return <MyCourses user={mockUser} onNavigateToContent={handleNavigateToContent} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<AppShell
|
||||
currentPage={currentPage}
|
||||
user={mockUser}
|
||||
onNavigate={setCurrentPage}
|
||||
>
|
||||
{renderCurrentPage()}
|
||||
</AppShell>
|
||||
|
||||
{/* AI Mentor FAB - Always visible */}
|
||||
<AIMentor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Placeholder component for unimplemented pages
|
||||
function PlaceholderPage({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-medium">{title}</h1>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-12 text-center">
|
||||
<h2 className="text-xl font-medium text-muted-foreground mb-2">
|
||||
{title} Coming Soon
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
This feature is currently under development and will be available soon.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/Attributions.md
Normal file
3
src/Attributions.md
Normal file
@@ -0,0 +1,3 @@
|
||||
This Figma Make file includes components from [shadcn/ui](https://ui.shadcn.com/) used under [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
|
||||
|
||||
This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license).
|
||||
BIN
src/assets/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png
Normal file
BIN
src/assets/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/6b17aafb4d0b31099f8eec7b69e7d0a8b29ad00f.png
Normal file
BIN
src/assets/6b17aafb4d0b31099f8eec7b69e7d0a8b29ad00f.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
437
src/components/AIMentor.tsx
Normal file
437
src/components/AIMentor.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Send,
|
||||
Bot,
|
||||
User,
|
||||
Lightbulb,
|
||||
BookOpen,
|
||||
Target,
|
||||
TrendingUp,
|
||||
MessageCircle,
|
||||
X,
|
||||
Youtube,
|
||||
FileText,
|
||||
Headphones,
|
||||
ExternalLink,
|
||||
Upload,
|
||||
Edit3
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Badge } from './ui/badge';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Separator } from './ui/separator';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Textarea } from './ui/textarea';
|
||||
|
||||
// Mock conversation data
|
||||
const mockConversation = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'assistant',
|
||||
content: "Hello! I'm your KLC Assistant. I'm here to help you with your learning journey. How can I assist you today?",
|
||||
timestamp: '2024-09-03T09:00:00Z'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'user',
|
||||
content: "What should I do next in my learning path?",
|
||||
timestamp: '2024-09-03T09:01:00Z'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'assistant',
|
||||
content: "Based on your current progress in the Strategic Leadership Development course, I recommend:\n\n1. **Complete Module 3, Lesson 2** - You're 65% through 'Risk Assessment Strategies'\n2. **Review your notes** - You have 4 notes that could benefit from additional practice\n3. **Join the upcoming webinar** - 'Future of Leadership in Digital Age' on Sep 15th\n\nWould you like me to explain any specific concept from your current lesson?",
|
||||
timestamp: '2024-09-03T09:02:00Z'
|
||||
}
|
||||
];
|
||||
|
||||
const quickChips = [
|
||||
{ id: '1', text: "What should I do next?", icon: Target },
|
||||
{ id: '2', text: "Explain my Module 3 gap", icon: BookOpen },
|
||||
{ id: '3', text: "Review my progress", icon: TrendingUp },
|
||||
{ id: '4', text: "Find YouTube videos", icon: Youtube },
|
||||
{ id: '5', text: "Get feedback on my notes", icon: Edit3 },
|
||||
{ id: '6', text: "Leadership challenge help", icon: Lightbulb }
|
||||
];
|
||||
|
||||
const mockRecommendations = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'youtube',
|
||||
title: 'KLC Leadership Interview: Digital Transformation',
|
||||
description: 'CEO discusses modern leadership challenges in digital transformation',
|
||||
url: 'https://youtube.com/watch?v=example1',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=400&h=200&fit=crop'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'article',
|
||||
title: 'Research: Emotional Intelligence in Leadership',
|
||||
description: 'Latest findings on EQ impact on team performance and organizational outcomes',
|
||||
url: 'https://klc.edu/research/emotional-intelligence-2024',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=200&fit=crop'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'podcast',
|
||||
title: 'Future Leaders Podcast: Episode 24',
|
||||
description: 'Discussion on adaptive leadership strategies with industry experts',
|
||||
url: 'https://podcasts.klc.edu/future-leaders-24',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1535015853489-4c0e8b7cd34e?w=400&h=200&fit=crop'
|
||||
}
|
||||
];
|
||||
|
||||
export function AIMentor() {
|
||||
const [messages, setMessages] = useState(mockConversation);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('chat');
|
||||
const [notesFeedback, setNotesFeedback] = useState('');
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputValue.trim()) return;
|
||||
|
||||
const newUserMessage = {
|
||||
id: Date.now().toString(),
|
||||
type: 'user' as const,
|
||||
content: inputValue,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, newUserMessage]);
|
||||
setInputValue('');
|
||||
setIsTyping(true);
|
||||
|
||||
// Enhanced AI response with recommendations
|
||||
setTimeout(() => {
|
||||
let responseContent = "I understand your question. Let me provide you with a helpful response based on your learning context and progress.";
|
||||
|
||||
// Add contextual recommendations based on query
|
||||
if (inputValue.toLowerCase().includes('youtube') || inputValue.toLowerCase().includes('video')) {
|
||||
responseContent += "\n\nHere are some relevant KLC YouTube interviews I'd recommend:\n• Digital Transformation Leadership with Sarah Chen\n• Emotional Intelligence Masterclass with Dr. Kumar\n• Crisis Leadership Strategies - Executive Panel";
|
||||
} else if (inputValue.toLowerCase().includes('research') || inputValue.toLowerCase().includes('article')) {
|
||||
responseContent += "\n\nBased on your current learning path, here are relevant research articles:\n• 'Adaptive Leadership in Remote Teams' - Harvard Business Review\n• 'Data-Driven Decision Making for Leaders' - KLC Research\n• 'Building Psychological Safety' - Stanford Leadership Institute";
|
||||
} else if (inputValue.toLowerCase().includes('leadership challenge')) {
|
||||
responseContent += "\n\nFor leadership challenges, I recommend:\n1. Review Module 3 content on conflict resolution\n2. Practice scenario planning with the KLC Leadership Simulator\n3. Connect with mentor Dr. Patel for personalized guidance\n\nWould you like me to schedule a mentoring session?";
|
||||
}
|
||||
|
||||
const aiResponse = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
type: 'assistant' as const,
|
||||
content: responseContent,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, aiResponse]);
|
||||
setIsTyping(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleNotesFeedback = async () => {
|
||||
if (!notesFeedback.trim()) return;
|
||||
|
||||
setIsTyping(true);
|
||||
// Simulate AI feedback processing
|
||||
setTimeout(() => {
|
||||
const feedbackResponse = {
|
||||
id: Date.now().toString(),
|
||||
type: 'assistant' as const,
|
||||
content: `I've analyzed your notes. Here's my feedback:\n\n**Strengths:**\n• Clear structure and logical flow\n• Good use of real-world examples\n• Well-connected to course concepts\n\n**Suggestions:**\n• Consider adding more specific metrics/data\n• Link to Module 4 content on implementation\n• Try the STAR method for case study format\n\n**Learning Resources:**\n• KLC Note-Taking Best Practices Guide\n• Strategic Thinking Framework Workshop\n\nWould you like me to suggest related courses based on your notes?`,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, feedbackResponse]);
|
||||
setNotesFeedback('');
|
||||
setIsTyping(false);
|
||||
setActiveTab('chat');
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleChipClick = (chipText: string) => {
|
||||
setInputValue(chipText);
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleTimeString('en-IN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Asia/Kolkata'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Action Button */}
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-14 h-14 rounded-full bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary)]/90 text-white shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
size="icon"
|
||||
>
|
||||
<Bot className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Chat Panel with Tabs */}
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<SheetContent side="right" className="w-[400px] sm:w-[500px] p-0 bg-white">
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<SheetHeader className="px-6 py-4 border-b bg-[var(--color-brand-primary)] text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center">
|
||||
<Bot className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<SheetTitle className="text-white text-lg">KLC Assistant</SheetTitle>
|
||||
<p className="text-blue-100 text-sm">Strategic Leadership Development - Module 3</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col">
|
||||
<TabsList className="mx-6 my-4 grid w-full grid-cols-3">
|
||||
<TabsTrigger value="chat" className="text-xs">Chat</TabsTrigger>
|
||||
<TabsTrigger value="resources" className="text-xs">Resources</TabsTrigger>
|
||||
<TabsTrigger value="feedback" className="text-xs">Feedback</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="chat" className="flex-1 flex flex-col m-0">
|
||||
{/* Quick Action Chips */}
|
||||
<div className="px-6 py-4 border-b bg-gray-50">
|
||||
<p className="text-sm font-medium mb-3">Quick questions:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickChips.map((chip) => {
|
||||
const Icon = chip.icon;
|
||||
return (
|
||||
<Button
|
||||
key={chip.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-8"
|
||||
onClick={() => handleChipClick(chip.text)}
|
||||
>
|
||||
<Icon className="h-3 w-3 mr-1" />
|
||||
{chip.text}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<ScrollArea className="flex-1 px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex gap-3 ${
|
||||
message.type === 'user' ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
{message.type === 'assistant' && (
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--color-brand-primary)] flex items-center justify-center flex-shrink-0">
|
||||
<Bot className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`max-w-[80%] p-3 rounded-lg ${
|
||||
message.type === 'user'
|
||||
? 'bg-[var(--color-brand-primary)] text-white'
|
||||
: 'bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-line text-sm">{message.content}</p>
|
||||
<p className={`text-xs mt-1 ${
|
||||
message.type === 'user' ? 'text-blue-100' : 'text-gray-600'
|
||||
}`}>
|
||||
{formatTime(message.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message.type === 'user' && (
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--color-brand-accent)] flex items-center justify-center flex-shrink-0">
|
||||
<User className="h-4 w-4 text-[var(--color-brand-black)]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isTyping && (
|
||||
<div className="flex gap-3 justify-start">
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--color-brand-primary)] flex items-center justify-center">
|
||||
<Bot className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div className="bg-gray-100 p-3 rounded-lg">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="px-6 py-4 border-t bg-white">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Ask me anything..."
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSendMessage();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-white"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!inputValue.trim() || isTyping}
|
||||
className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary)]/90 text-white"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-2">
|
||||
Press Enter to send, Esc to close
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="resources" className="flex-1 m-0">
|
||||
<ScrollArea className="h-full px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">Recommended for You</h3>
|
||||
<div className="space-y-3">
|
||||
{mockRecommendations.map((rec) => (
|
||||
<Card key={rec.id} className="bg-white border">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex gap-3">
|
||||
<img src={rec.thumbnail} alt={rec.title} className="w-16 h-12 object-cover rounded" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{rec.type === 'youtube' && <Youtube className="h-3 w-3 text-red-500" />}
|
||||
{rec.type === 'article' && <FileText className="h-3 w-3 text-blue-500" />}
|
||||
{rec.type === 'podcast' && <Headphones className="h-3 w-3 text-green-500" />}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{rec.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<h4 className="font-medium text-sm line-clamp-2 mb-1">{rec.title}</h4>
|
||||
<p className="text-xs text-gray-600 line-clamp-2 mb-2">{rec.description}</p>
|
||||
<Button size="sm" variant="outline" className="text-xs h-6">
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">External References</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-50 rounded">
|
||||
<FileText className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Harvard Business Review</p>
|
||||
<p className="text-xs text-gray-600">Leadership in Crisis Management</p>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" className="text-xs h-6">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-50 rounded">
|
||||
<Youtube className="h-4 w-4 text-red-500" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">MIT Leadership Center</p>
|
||||
<p className="text-xs text-gray-600">Digital Leadership Strategies</p>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" className="text-xs h-6">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feedback" className="flex-1 m-0">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h3 className="font-medium mb-2">Get AI Feedback</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Upload your notes, case studies, or learning reflections for personalized feedback.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Your Notes/Case Study</label>
|
||||
<Textarea
|
||||
placeholder="Paste your notes, case study, or reflection here..."
|
||||
value={notesFeedback}
|
||||
onChange={(e) => setNotesFeedback(e.target.value)}
|
||||
className="min-h-[200px] bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleNotesFeedback}
|
||||
disabled={!notesFeedback.trim() || isTyping}
|
||||
className="flex-1 bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary)]/90 text-white"
|
||||
>
|
||||
Get AI Feedback
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title="Upload file"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
<p>• Get suggestions for improvement</p>
|
||||
<p>• Receive learning resource recommendations</p>
|
||||
<p>• Connect insights to KLC framework</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
325
src/components/AppShell.tsx
Normal file
325
src/components/AppShell.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import React, { useState, useContext, createContext, useEffect } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
Search,
|
||||
Menu,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
BookOpen,
|
||||
Library,
|
||||
Award,
|
||||
Trophy,
|
||||
StickyNote,
|
||||
Bot,
|
||||
PenTool,
|
||||
Settings,
|
||||
User,
|
||||
LogOut,
|
||||
Building2,
|
||||
UserCircle2,
|
||||
ChevronDown,
|
||||
MessageCircle,
|
||||
GraduationCap
|
||||
} from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Badge } from './ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from './ui/dropdown-menu';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
import { Separator } from './ui/separator';
|
||||
import { ImageWithFallback } from './figma/ImageWithFallback';
|
||||
import { GlobalSearch } from './GlobalSearch';
|
||||
import { ProfileSwitchDropdown } from './ProfileSwitchDropdown';
|
||||
import klcLogo from 'figma:asset/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png';
|
||||
|
||||
// Persona and user context
|
||||
interface User {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
persona: 'corporate' | 'individual';
|
||||
orgName?: string;
|
||||
canSwitchMode?: boolean;
|
||||
canSwitchAccount?: boolean;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface AppContextType {
|
||||
user: User;
|
||||
currentMode: 'corporate' | 'hr' | 'individual';
|
||||
currentAccount: 'corporate' | 'personal';
|
||||
sidebarCollapsed: boolean;
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
switchMode: (mode: 'corporate' | 'hr') => void;
|
||||
switchAccount: (account: 'corporate' | 'personal') => void;
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppContextType | null>(null);
|
||||
|
||||
export const useAppContext = () => {
|
||||
const context = useContext(AppContext);
|
||||
if (!context) throw new Error('useAppContext must be used within AppShell');
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
currentPage: string;
|
||||
user: User;
|
||||
onNavigate?: (page: string) => void;
|
||||
}
|
||||
|
||||
export function AppShell({ children, currentPage, user, onNavigate }: AppShellProps) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [currentMode, setCurrentMode] = useState<'corporate' | 'hr' | 'individual'>(
|
||||
user.persona === 'corporate' ? 'corporate' : 'individual'
|
||||
);
|
||||
const [currentAccount, setCurrentAccount] = useState<'corporate' | 'personal'>('corporate');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
const switchMode = (mode: 'corporate' | 'hr') => {
|
||||
setCurrentMode(mode);
|
||||
};
|
||||
|
||||
const switchAccount = (account: 'corporate' | 'personal') => {
|
||||
setCurrentAccount(account);
|
||||
};
|
||||
|
||||
const contextValue: AppContextType = {
|
||||
user,
|
||||
currentMode,
|
||||
currentAccount,
|
||||
sidebarCollapsed,
|
||||
setSidebarCollapsed,
|
||||
switchMode,
|
||||
switchAccount
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
id: 'my-courses',
|
||||
label: 'My Courses',
|
||||
icon: GraduationCap,
|
||||
href: '/my-courses',
|
||||
available: ['corporate', 'individual']
|
||||
},
|
||||
{
|
||||
id: 'leaderboard',
|
||||
label: 'Leaderboard',
|
||||
icon: Trophy,
|
||||
href: '/leaderboard',
|
||||
available: ['corporate', 'individual']
|
||||
},
|
||||
{
|
||||
id: 'reports-certificates',
|
||||
label: 'Reports & Certificates',
|
||||
icon: Award,
|
||||
href: '/reports-certificates',
|
||||
available: ['corporate', 'individual']
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
label: 'Notes',
|
||||
icon: StickyNote,
|
||||
href: '/notes',
|
||||
available: ['corporate', 'individual']
|
||||
},
|
||||
{
|
||||
id: 'blog',
|
||||
label: 'Blog',
|
||||
icon: PenTool,
|
||||
href: '/blog',
|
||||
available: ['corporate', 'individual']
|
||||
},
|
||||
{
|
||||
id: 'forums',
|
||||
label: 'Discussion Forums',
|
||||
icon: MessageCircle,
|
||||
href: '/forums',
|
||||
available: ['corporate', 'individual']
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
icon: Settings,
|
||||
href: '/settings',
|
||||
available: ['corporate', 'individual']
|
||||
}
|
||||
];
|
||||
|
||||
const handleSearch = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === '/') {
|
||||
e.preventDefault();
|
||||
setSearchOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchClick = () => {
|
||||
setSearchOpen(true);
|
||||
};
|
||||
|
||||
// Global keyboard shortcut
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === '/' && !searchOpen) {
|
||||
e.preventDefault();
|
||||
setSearchOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [searchOpen]);
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={contextValue}>
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Top Bar */}
|
||||
<header className="h-16 border-b border-border bg-white flex items-center px-4 sticky top-0 z-50">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageWithFallback
|
||||
src={klcLogo}
|
||||
alt="Kautilya Leadership Centre (KLC)"
|
||||
className="h-8 w-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Universal Search */}
|
||||
<div className="relative max-w-md flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left font-normal bg-white"
|
||||
onClick={handleSearchClick}
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Search... (Press '/' to focus)</span>
|
||||
<kbd className="ml-auto pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100">
|
||||
/
|
||||
</kbd>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Notifications */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative"
|
||||
onClick={() => onNavigate?.('notifications')}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
<Badge className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs bg-[var(--color-brand-accent)] text-[var(--color-brand-black)]">
|
||||
3
|
||||
</Badge>
|
||||
</Button>
|
||||
|
||||
{/* User Menu */}
|
||||
<ProfileSwitchDropdown
|
||||
user={user}
|
||||
currentMode={currentMode as 'corporate' | 'hr'}
|
||||
currentAccount={currentAccount}
|
||||
onModeSwitch={switchMode}
|
||||
onAccountSwitch={switchAccount}
|
||||
onNavigate={onNavigate || (() => {})}
|
||||
onSignOut={() => {
|
||||
// Handle sign out logic here
|
||||
console.log('User signed out');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`${
|
||||
sidebarCollapsed ? 'w-16' : 'w-64'
|
||||
} transition-all duration-200 border-r border-border bg-sidebar h-[calc(100vh-4rem)] sticky top-16 overflow-hidden`}
|
||||
>
|
||||
{/* Sidebar Toggle */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-sidebar-border">
|
||||
{!sidebarCollapsed && (
|
||||
<span className="font-medium text-sidebar-foreground">Navigation</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigation Items */}
|
||||
<nav className="p-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentPage === item.id;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="mb-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full justify-start h-10 px-3 transition-colors ${
|
||||
sidebarCollapsed ? 'px-2' : 'px-3'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary border border-primary/20 hover:bg-primary/15'
|
||||
: 'hover:bg-muted'
|
||||
}`}
|
||||
title={sidebarCollapsed ? item.label : undefined}
|
||||
onClick={() => onNavigate?.(item.id)}
|
||||
>
|
||||
<Icon className={`h-4 w-4 ${sidebarCollapsed ? '' : 'mr-3'}`} />
|
||||
{!sidebarCollapsed && (
|
||||
<span className="flex-1 text-left">{item.label}</span>
|
||||
)}
|
||||
{!sidebarCollapsed && item.badge && (
|
||||
<Badge variant="secondary" className="ml-auto text-xs">
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-x-hidden">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Global Search Modal */}
|
||||
<GlobalSearch
|
||||
isOpen={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
onOpenAIMentor={() => {
|
||||
setSearchOpen(false);
|
||||
// This would trigger AI Mentor opening
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
</div>
|
||||
</AppContext.Provider>
|
||||
);
|
||||
}
|
||||
590
src/components/Blog.tsx
Normal file
590
src/components/Blog.tsx
Normal file
@@ -0,0 +1,590 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Eye,
|
||||
Trash2,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Upload,
|
||||
Image,
|
||||
Video,
|
||||
BookOpen,
|
||||
TrendingUp,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from './ui/dialog';
|
||||
import { Label } from './ui/label';
|
||||
import { useAppContext } from './AppShell';
|
||||
|
||||
// Mock blog submissions data
|
||||
const mockBlogData = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'The Future of Remote Leadership: Lessons from the Pandemic',
|
||||
excerpt: 'Exploring how the COVID-19 pandemic transformed leadership approaches and what lessons we can carry forward...',
|
||||
content: 'Full article content would be here...',
|
||||
status: 'published',
|
||||
submittedAt: '2024-08-15T10:00:00Z',
|
||||
publishedAt: '2024-08-18T14:00:00Z',
|
||||
views: 1247,
|
||||
tags: ['Remote Leadership', 'Digital Transformation', 'Crisis Management']
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Building Resilient Teams in Uncertain Times',
|
||||
excerpt: 'Strategies for developing team resilience and maintaining high performance during periods of uncertainty...',
|
||||
content: 'Full article content would be here...',
|
||||
status: 'under-review',
|
||||
submittedAt: '2024-09-01T15:30:00Z',
|
||||
tags: ['Team Building', 'Resilience', 'Leadership']
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Emotional Intelligence in Modern Leadership',
|
||||
excerpt: 'Why EQ matters more than ever in today\'s complex business environment and how to develop it...',
|
||||
content: 'Full article content would be here...',
|
||||
status: 'rejected',
|
||||
submittedAt: '2024-08-20T09:15:00Z',
|
||||
rejectedAt: '2024-08-22T11:00:00Z',
|
||||
rejectionReason: 'Content needs more original research and citations. Please add specific examples and data to support your claims.',
|
||||
tags: ['Emotional Intelligence', 'Leadership Development']
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Sustainable Leadership Practices',
|
||||
excerpt: 'Draft exploring how leaders can integrate sustainability into their decision-making processes...',
|
||||
content: 'Full article content would be here...',
|
||||
status: 'draft',
|
||||
updatedAt: '2024-09-02T16:45:00Z',
|
||||
tags: ['Sustainability', 'Strategic Leadership']
|
||||
}
|
||||
];
|
||||
|
||||
export function Blog() {
|
||||
const { user } = useAppContext();
|
||||
const [activeTab, setActiveTab] = useState('my-posts');
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingPost, setEditingPost] = useState<any>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
excerpt: '',
|
||||
content: '',
|
||||
tags: ''
|
||||
});
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published': return CheckCircle;
|
||||
case 'under-review': return Clock;
|
||||
case 'rejected': return XCircle;
|
||||
case 'draft': return Edit;
|
||||
default: return AlertCircle;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published': return 'bg-green-100 text-green-800';
|
||||
case 'under-review': return 'bg-blue-100 text-blue-800';
|
||||
case 'rejected': return 'bg-red-100 text-red-800';
|
||||
case 'draft': return 'bg-gray-100 text-gray-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'Asia/Kolkata'
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// TODO: Submit blog post
|
||||
console.log('Submitting:', formData);
|
||||
setIsDialogOpen(false);
|
||||
setFormData({ title: '', excerpt: '', content: '', tags: '' });
|
||||
setEditingPost(null);
|
||||
};
|
||||
|
||||
const BlogPostCard = ({ post }: { post: any }) => {
|
||||
const StatusIcon = getStatusIcon(post.status);
|
||||
|
||||
return (
|
||||
<Card className="group hover:shadow-lg transition-all duration-300 border border-gray-200 bg-white">
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-[#04045B] to-[#030344] flex items-center justify-center flex-shrink-0">
|
||||
<BookOpen className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className={`h-4 w-4 ${
|
||||
post.status === 'published' ? 'text-green-600' :
|
||||
post.status === 'under-review' ? 'text-blue-600' :
|
||||
post.status === 'rejected' ? 'text-red-600' : 'text-gray-600'
|
||||
}`} />
|
||||
<Badge className={`capitalize font-medium ${getStatusColor(post.status)} border-0`}>
|
||||
{post.status.replace('-', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 leading-tight">{post.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingPost(post);
|
||||
setFormData({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
content: post.content,
|
||||
tags: post.tags.join(', ')
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
|
||||
{post.status === 'published' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2 text-red-600 hover:text-red-700 hover:border-red-200 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Excerpt */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100">
|
||||
<p className="text-sm text-gray-700 leading-relaxed line-clamp-2">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.tags.map((tag: string) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="text-xs font-medium bg-[#F8C301]/10 text-[#04045B] border-[#F8C301]/30"
|
||||
>
|
||||
#{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600 bg-blue-50 p-3 rounded-lg border border-blue-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-gray-400" />
|
||||
<span>Submitted {formatDate(post.submittedAt)}</span>
|
||||
</div>
|
||||
|
||||
{post.publishedAt && (
|
||||
<>
|
||||
<span className="text-gray-400">•</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<span>Published {formatDate(post.publishedAt)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{post.views && (
|
||||
<>
|
||||
<span className="text-gray-400">•</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-blue-500" />
|
||||
<span>{post.views} views</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rejection Reason */}
|
||||
{post.rejectionReason && (
|
||||
<div className="bg-red-50 border border-red-200 p-4 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<XCircle className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-red-800">Rejection Reason:</p>
|
||||
<p className="text-sm text-red-700 leading-relaxed">{post.rejectionReason}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8 bg-white min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold text-gray-900">Leadership Blog</h1>
|
||||
<p className="text-gray-600">
|
||||
Share your leadership insights and experiences with the community
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingPost(null);
|
||||
setFormData({ title: '', excerpt: '', content: '', tags: '' });
|
||||
}}
|
||||
className="bg-[#04045B] hover:bg-[#030344] text-white px-6 py-2.5"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Write Article
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingPost ? 'Edit Article' : 'Write New Article'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="Enter article title..."
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="excerpt">Excerpt</Label>
|
||||
<Textarea
|
||||
id="excerpt"
|
||||
placeholder="Brief summary of your article..."
|
||||
className="h-20"
|
||||
value={formData.excerpt}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, excerpt: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content">Content</Label>
|
||||
<div className="border rounded-lg">
|
||||
<div className="flex items-center gap-2 p-2 border-b bg-muted">
|
||||
<Button size="sm" variant="ghost">
|
||||
<Image className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost">
|
||||
<Video className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost">
|
||||
<Upload className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
Markdown supported
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="content"
|
||||
placeholder="Write your article content here. You can use markdown formatting and embed images/videos..."
|
||||
className="min-h-[300px] border-0 resize-none focus-visible:ring-0"
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, content: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tags">Tags</Label>
|
||||
<Input
|
||||
id="tags"
|
||||
placeholder="Leadership, Strategy, Team Building (comma separated)"
|
||||
value={formData.tags}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, tags: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Submission Guidelines</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>• Articles are reviewed by our editorial team before publication</li>
|
||||
<li>• Focus on original insights and practical leadership advice</li>
|
||||
<li>• Include specific examples and actionable takeaways</li>
|
||||
<li>• Maintain professional tone and cite sources when needed</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
{editingPost ? 'Update Article' : 'Submit for Review'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="hover:shadow-lg transition-all duration-300 border-0 shadow-sm bg-gradient-to-br from-white to-green-50/30">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center text-white shadow-lg">
|
||||
<CheckCircle className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-gray-900">{mockBlogData.filter(p => p.status === 'published').length}</p>
|
||||
<p className="text-sm text-gray-600 font-medium">Published</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="hover:shadow-lg transition-all duration-300 border-0 shadow-sm bg-gradient-to-br from-white to-blue-50/30">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white shadow-lg">
|
||||
<Clock className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-gray-900">{mockBlogData.filter(p => p.status === 'under-review').length}</p>
|
||||
<p className="text-sm text-gray-600 font-medium">Under Review</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="hover:shadow-lg transition-all duration-300 border-0 shadow-sm bg-gradient-to-br from-white to-gray-50/30">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-gray-500 to-gray-600 flex items-center justify-center text-white shadow-lg">
|
||||
<Edit className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-gray-900">{mockBlogData.filter(p => p.status === 'draft').length}</p>
|
||||
<p className="text-sm text-gray-600 font-medium">Drafts</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="hover:shadow-lg transition-all duration-300 border-0 shadow-sm bg-gradient-to-br from-white to-yellow-50/30">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-[#F8C301] to-yellow-500 flex items-center justify-center text-[#04045B] shadow-lg">
|
||||
<TrendingUp className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{mockBlogData.reduce((sum, post) => sum + (post.views || 0), 0)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 font-medium">Total Views</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-8">
|
||||
<TabsList className="grid w-full max-w-lg grid-cols-2 bg-gray-50/80 border border-gray-200 p-1 rounded-xl shadow-sm h-auto">
|
||||
<TabsTrigger
|
||||
value="my-posts"
|
||||
className="flex items-center gap-3 px-6 py-3 rounded-lg font-medium transition-all data-[state=active]:bg-white data-[state=active]:text-[#04045B] data-[state=active]:shadow-md data-[state=active]:border data-[state=active]:border-[#04045B]/10"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
My Submissions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="guidelines"
|
||||
className="flex items-center gap-3 px-6 py-3 rounded-lg font-medium transition-all data-[state=active]:bg-white data-[state=active]:text-[#04045B] data-[state=active]:shadow-md data-[state=active]:border data-[state=active]:border-[#04045B]/10"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
Guidelines
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="my-posts" className="space-y-6">
|
||||
{mockBlogData.length === 0 ? (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-[#04045B]/10 to-blue-50 rounded-full flex items-center justify-center">
|
||||
<BookOpen className="h-10 w-10 text-[#04045B]" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">No articles yet</h3>
|
||||
<p className="text-gray-600 mb-6 max-w-md mx-auto">
|
||||
Start sharing your leadership insights with the community
|
||||
</p>
|
||||
<Button className="bg-[#04045B] hover:bg-[#030344] text-white px-6 py-2.5">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Write Your First Article
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{mockBlogData.map((post) => (
|
||||
<BlogPostCard key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="guidelines">
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader className="pb-6">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#04045B] to-[#030344] flex items-center justify-center">
|
||||
<Users className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
Article Submission Guidelines
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-6">
|
||||
<div className="bg-white p-6 rounded-lg border border-gray-100 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-2 h-2 bg-[#04045B] rounded-full"></div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Content Requirements</h3>
|
||||
</div>
|
||||
<ul className="space-y-3 text-sm text-gray-700">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-1.5 h-1.5 bg-[#F8C301] rounded-full mt-2 flex-shrink-0" />
|
||||
<span className="font-medium">Minimum 800 words, maximum 3000 words</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-1.5 h-1.5 bg-[#F8C301] rounded-full mt-2 flex-shrink-0" />
|
||||
<span className="font-medium">Original content with practical leadership insights</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-1.5 h-1.5 bg-[#F8C301] rounded-full mt-2 flex-shrink-0" />
|
||||
<span className="font-medium">Include specific examples and actionable advice</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-1.5 h-1.5 bg-[#F8C301] rounded-full mt-2 flex-shrink-0" />
|
||||
<span className="font-medium">Cite sources and provide references where applicable</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg border border-gray-100 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-2 h-2 bg-[#04045B] rounded-full"></div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Review Process</h3>
|
||||
</div>
|
||||
<ul className="space-y-3 text-sm text-gray-700">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-1.5 h-1.5 bg-[#F8C301] rounded-full mt-2 flex-shrink-0" />
|
||||
<span className="font-medium">Articles are reviewed within 5-7 business days</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-1.5 h-1.5 bg-[#F8C301] rounded-full mt-2 flex-shrink-0" />
|
||||
<span className="font-medium">Editorial team may suggest revisions</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-1.5 h-1.5 bg-[#F8C301] rounded-full mt-2 flex-shrink-0" />
|
||||
<span className="font-medium">Approved articles are published on the KLC website</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-1.5 h-1.5 bg-[#F8C301] rounded-full mt-2 flex-shrink-0" />
|
||||
<span className="font-medium">Authors are notified of publication via email</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg border border-gray-100 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-2 h-2 bg-[#04045B] rounded-full"></div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Topics We're Looking For</h3>
|
||||
</div>
|
||||
<ul className="space-y-3 text-sm text-gray-700">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-1.5 h-1.5 bg-[#F8C301] rounded-full mt-2 flex-shrink-0" />
|
||||
<span className="font-medium">Strategic leadership and vision</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-1.5 h-1.5 bg-[#F8C301] rounded-full mt-2 flex-shrink-0" />
|
||||
<span className="font-medium">Team building and management</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-1.5 h-1.5 bg-[#F8C301] rounded-full mt-2 flex-shrink-0" />
|
||||
<span className="font-medium">Change management and innovation</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-1.5 h-1.5 bg-[#F8C301] rounded-full mt-2 flex-shrink-0" />
|
||||
<span className="font-medium">Digital transformation leadership</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-1.5 h-1.5 bg-[#F8C301] rounded-full mt-2 flex-shrink-0" />
|
||||
<span className="font-medium">Emotional intelligence and soft skills</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-1.5 h-1.5 bg-[#F8C301] rounded-full mt-2 flex-shrink-0" />
|
||||
<span className="font-medium">Industry-specific leadership challenges</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
881
src/components/CourseDetailPage.tsx
Normal file
881
src/components/CourseDetailPage.tsx
Normal file
@@ -0,0 +1,881 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Progress } from './ui/progress';
|
||||
import { LeadershipProfiler } from './LeadershipProfiler';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CheckCircle,
|
||||
Check,
|
||||
Play,
|
||||
Pause,
|
||||
Volume2,
|
||||
Maximize,
|
||||
Calendar,
|
||||
Clock,
|
||||
BookOpen,
|
||||
FileText,
|
||||
Video,
|
||||
Target,
|
||||
User,
|
||||
MessageSquare,
|
||||
StickyNote,
|
||||
Info,
|
||||
BarChart3,
|
||||
Users,
|
||||
Globe,
|
||||
GraduationCap,
|
||||
Award,
|
||||
Smartphone
|
||||
} from 'lucide-react';
|
||||
|
||||
// Data interfaces
|
||||
interface CourseData {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
week: number;
|
||||
totalWeeks: number;
|
||||
progress: number;
|
||||
category: string;
|
||||
description: string;
|
||||
instructor: {
|
||||
name: string;
|
||||
title: string;
|
||||
bio: string;
|
||||
avatar: string;
|
||||
};
|
||||
learningOutcomes: string[];
|
||||
modules: Module[];
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
title: string;
|
||||
lessons: Lesson[];
|
||||
isExpanded: boolean;
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
interface Lesson {
|
||||
id: string;
|
||||
title: string;
|
||||
duration: string;
|
||||
isCompleted: boolean;
|
||||
isActive: boolean;
|
||||
type: 'video' | 'quiz' | 'reading' | 'assignment' | 'profiler' | 'lab';
|
||||
videoUrl?: string;
|
||||
content: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
// Mock course database
|
||||
const courseDatabase: Record<string, CourseData> = {
|
||||
'course-1': {
|
||||
id: 'course-1',
|
||||
title: 'Building High-Performance Teams',
|
||||
subtitle: 'Understanding Communication Styles',
|
||||
week: 2,
|
||||
totalWeeks: 6,
|
||||
progress: 67,
|
||||
category: 'Leadership Development',
|
||||
description: 'Discover the secrets of creating and managing teams that consistently deliver exceptional results. Learn team dynamics, conflict resolution, motivation strategies, and performance optimization techniques used by world-class leaders.',
|
||||
instructor: {
|
||||
name: 'Dr. Emily Chen',
|
||||
title: 'Organizational Psychology Expert',
|
||||
bio: 'Dr. Chen has over 15 years of experience in organizational psychology and team development, having worked with Fortune 500 companies to build high-performing teams.',
|
||||
avatar: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?w=100&h=100&fit=crop'
|
||||
},
|
||||
learningOutcomes: [
|
||||
'Design and structure high-performance team environments that maximize productivity',
|
||||
'Master team dynamics and psychological safety principles for optimal collaboration',
|
||||
'Implement effective conflict resolution and performance management strategies',
|
||||
'Develop leadership skills that inspire and motivate team members to achieve excellence'
|
||||
],
|
||||
thumbnail: 'https://images.unsplash.com/photo-1573164713714-d95e436ab8d6?w=800&h=450&fit=crop',
|
||||
modules: [
|
||||
{
|
||||
id: 'module-1',
|
||||
title: 'Understanding Communication Styles',
|
||||
isExpanded: true,
|
||||
isCompleted: true,
|
||||
lessons: [
|
||||
{
|
||||
id: 'lesson-1',
|
||||
title: 'Situational Communication',
|
||||
duration: '4 min',
|
||||
isCompleted: true,
|
||||
isActive: false,
|
||||
type: 'video',
|
||||
content: 'Learn the fundamentals of situational communication.'
|
||||
},
|
||||
{
|
||||
id: 'lesson-2',
|
||||
title: 'Communication Styles',
|
||||
duration: '5 min',
|
||||
isCompleted: false,
|
||||
isActive: true,
|
||||
type: 'video',
|
||||
content: 'Understanding different communication styles in practice.',
|
||||
subtitle: 'Currently watching'
|
||||
},
|
||||
{
|
||||
id: 'lesson-profiler-1',
|
||||
title: 'Leadership Profiler',
|
||||
duration: '',
|
||||
isCompleted: false,
|
||||
isActive: false,
|
||||
type: 'profiler',
|
||||
content: 'Interactive leadership assessment to identify your leadership style and strengths.'
|
||||
},
|
||||
{
|
||||
id: 'lesson-profiler-2',
|
||||
title: 'Assessment',
|
||||
duration: '15 min',
|
||||
isCompleted: false,
|
||||
isActive: false,
|
||||
type: 'profiler',
|
||||
content: 'Interactive profiler to assess your learning and application of communication styles.',
|
||||
subtitle: 'Interactive profiler'
|
||||
},
|
||||
{
|
||||
id: 'lesson-lab-1',
|
||||
title: 'Hands-on Lab: Assess your Communication Style',
|
||||
duration: '15 min',
|
||||
isCompleted: false,
|
||||
isActive: false,
|
||||
type: 'lab',
|
||||
content: 'Practical lab exercise to assess and practice your communication style.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'module-2',
|
||||
title: 'Communication Challenges and Styles',
|
||||
isExpanded: false,
|
||||
isCompleted: false,
|
||||
lessons: [
|
||||
{
|
||||
id: 'lesson-5',
|
||||
title: 'Expert Viewpoints: Communication Styles',
|
||||
duration: '12 min',
|
||||
isCompleted: false,
|
||||
isActive: false,
|
||||
type: 'video',
|
||||
content: 'Expert insights on effective communication styles.'
|
||||
},
|
||||
{
|
||||
id: 'lesson-6',
|
||||
title: 'Trust and Communication Styles',
|
||||
duration: '8 min',
|
||||
isCompleted: false,
|
||||
isActive: false,
|
||||
type: 'video',
|
||||
content: 'Building trust through effective communication.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'module-3',
|
||||
title: 'Communication Styles and the Team',
|
||||
isExpanded: false,
|
||||
isCompleted: false,
|
||||
lessons: [
|
||||
{
|
||||
id: 'lesson-7',
|
||||
title: 'Team Communication Dynamics',
|
||||
duration: '10 min',
|
||||
isCompleted: false,
|
||||
isActive: false,
|
||||
type: 'video',
|
||||
content: 'Understanding team communication patterns.'
|
||||
},
|
||||
{
|
||||
id: 'lesson-8',
|
||||
title: 'Building Effective Team Communication',
|
||||
duration: '12 min',
|
||||
isCompleted: false,
|
||||
isActive: false,
|
||||
type: 'video',
|
||||
content: 'Strategies for building effective team communication.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
'course-2': {
|
||||
id: 'course-2',
|
||||
title: 'Workshop: Team Building Essentials',
|
||||
subtitle: 'Hands-on Team Development',
|
||||
week: 1,
|
||||
totalWeeks: 4,
|
||||
progress: 0,
|
||||
category: 'Team Management',
|
||||
description: 'Hands-on workshop for building high-performance teams. Learn practical techniques for team formation, collaboration, and performance optimization.',
|
||||
instructor: {
|
||||
name: 'Michael Rodriguez',
|
||||
title: 'Team Development Specialist',
|
||||
bio: 'Michael is a certified team development facilitator with 12+ years of experience helping organizations build cohesive, high-performing teams.',
|
||||
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop'
|
||||
},
|
||||
learningOutcomes: [
|
||||
'Facilitate effective team building exercises and activities',
|
||||
'Identify and resolve common team conflicts and dynamics',
|
||||
'Create psychological safety and trust within team environments',
|
||||
'Measure and improve team performance using proven frameworks'
|
||||
],
|
||||
thumbnail: 'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=800&h=450&fit=crop',
|
||||
modules: [
|
||||
{
|
||||
id: 'team-foundations',
|
||||
title: 'Team Formation Fundamentals',
|
||||
isExpanded: true,
|
||||
isCompleted: false,
|
||||
lessons: [
|
||||
{
|
||||
id: 'tf-1',
|
||||
title: 'Stages of Team Development',
|
||||
duration: '12 min',
|
||||
isCompleted: false,
|
||||
isActive: true,
|
||||
type: 'video',
|
||||
content: 'Understanding the five stages of team development.'
|
||||
},
|
||||
{
|
||||
id: 'tf-2',
|
||||
title: 'Team Roles and Responsibilities',
|
||||
duration: '15 min',
|
||||
isCompleted: false,
|
||||
isActive: false,
|
||||
type: 'video',
|
||||
content: 'Defining clear roles and responsibilities for team success.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
'prog-1': {
|
||||
id: 'prog-1',
|
||||
title: 'Leadership Excellence Program',
|
||||
subtitle: 'Executive Leadership Track',
|
||||
week: 3,
|
||||
totalWeeks: 12,
|
||||
progress: 65,
|
||||
category: 'Leadership Development',
|
||||
description: 'Comprehensive leadership development program for senior executives. Master advanced leadership techniques and strategic thinking.',
|
||||
instructor: {
|
||||
name: 'Dr. Patricia Wilson',
|
||||
title: 'Executive Leadership Expert',
|
||||
bio: 'Dr. Wilson is a renowned leadership consultant with 20+ years of experience developing C-level executives.',
|
||||
avatar: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=100&h=100&fit=crop'
|
||||
},
|
||||
learningOutcomes: [
|
||||
'Develop advanced strategic leadership capabilities',
|
||||
'Master executive decision-making frameworks',
|
||||
'Build and lead high-performance leadership teams',
|
||||
'Execute organizational transformation initiatives'
|
||||
],
|
||||
thumbnail: 'https://images.unsplash.com/photo-1552664730-d307ca884978?w=800&h=450&fit=crop',
|
||||
modules: [
|
||||
{
|
||||
id: 'leadership-foundations',
|
||||
title: 'Leadership Foundations',
|
||||
isExpanded: true,
|
||||
isCompleted: true,
|
||||
lessons: [
|
||||
{
|
||||
id: 'lf-1',
|
||||
title: 'Leadership Principles',
|
||||
duration: '20 min',
|
||||
isCompleted: true,
|
||||
isActive: false,
|
||||
type: 'video',
|
||||
content: 'Core principles of effective leadership.'
|
||||
},
|
||||
{
|
||||
id: 'lf-2',
|
||||
title: 'Strategic Thinking',
|
||||
duration: '35 min',
|
||||
isCompleted: false,
|
||||
isActive: true,
|
||||
type: 'video',
|
||||
content: 'Advanced frameworks for strategic thinking.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
interface CourseDetailPageProps {
|
||||
courseId: string;
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
export function CourseDetailPage({ courseId, onNavigateBack }: CourseDetailPageProps) {
|
||||
const [courseData] = useState<CourseData>(courseDatabase[courseId] || courseDatabase['course-1']);
|
||||
|
||||
// Find the first active lesson or default to the first lesson
|
||||
const getInitialActiveLesson = () => {
|
||||
for (const module of courseData.modules) {
|
||||
for (const lesson of module.lessons) {
|
||||
if (lesson.isActive) return lesson.id;
|
||||
}
|
||||
}
|
||||
return courseData.modules[0]?.lessons[0]?.id || 'lesson-2';
|
||||
};
|
||||
|
||||
const [activeLesson, setActiveLesson] = useState(getInitialActiveLesson());
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [modules, setModules] = useState(courseData.modules);
|
||||
const [showProfiler, setShowProfiler] = useState(false);
|
||||
|
||||
const toggleModule = (moduleId: string) => {
|
||||
setModules(prev => prev.map(module =>
|
||||
module.id === moduleId
|
||||
? { ...module, isExpanded: !module.isExpanded }
|
||||
: module
|
||||
));
|
||||
};
|
||||
|
||||
const selectLesson = (lessonId: string) => {
|
||||
setActiveLesson(lessonId);
|
||||
|
||||
// Check if selected lesson is a profiler type
|
||||
let selectedLesson = null;
|
||||
for (const module of modules) {
|
||||
const lesson = module.lessons.find(l => l.id === lessonId);
|
||||
if (lesson) {
|
||||
selectedLesson = lesson;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedLesson?.type === 'profiler') {
|
||||
setShowProfiler(true);
|
||||
} else {
|
||||
setShowProfiler(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveLesson = () => {
|
||||
for (const module of modules) {
|
||||
const lesson = module.lessons.find(l => l.id === activeLesson);
|
||||
if (lesson) return lesson;
|
||||
}
|
||||
return modules[0].lessons[0];
|
||||
};
|
||||
|
||||
const currentLesson = getActiveLesson();
|
||||
|
||||
const getLessonIcon = (lesson: Lesson) => {
|
||||
switch (lesson.type) {
|
||||
case 'video':
|
||||
return <Video className="h-4 w-4" />;
|
||||
case 'quiz':
|
||||
return <Target className="h-4 w-4" />;
|
||||
case 'reading':
|
||||
return <BookOpen className="h-4 w-4" />;
|
||||
case 'assignment':
|
||||
return <FileText className="h-4 w-4" />;
|
||||
case 'profiler':
|
||||
return <User className="h-4 w-4" />;
|
||||
case 'lab':
|
||||
return <Target className="h-4 w-4" />;
|
||||
default:
|
||||
return <Video className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getLessonIndicatorColor = (lesson: Lesson) => {
|
||||
if (lesson.isCompleted) {
|
||||
return 'bg-[#21A36A]';
|
||||
}
|
||||
if (lesson.id === activeLesson) {
|
||||
return 'bg-[#0066CC]'; // Blue for currently watching
|
||||
}
|
||||
if (lesson.type === 'profiler') {
|
||||
return 'bg-[#8B5CF6]'; // Purple for profiler/assessment
|
||||
}
|
||||
return 'border-2 border-gray-300'; // Default gray
|
||||
};
|
||||
|
||||
// Check if current active lesson is a profiler
|
||||
React.useEffect(() => {
|
||||
const currentLesson = getActiveLesson();
|
||||
if (currentLesson?.type === 'profiler') {
|
||||
setShowProfiler(true);
|
||||
}
|
||||
}, [activeLesson]);
|
||||
|
||||
// If profiler is active, show profiler interface
|
||||
if (showProfiler) {
|
||||
return (
|
||||
<LeadershipProfiler
|
||||
onBack={() => {
|
||||
setShowProfiler(false);
|
||||
// Stay on the course page instead of navigating back
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full bg-background">
|
||||
{/* Full Width Headers */}
|
||||
{/* Breadcrumb Navigation - Full Width */}
|
||||
<div className="bg-background border-b border-border py-3">
|
||||
<div className="px-6">
|
||||
<nav className="flex items-center gap-2 text-[14px] text-muted-foreground">
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
className="hover:text-[var(--color-brand-primary)] transition-colors"
|
||||
>
|
||||
Library
|
||||
</button>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span>{courseData.category}</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-foreground font-medium">{courseData.title}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Course Header - Full Width */}
|
||||
<div className="bg-background border-b border-border py-6">
|
||||
<div className="px-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-[28px] font-bold leading-[34px] tracking-[-1px] mb-2 text-gray-900">
|
||||
{courseData.title}
|
||||
</h1>
|
||||
<p className="text-[16px] text-muted-foreground mb-0">
|
||||
Week {courseData.week} of {courseData.totalWeeks} • {courseData.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<p className="text-[14px] text-muted-foreground">Progress</p>
|
||||
<p className="text-[24px] font-bold text-[var(--color-brand-primary)]">{courseData.progress}%</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-full bg-[var(--color-brand-primary)] flex items-center justify-center relative">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full bg-[var(--color-brand-accent)]"
|
||||
style={{
|
||||
clipPath: `inset(0 ${100 - courseData.progress}% 0 0)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Layout with Sidebar */}
|
||||
<div className="flex h-full">
|
||||
{/* Left Sidebar - Course Materials (300px fixed) */}
|
||||
<aside className="w-[300px] flex-shrink-0 bg-white border-r border-border">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-[16px] font-semibold text-foreground mb-6">Course Material</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{modules.map((module, moduleIndex) => (
|
||||
<div key={module.id} className="mb-6">
|
||||
<div
|
||||
className="flex items-center justify-between mb-3 cursor-pointer hover:bg-gray-50 p-2 rounded-lg"
|
||||
onClick={() => toggleModule(module.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{module.isCompleted && (
|
||||
<div className="w-5 h-5 rounded-full bg-[#21A36A] flex items-center justify-center">
|
||||
<Check className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<h4 className="text-[16px] font-medium text-foreground">
|
||||
Module {moduleIndex + 1}
|
||||
</h4>
|
||||
</div>
|
||||
{module.isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-7 mb-2">
|
||||
<p className="text-[14px] text-foreground">{module.title}</p>
|
||||
</div>
|
||||
|
||||
{module.isExpanded && (
|
||||
<div className="space-y-2 ml-4">
|
||||
{module.lessons.map((lesson) => (
|
||||
<div
|
||||
key={lesson.id}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors ${
|
||||
lesson.id === activeLesson
|
||||
? 'bg-[#E3F2FD] border-l-3 border-[#0066CC]'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => selectLesson(lesson.id)}
|
||||
>
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${getLessonIndicatorColor(lesson)}`}>
|
||||
{lesson.isCompleted ? (
|
||||
<Check className="h-3 w-3 text-white" />
|
||||
) : lesson.id === activeLesson ? (
|
||||
<Play className="h-3 w-3 text-white" />
|
||||
) : lesson.type === 'profiler' ? (
|
||||
<div className="w-2 h-2 rounded-full bg-white" />
|
||||
) : (
|
||||
<div className="w-2 h-2 rounded-full bg-gray-300" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className={`text-[14px] ${
|
||||
lesson.id === activeLesson
|
||||
? 'font-medium text-[#0066CC]'
|
||||
: 'text-foreground'
|
||||
}`}>
|
||||
{lesson.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 text-[12px] text-muted-foreground">
|
||||
{lesson.duration && (
|
||||
<>
|
||||
<span>{lesson.duration}</span>
|
||||
{lesson.subtitle && <span> • {lesson.subtitle}</span>}
|
||||
</>
|
||||
)}
|
||||
{!lesson.duration && lesson.subtitle && (
|
||||
<span>{lesson.subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Additional Course Sections */}
|
||||
<div className="mt-8 pt-4 border-t border-border space-y-3">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg cursor-pointer hover:bg-gray-50 transition-colors">
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-[14px] text-foreground">Discussion Forums</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg cursor-pointer hover:bg-gray-50 transition-colors">
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
<StickyNote className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-[14px] text-foreground">Notes</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg cursor-pointer hover:bg-gray-50 transition-colors">
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-[14px] text-foreground">Course Info</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-lg cursor-pointer hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-[14px] text-foreground">Surveys & Assessments</span>
|
||||
</div>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content (flexible) */}
|
||||
<main className="flex-1 min-w-0">
|
||||
{/* Main Content */}
|
||||
<div className="p-6">
|
||||
{/* Module Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--color-brand-primary)]/10 flex items-center justify-center">
|
||||
<span className="text-[14px] font-medium text-[var(--color-brand-primary)]">1</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[14px] text-muted-foreground">Module 1</p>
|
||||
<h2 className="text-[20px] font-semibold text-foreground">{modules[0]?.title || 'Leadership'}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Player */}
|
||||
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden mb-6">
|
||||
<img
|
||||
src={courseData.thumbnail}
|
||||
alt={courseData.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => setIsPlaying(!isPlaying)}
|
||||
className="w-16 h-16 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center hover:bg-white/30 transition-colors"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-8 w-8 text-white" />
|
||||
) : (
|
||||
<Play className="h-8 w-8 text-white ml-1" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 left-4 right-4 flex items-center justify-between text-white text-[14px]">
|
||||
<span>0:00 / {currentLesson?.duration || '8 min'}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Volume2 className="h-4 w-4" />
|
||||
<Maximize className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 bg-gray-100 rounded-full p-1 mb-6">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="rounded-full text-[16px] font-medium data-[state=active]:bg-white data-[state=active]:text-[var(--color-brand-primary)]"
|
||||
>
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="notes"
|
||||
className="rounded-full text-[16px] font-medium data-[state=active]:bg-white data-[state=active]:text-[var(--color-brand-primary)]"
|
||||
>
|
||||
Notes
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<div className="space-y-6">
|
||||
{/* About this course */}
|
||||
<div>
|
||||
<h3 className="text-[18px] font-semibold text-foreground mb-3">About this course</h3>
|
||||
<p className="text-[14px] text-muted-foreground leading-relaxed">
|
||||
{courseData.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* What you'll learn */}
|
||||
<div>
|
||||
<h3 className="text-[18px] font-semibold text-foreground mb-3">What you'll learn</h3>
|
||||
<div className="space-y-2">
|
||||
{courseData.learningOutcomes.map((outcome, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div className="w-4 h-4 rounded-full bg-[#21A36A] flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Check className="h-2.5 w-2.5 text-white" />
|
||||
</div>
|
||||
<p className="text-[14px] text-foreground">
|
||||
{outcome}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructor */}
|
||||
<div>
|
||||
<h3 className="text-[18px] font-semibold text-foreground mb-3">Instructor</h3>
|
||||
<div className="flex items-start gap-3">
|
||||
<img
|
||||
src={courseData.instructor.avatar}
|
||||
alt={courseData.instructor.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-[16px] font-semibold text-foreground">{courseData.instructor.name}</h4>
|
||||
<p className="text-[14px] text-[var(--color-brand-primary)] mb-1">{courseData.instructor.title}</p>
|
||||
<p className="text-[14px] text-muted-foreground">{courseData.instructor.bio}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Course Summary */}
|
||||
<div>
|
||||
<h3 className="text-[18px] font-semibold text-foreground mb-4">Course Summary</h3>
|
||||
<div className="bg-muted/30 rounded-lg p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Left Column */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<span className="text-[14px] font-medium text-foreground">Skill Level:</span>
|
||||
<span className="text-[14px] text-muted-foreground ml-1">Intermediate</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<span className="text-[14px] font-medium text-foreground">Languages:</span>
|
||||
<span className="text-[14px] text-muted-foreground ml-1">EN, ES, DE, FR, JP</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<span className="text-[14px] font-medium text-foreground">Lectures:</span>
|
||||
<span className="text-[14px] text-muted-foreground ml-1">28</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Award className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<span className="text-[14px] font-medium text-foreground">Certification:</span>
|
||||
<span className="text-[14px] text-muted-foreground ml-1">Yes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<span className="text-[14px] font-medium text-foreground">Students:</span>
|
||||
<span className="text-[14px] text-muted-foreground ml-1">12,350</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<span className="text-[14px] font-medium text-foreground">Captions:</span>
|
||||
<span className="text-[14px] text-muted-foreground ml-1">Yes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<span className="text-[14px] font-medium text-foreground">Duration:</span>
|
||||
<span className="text-[14px] text-muted-foreground ml-1">6.5h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Smartphone className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<span className="text-[14px] font-medium text-foreground">App Support:</span>
|
||||
<span className="text-[14px] text-muted-foreground ml-1">Yes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notes">
|
||||
<div className="space-y-4">
|
||||
<div className="text-center py-8">
|
||||
<BookOpen className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
|
||||
<h3 className="text-[18px] font-semibold mb-2">No notes yet</h3>
|
||||
<p className="text-[16px] text-muted-foreground mb-4">
|
||||
Start taking notes as you watch the course to keep track of important insights and key concepts.
|
||||
</p>
|
||||
<Button className="text-[16px] min-h-[44px] bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary)]/90">
|
||||
Add your first note
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Right Sidebar - Learning Plan (300px fixed) */}
|
||||
<aside className="w-[300px] flex-shrink-0 bg-white border-l border-border">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-[18px] font-semibold text-foreground mb-4">Learning plan</h3>
|
||||
|
||||
<p className="text-[14px] text-muted-foreground mb-6">
|
||||
I'm committed to learning 1 day a week on Coursera.
|
||||
</p>
|
||||
|
||||
{/* This week */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-[16px] font-medium text-foreground">This week</h4>
|
||||
<span className="text-[14px] font-medium text-muted-foreground">Week {courseData.week}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-[14px] text-muted-foreground mb-2">Your next deadline</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-[var(--color-brand-primary)] flex items-center justify-center">
|
||||
<div className="w-2 h-2 bg-[var(--color-brand-primary)] rounded-full" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[14px] font-medium text-foreground">Peer-graded Assignment</p>
|
||||
<p className="text-[12px] text-muted-foreground">Due in 7 days</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Course timeline */}
|
||||
<div>
|
||||
<h4 className="text-[16px] font-medium text-foreground mb-4">Course timeline</h4>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Start date */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-[#21A36A] flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Check className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[14px] font-medium text-foreground">Start date: February 6, 2024</p>
|
||||
<p className="text-[12px] text-muted-foreground">Your goal deadline</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current assignment */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-[var(--color-brand-primary)] flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-[12px] font-medium text-white">2</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[14px] font-medium text-foreground">Peer-graded Assignment</p>
|
||||
<p className="text-[12px] text-muted-foreground">Due in 3 days</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* End date */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-5 h-5 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[14px] font-medium text-foreground">Estimated end date: October 3, 2024</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
410
src/components/Dashboard.tsx
Normal file
410
src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Play,
|
||||
Edit,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Clock,
|
||||
Target,
|
||||
Flame,
|
||||
Star,
|
||||
Award,
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
BarChart3
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Separator } from './ui/separator';
|
||||
import { useAppContext } from './AppShell';
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
|
||||
// Mock data - in real app this would come from API
|
||||
const mockDashboardData = {
|
||||
currentCourse: {
|
||||
id: '1',
|
||||
title: 'Strategic Leadership Development',
|
||||
moduleTitle: 'Module 3: Decision Making Framework',
|
||||
lessonTitle: 'Lesson 2: Risk Assessment Strategies',
|
||||
dueAt: '2024-09-10T18:00:00Z',
|
||||
progressPct: 65
|
||||
},
|
||||
weeklyConsistency: {
|
||||
start: '2024-09-01',
|
||||
end: '2024-09-07',
|
||||
points: [
|
||||
{ date: '2024-09-01', hours: 2.5, day: 'Mon' },
|
||||
{ date: '2024-09-02', hours: 1.8, day: 'Tue' },
|
||||
{ date: '2024-09-03', hours: 3.2, day: 'Wed' },
|
||||
{ date: '2024-09-04', hours: 0, day: 'Thu' },
|
||||
{ date: '2024-09-05', hours: 2.1, day: 'Fri' },
|
||||
{ date: '2024-09-06', hours: 1.5, day: 'Sat' },
|
||||
{ date: '2024-09-07', hours: 0.8, day: 'Sun' }
|
||||
]
|
||||
},
|
||||
goals: {
|
||||
daysOfWeek: [1, 2, 3, 4, 5], // Mon-Fri
|
||||
minutesPerDay: 120
|
||||
},
|
||||
streaks: {
|
||||
loginStreakDays: 12,
|
||||
goalAlignedDays: 8
|
||||
},
|
||||
badges: [
|
||||
{ id: '1', name: 'Early Bird', earnedAt: '2024-08-15', iconUrl: '', desc: 'Complete 5 lessons before 9 AM' },
|
||||
{ id: '2', name: 'Consistent Learner', earnedAt: '2024-08-20', iconUrl: '', desc: '7-day learning streak' },
|
||||
{ id: '3', name: 'Discussion Champion', earnedAt: '2024-08-25', iconUrl: '', desc: 'Participate in 10 discussions' }
|
||||
],
|
||||
leaderboardGlance: {
|
||||
rank: 23,
|
||||
total: 156,
|
||||
xp: 2450,
|
||||
scope: 'org'
|
||||
},
|
||||
recommendations: [
|
||||
{
|
||||
courseId: '101',
|
||||
title: 'Advanced Communication Skills',
|
||||
level: 'Intermediate',
|
||||
trendingSkill: 'Leadership',
|
||||
currentLearnersCount: 89,
|
||||
price: 299
|
||||
},
|
||||
{
|
||||
courseId: '102',
|
||||
title: 'Financial Management for Leaders',
|
||||
level: 'Advanced',
|
||||
trendingSkill: 'Finance',
|
||||
currentLearnersCount: 67,
|
||||
price: 399
|
||||
},
|
||||
{
|
||||
courseId: '103',
|
||||
title: 'Team Building Essentials',
|
||||
level: 'Beginner',
|
||||
trendingSkill: 'Management',
|
||||
currentLearnersCount: 134,
|
||||
price: 199
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export function Dashboard() {
|
||||
const { user, currentMode } = useAppContext();
|
||||
const [dateRange, setDateRange] = useState({ start: '2024-09-01', end: '2024-09-07' });
|
||||
|
||||
const data = mockDashboardData;
|
||||
|
||||
// Donut chart data for module progress
|
||||
const moduleProgressData = [
|
||||
{ name: 'Completed', value: data.currentCourse.progressPct, fill: '#04045B' },
|
||||
{ name: 'Remaining', value: 100 - data.currentCourse.progressPct, fill: '#C0C0C0' }
|
||||
];
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Asia/Kolkata'
|
||||
});
|
||||
};
|
||||
|
||||
const totalWeeklyHours = data.weeklyConsistency.points.reduce((sum, point) => sum + point.hours, 0);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header Greeting */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-3xl">Welcome back, {user.firstName}</h1>
|
||||
<span className="text-2xl">👋</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{user.persona} Learner
|
||||
</Badge>
|
||||
{user.orgName && (
|
||||
<Badge variant="outline">
|
||||
{user.orgName}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline">
|
||||
Asia/Kolkata (GMT+5:30)
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Resume last activity
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
View course timeline
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* Left Column */}
|
||||
<div className="lg:col-span-8 space-y-6">
|
||||
{/* Module Progress */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Module Progress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative w-24 h-24">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={moduleProgressData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={25}
|
||||
outerRadius={40}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
dataKey="value"
|
||||
>
|
||||
{moduleProgressData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-sm font-medium">{data.currentCourse.progressPct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
<h3 className="font-medium">{data.currentCourse.moduleTitle}</h3>
|
||||
<p className="text-sm text-muted-foreground">{data.currentCourse.lessonTitle}</p>
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">Due: </span>
|
||||
{formatTime(data.currentCourse.dueAt)}
|
||||
</p>
|
||||
<Button size="sm" className="mt-2">
|
||||
Continue Learning
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Weekly Consistency Chart */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Weekly Consistency Chart</CardTitle>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
{totalWeeklyHours.toFixed(1)}h this week
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={data.weeklyConsistency.points}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="day" />
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
formatter={(value) => [`${value}h`, 'Hours Spent']}
|
||||
labelFormatter={(label) => `${label}`}
|
||||
/>
|
||||
<Bar dataKey="hours" fill="#04045B" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Continue Learning */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Continue Learning</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Strategic Leadership Programme › Advanced Leadership › Module 3</p>
|
||||
<h3 className="font-medium">{data.currentCourse.lessonTitle}</h3>
|
||||
<Progress value={data.currentCourse.progressPct} className="w-32" />
|
||||
</div>
|
||||
<Button>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Resume
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recommendations */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Based on your activity, here are our course recommendations</CardTitle>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit recommendations
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{data.recommendations.map((course) => (
|
||||
<div key={course.courseId} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">{course.title}</h3>
|
||||
<Badge variant="secondary">{course.level}</Badge>
|
||||
<Badge variant="outline" className="bg-[#F8C301] text-black">
|
||||
{course.trendingSkill}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{course.currentLearnersCount} current learners
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{user.persona === 'individual' && (
|
||||
<span className="font-medium">₹{course.price}</span>
|
||||
)}
|
||||
<Button>
|
||||
{user.persona === 'individual' ? 'Enrol' : 'View details'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="lg:col-span-4 space-y-6">
|
||||
{/* Learning Goals & Streaks */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Learning Goals & Streaks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Weekly Goal</p>
|
||||
<p className="font-medium">{data.goals.minutesPerDay} min/day, 5 days/week</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit My Goal
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Flame className="h-5 w-5 text-orange-500" />
|
||||
<span className="font-medium">Login Streak</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold">{data.streaks.loginStreakDays}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-green-500" />
|
||||
<span className="font-medium">Goal Aligned Days</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold">{data.streaks.goalAlignedDays}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Badges */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Badges</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.badges.map((badge) => (
|
||||
<div key={badge.id} className="flex items-center gap-3 p-2 rounded-lg border">
|
||||
<div className="w-10 h-10 rounded-full bg-[#F8C301] flex items-center justify-center">
|
||||
<Award className="h-5 w-5 text-[#04045B]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">{badge.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{badge.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Global Leaderboard Glance */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Leaderboard</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="text-3xl font-bold">#{data.leaderboardGlance.rank}</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
out of {data.leaderboardGlance.total} {data.leaderboardGlance.scope === 'org' ? 'colleagues' : 'learners'}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Star className="h-4 w-4 text-[#F8C301]" />
|
||||
<span className="font-medium">{data.leaderboardGlance.xp} XP</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full">
|
||||
View Full Leaderboard
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* What other leaders are training for */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>What other leaders are training for</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.recommendations.slice(0, 2).map((course) => (
|
||||
<div key={course.courseId} className="p-3 border rounded-lg space-y-2">
|
||||
<h3 className="font-medium text-sm">{course.title}</h3>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{course.currentLearnersCount} learners
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{course.trendingSkill}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
472
src/components/DiscussionForums.tsx
Normal file
472
src/components/DiscussionForums.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Input } from './ui/input';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||
import { MessageCircle, Plus, User, Calendar, Pin, Flag, ThumbsUp, Reply } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
|
||||
interface User {
|
||||
persona: 'corporate' | 'individual';
|
||||
firstName: string;
|
||||
}
|
||||
|
||||
interface Thread {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
authorAvatar?: string;
|
||||
createdAt: string;
|
||||
lastReplyAt: string;
|
||||
replies: number;
|
||||
pinned?: boolean;
|
||||
tags?: string[];
|
||||
excerpt: string;
|
||||
}
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
author: string;
|
||||
authorAvatar?: string;
|
||||
createdAt: string;
|
||||
bodyHtml: string;
|
||||
canEdit: boolean;
|
||||
likes: number;
|
||||
liked: boolean;
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockThreads: Thread[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Best practices for remote team leadership',
|
||||
author: 'Priya Sharma',
|
||||
authorAvatar: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?w=64&h=64&fit=crop&crop=face',
|
||||
createdAt: '2024-01-15T10:30:00Z',
|
||||
lastReplyAt: '2024-01-16T14:20:00Z',
|
||||
replies: 12,
|
||||
pinned: true,
|
||||
tags: ['leadership', 'remote-work'],
|
||||
excerpt: 'Looking for insights on managing distributed teams effectively...'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Digital transformation challenges in traditional industries',
|
||||
author: 'Rajesh Kumar',
|
||||
authorAvatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=64&h=64&fit=crop&crop=face',
|
||||
createdAt: '2024-01-14T09:15:00Z',
|
||||
lastReplyAt: '2024-01-16T11:45:00Z',
|
||||
replies: 8,
|
||||
tags: ['digital-transformation', 'strategy'],
|
||||
excerpt: 'How do we overcome resistance to change in established organizations?'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Effective feedback techniques for managers',
|
||||
author: 'Sneha Patel',
|
||||
createdAt: '2024-01-13T16:20:00Z',
|
||||
lastReplyAt: '2024-01-15T13:10:00Z',
|
||||
replies: 15,
|
||||
tags: ['feedback', 'management'],
|
||||
excerpt: 'Share your experiences with giving constructive feedback...'
|
||||
}
|
||||
];
|
||||
|
||||
const mockPosts: Post[] = [
|
||||
{
|
||||
id: '1',
|
||||
author: 'Priya Sharma',
|
||||
authorAvatar: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?w=64&h=64&fit=crop&crop=face',
|
||||
createdAt: '2024-01-15T10:30:00Z',
|
||||
bodyHtml: '<p>I have been leading a remote team for the past 2 years and wanted to share some insights while also learning from your experiences.</p><p><strong>Key challenges I have faced:</strong></p><ul><li>Maintaining team cohesion across time zones</li><li>Ensuring clear communication without over-communicating</li><li>Building trust with team members I rarely see in person</li></ul><p>What strategies have worked best for you?</p>',
|
||||
canEdit: true,
|
||||
likes: 8,
|
||||
liked: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
author: 'Amit Singh',
|
||||
authorAvatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=64&h=64&fit=crop&crop=face',
|
||||
createdAt: '2024-01-15T14:45:00Z',
|
||||
bodyHtml: '<p>Great topic, Priya! I have found that regular one-on-ones are crucial. Here is what works for me:</p><p>📅 <strong>Weekly 30-min check-ins</strong> - not just about work, but also personal connection</p><p>🎯 <strong>Clear goal setting</strong> - using OKRs to align everyone</p><p>🛠️ <strong>Right tools</strong> - Slack for quick comms, Zoom for face-to-face, Notion for documentation</p><p>The key is being intentional about relationship building, not just task management.</p>',
|
||||
canEdit: false,
|
||||
likes: 12,
|
||||
liked: true
|
||||
}
|
||||
];
|
||||
|
||||
export function DiscussionForums({ user }: { user: User }) {
|
||||
const [selectedCohort, setSelectedCohort] = useState('cohort1');
|
||||
const [currentView, setCurrentView] = useState<'threads' | 'thread'>('threads');
|
||||
const [selectedThread, setSelectedThread] = useState<Thread | null>(null);
|
||||
const [newThreadOpen, setNewThreadOpen] = useState(false);
|
||||
const [newThreadTitle, setNewThreadTitle] = useState('');
|
||||
const [newThreadBody, setNewThreadBody] = useState('');
|
||||
const [replyText, setReplyText] = useState('');
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const handleThreadClick = (thread: Thread) => {
|
||||
setSelectedThread(thread);
|
||||
setCurrentView('thread');
|
||||
};
|
||||
|
||||
const handleCreateThread = () => {
|
||||
// Mock thread creation
|
||||
console.log('Creating thread:', { title: newThreadTitle, body: newThreadBody });
|
||||
setNewThreadOpen(false);
|
||||
setNewThreadTitle('');
|
||||
setNewThreadBody('');
|
||||
};
|
||||
|
||||
const handleReply = () => {
|
||||
// Mock reply creation
|
||||
console.log('Creating reply:', replyText);
|
||||
setReplyText('');
|
||||
};
|
||||
|
||||
if (currentView === 'thread' && selectedThread) {
|
||||
return (
|
||||
<div className="p-6 space-y-6 bg-white min-h-screen">
|
||||
{/* Thread Header */}
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentView('threads')}
|
||||
className="mb-4"
|
||||
>
|
||||
← Back to Forums
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedThread.pinned && <Pin className="h-4 w-4 text-[var(--color-brand-accent)]" />}
|
||||
<h1 className="text-2xl font-medium">{selectedThread.title}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
{selectedThread.authorAvatar && <AvatarImage src={selectedThread.authorAvatar} />}
|
||||
<AvatarFallback>{selectedThread.author.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{selectedThread.author}</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>{formatDate(selectedThread.createdAt)}</span>
|
||||
<span>•</span>
|
||||
<span>{selectedThread.replies} replies</span>
|
||||
</div>
|
||||
|
||||
{selectedThread.tags && (
|
||||
<div className="flex gap-2">
|
||||
{selectedThread.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Posts */}
|
||||
<div className="space-y-5">
|
||||
{mockPosts.map((post, index) => (
|
||||
<Card key={post.id} className="border border-gray-200 bg-white">
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
{post.authorAvatar && <AvatarImage src={post.authorAvatar} />}
|
||||
<AvatarFallback>{post.author.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="font-semibold text-gray-900">{post.author}</span>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Calendar className="h-4 w-4 text-gray-400" />
|
||||
<span>{formatDate(post.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{post.canEdit && (
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Flag className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Flag className="h-4 w-4" />
|
||||
Report
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100">
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-gray-700"
|
||||
dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`gap-2 ${post.liked ? 'bg-[#04045B]/5 text-[#04045B] border-[#04045B]/20' : ''}`}
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
{post.likes} Likes
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Reply className="h-4 w-4" />
|
||||
Reply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reply Form */}
|
||||
<Card className="border border-gray-200 bg-white">
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-[#04045B] rounded-full"></div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Add a reply</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100">
|
||||
<Textarea
|
||||
placeholder="Share your thoughts and contribute to the discussion..."
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
rows={4}
|
||||
className="bg-white border-gray-200 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={() => setReplyText('')} className="gap-2">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReply}
|
||||
disabled={!replyText.trim()}
|
||||
className="gap-2 bg-[#04045B] hover:bg-[#030344] text-white"
|
||||
>
|
||||
<Reply className="h-4 w-4" />
|
||||
Post Reply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 bg-white min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold text-gray-900">Discussion Forums</h1>
|
||||
<p className="text-gray-600">Connect and learn with your cohort</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between bg-gradient-to-r from-gray-50 to-blue-50/30 p-4 rounded-xl border border-gray-100">
|
||||
<div className="flex items-center gap-4">
|
||||
{user.persona === 'corporate' && (
|
||||
<Select value={selectedCohort} onValueChange={setSelectedCohort}>
|
||||
<SelectTrigger className="w-56 bg-white border-gray-200 shadow-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="cohort1">Tech Solutions Cohort</SelectItem>
|
||||
<SelectItem value="cohort2">Leadership Program 2024</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={newThreadOpen} onOpenChange={setNewThreadOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2 bg-[#04045B] hover:bg-[#030344] text-white px-6 py-2.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
New Thread
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Discussion Thread</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Title</label>
|
||||
<Input
|
||||
placeholder="What would you like to discuss?"
|
||||
value={newThreadTitle}
|
||||
onChange={(e) => setNewThreadTitle(e.target.value)}
|
||||
maxLength={120}
|
||||
className="bg-white"
|
||||
/>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{newThreadTitle.length}/120 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<Textarea
|
||||
placeholder="Provide more details about your topic..."
|
||||
value={newThreadBody}
|
||||
onChange={(e) => setNewThreadBody(e.target.value)}
|
||||
rows={6}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setNewThreadOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateThread}
|
||||
disabled={!newThreadTitle.trim() || !newThreadBody.trim()}
|
||||
className="bg-[#04045B] hover:bg-[#030344] text-white"
|
||||
>
|
||||
Create Thread
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Threads List */}
|
||||
<div className="space-y-5">
|
||||
{mockThreads.map((thread) => (
|
||||
<Card
|
||||
key={thread.id}
|
||||
className="hover:shadow-lg transition-all duration-300 border border-gray-200 bg-white cursor-pointer group"
|
||||
onClick={() => handleThreadClick(thread)}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
{/* Header with author and pin */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Avatar className="h-10 w-10 flex-shrink-0">
|
||||
{thread.authorAvatar && <AvatarImage src={thread.authorAvatar} />}
|
||||
<AvatarFallback>{thread.author.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="space-y-1 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{thread.pinned && <Pin className="h-4 w-4 text-[#F8C301]" />}
|
||||
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-[#04045B] transition-colors leading-tight">
|
||||
{thread.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">by {thread.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right text-sm text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{formatDate(thread.lastReplyAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thread excerpt */}
|
||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
||||
<p className="text-sm text-gray-700 leading-relaxed line-clamp-2">
|
||||
{thread.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tags and metadata */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="h-4 w-4 text-gray-400" />
|
||||
<span>{thread.replies} replies</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{thread.tags && (
|
||||
<div className="flex gap-2">
|
||||
{thread.tags.slice(0, 2).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="text-xs font-medium bg-[#F8C301]/10 text-[#04045B] border-[#F8C301]/30"
|
||||
>
|
||||
#{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{thread.tags.length > 2 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-medium bg-gray-100 text-gray-600 border-gray-200"
|
||||
>
|
||||
+{thread.tags.length - 2} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mockThreads.length === 0 && (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-[#04045B]/10 to-blue-50 rounded-full flex items-center justify-center">
|
||||
<MessageCircle className="h-10 w-10 text-[#04045B]" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">No discussions yet</h3>
|
||||
<p className="text-gray-600 mb-6 max-w-md mx-auto">
|
||||
Be the first to start a conversation with your cohort and share your leadership insights.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setNewThreadOpen(true)}
|
||||
className="gap-2 bg-[#04045B] hover:bg-[#030344] text-white px-6 py-2.5"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Start a Discussion
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
557
src/components/DocumentDetailPage.tsx
Normal file
557
src/components/DocumentDetailPage.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Progress } from './ui/progress';
|
||||
import {
|
||||
ChevronRight,
|
||||
Download,
|
||||
Share,
|
||||
BookmarkPlus,
|
||||
Clock,
|
||||
FileText,
|
||||
Eye,
|
||||
Search,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
RotateCw,
|
||||
Printer,
|
||||
MessageSquare,
|
||||
Highlighter,
|
||||
StickyNote,
|
||||
ChevronLeft,
|
||||
ChevronUp,
|
||||
ChevronDown
|
||||
} from 'lucide-react';
|
||||
|
||||
// Mock document data
|
||||
interface DocumentData {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
category: string;
|
||||
author: {
|
||||
name: string;
|
||||
title: string;
|
||||
bio: string;
|
||||
avatar: string;
|
||||
};
|
||||
fileType: string;
|
||||
fileSize: string;
|
||||
pageCount: number;
|
||||
downloadUrl: string;
|
||||
isDownloaded: boolean;
|
||||
lastRead: string;
|
||||
progress: number;
|
||||
currentPage: number;
|
||||
tableOfContents: DocumentSection[];
|
||||
tags: string[];
|
||||
language: string;
|
||||
publishedDate: string;
|
||||
}
|
||||
|
||||
interface DocumentSection {
|
||||
id: string;
|
||||
title: string;
|
||||
page: number;
|
||||
subsections?: DocumentSection[];
|
||||
}
|
||||
|
||||
const documentDatabase: Record<string, DocumentData> = {
|
||||
'document-1': {
|
||||
id: 'document-1',
|
||||
title: 'Leadership in the Digital Age',
|
||||
subtitle: 'A Comprehensive Guide to Modern Leadership Strategies',
|
||||
description: 'This comprehensive guide explores the evolving landscape of leadership in our digital world. Learn how to navigate technological disruption, lead remote teams, and build resilient organizations that thrive in uncertainty.',
|
||||
category: 'Leadership Development',
|
||||
author: {
|
||||
name: 'Dr. Marcus Thompson',
|
||||
title: 'Digital Transformation Expert',
|
||||
bio: 'Dr. Thompson is a leading expert in digital leadership and organizational transformation, with over 20 years of experience consulting for global enterprises.',
|
||||
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop'
|
||||
},
|
||||
fileType: 'PDF',
|
||||
fileSize: '2.8 MB',
|
||||
pageCount: 142,
|
||||
downloadUrl: 'https://example.com/leadership-digital-age.pdf',
|
||||
isDownloaded: true,
|
||||
lastRead: '2024-03-15T14:30:00Z',
|
||||
progress: 45,
|
||||
currentPage: 64,
|
||||
language: 'English',
|
||||
publishedDate: '2024-01-15',
|
||||
tags: ['Leadership', 'Digital Transformation', 'Remote Work', 'Change Management'],
|
||||
tableOfContents: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
title: 'Introduction to Digital Leadership',
|
||||
page: 1,
|
||||
subsections: [
|
||||
{ id: 'section-1-1', title: 'Defining Digital Leadership', page: 3 },
|
||||
{ id: 'section-1-2', title: 'The Digital Leadership Mindset', page: 8 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'chapter-2',
|
||||
title: 'Leading Remote and Hybrid Teams',
|
||||
page: 15,
|
||||
subsections: [
|
||||
{ id: 'section-2-1', title: 'Building Trust in Virtual Environments', page: 17 },
|
||||
{ id: 'section-2-2', title: 'Communication Strategies for Remote Teams', page: 25 },
|
||||
{ id: 'section-2-3', title: 'Performance Management in Digital Workspaces', page: 33 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'chapter-3',
|
||||
title: 'Digital Transformation Leadership',
|
||||
page: 42,
|
||||
subsections: [
|
||||
{ id: 'section-3-1', title: 'Driving Organizational Change', page: 44 },
|
||||
{ id: 'section-3-2', title: 'Technology Adoption Strategies', page: 52 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'chapter-4',
|
||||
title: 'Building Resilient Organizations',
|
||||
page: 65,
|
||||
subsections: [
|
||||
{ id: 'section-4-1', title: 'Crisis Leadership in Digital Times', page: 67 },
|
||||
{ id: 'section-4-2', title: 'Future-Proofing Your Organization', page: 75 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'chapter-5',
|
||||
title: 'The Future of Leadership',
|
||||
page: 85,
|
||||
subsections: [
|
||||
{ id: 'section-5-1', title: 'Emerging Leadership Trends', page: 87 },
|
||||
{ id: 'section-5-2', title: 'Preparing for Tomorrow\'s Challenges', page: 95 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
interface DocumentDetailPageProps {
|
||||
documentId: string;
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
export function DocumentDetailPage({ documentId, onNavigateBack }: DocumentDetailPageProps) {
|
||||
const [documentData] = useState<DocumentData>(documentDatabase[documentId] || documentDatabase['document-1']);
|
||||
const [selectedSection, setSelectedSection] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<string[]>(['chapter-1']);
|
||||
const [viewMode, setViewMode] = useState<'reader' | 'viewer'>('reader');
|
||||
const [zoomLevel, setZoomLevel] = useState(100);
|
||||
|
||||
const formatLastRead = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const formatPublishedDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSection = (sectionId: string) => {
|
||||
setExpandedSections(prev =>
|
||||
prev.includes(sectionId)
|
||||
? prev.filter(id => id !== sectionId)
|
||||
: [...prev, sectionId]
|
||||
);
|
||||
};
|
||||
|
||||
const jumpToPage = (page: number) => {
|
||||
// In a real app, this would navigate to the specific page
|
||||
console.log(`Jumping to page ${page}`);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
// In a real app, this would trigger the download
|
||||
console.log('Downloading document...');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full bg-background">
|
||||
{/* Breadcrumb Navigation - Full Width */}
|
||||
<div className="bg-background border-b border-border py-3">
|
||||
<div className="px-6">
|
||||
<nav className="flex items-center gap-2 text-[14px] text-muted-foreground">
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
className="hover:text-[var(--color-brand-primary)] transition-colors"
|
||||
>
|
||||
Library
|
||||
</button>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span>{documentData.category}</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-foreground font-medium">Documents</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-foreground font-medium">{documentData.title}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document Header - Full Width */}
|
||||
<div className="bg-background border-b border-border py-6">
|
||||
<div className="px-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Badge variant="secondary" className="bg-[var(--color-brand-primary)]/10 text-[var(--color-brand-primary)] border-[var(--color-brand-primary)]/20">
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
{documentData.fileType}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[12px]">
|
||||
{documentData.pageCount} pages
|
||||
</Badge>
|
||||
{documentData.isDownloaded && (
|
||||
<Badge className="bg-green-100 text-green-800 border-green-200">
|
||||
Downloaded
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-[28px] font-bold leading-[34px] tracking-[-1px] mb-2 text-gray-900">
|
||||
{documentData.title}
|
||||
</h1>
|
||||
<p className="text-[16px] text-muted-foreground mb-3">
|
||||
{documentData.subtitle}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-[14px] text-muted-foreground">
|
||||
<span>By {documentData.author.name}</span>
|
||||
<span>•</span>
|
||||
<span>Published {formatPublishedDate(documentData.publishedDate)}</span>
|
||||
<span>•</span>
|
||||
<span>{documentData.fileSize}</span>
|
||||
{documentData.lastRead && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>Last read: {formatLastRead(documentData.lastRead)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Read Online
|
||||
</Button>
|
||||
<Button onClick={handleDownload}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Layout */}
|
||||
<div className="flex h-full">
|
||||
{/* Left Sidebar - Table of Contents */}
|
||||
<aside className="w-[300px] flex-shrink-0 bg-white border-r border-border">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-[16px] font-semibold text-foreground mb-6">Table of Contents</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{documentData.tableOfContents.map((section) => (
|
||||
<div key={section.id}>
|
||||
<div
|
||||
className={`flex items-center justify-between p-2 rounded-lg cursor-pointer transition-colors ${
|
||||
selectedSection === section.id
|
||||
? 'bg-[var(--color-brand-primary)]/5 border border-[var(--color-brand-primary)]/20'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedSection(section.id);
|
||||
jumpToPage(section.page);
|
||||
if (section.subsections) {
|
||||
toggleSection(section.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<span className="text-[14px] font-medium text-foreground">
|
||||
{section.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] text-muted-foreground">p.{section.page}</span>
|
||||
{section.subsections && (
|
||||
expandedSections.includes(section.id) ? (
|
||||
<ChevronUp className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{section.subsections && expandedSections.includes(section.id) && (
|
||||
<div className="ml-4 mt-2 space-y-1">
|
||||
{section.subsections.map((subsection) => (
|
||||
<div
|
||||
key={subsection.id}
|
||||
className="flex items-center justify-between p-2 rounded cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => {
|
||||
setSelectedSection(subsection.id);
|
||||
jumpToPage(subsection.page);
|
||||
}}
|
||||
>
|
||||
<span className="text-[13px] text-muted-foreground">
|
||||
{subsection.title}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
p.{subsection.page}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Document Tools */}
|
||||
<div className="mt-8 pt-6 border-t border-border">
|
||||
<h4 className="text-[14px] font-medium text-foreground mb-4">Document Tools</h4>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Search Document
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<Highlighter className="h-4 w-4 mr-2" />
|
||||
Highlight Text
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<StickyNote className="h-4 w-4 mr-2" />
|
||||
Add Notes
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<Printer className="h-4 w-4 mr-2" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content - Document Viewer */}
|
||||
<main className="flex-1 min-w-0">
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Document Toolbar */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-gray-50">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-[14px] text-muted-foreground">
|
||||
Page {documentData.currentPage} of {documentData.pageCount}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setZoomLevel(Math.max(50, zoomLevel - 10))}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-[14px] text-muted-foreground min-w-[60px] text-center">
|
||||
{zoomLevel}%
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setZoomLevel(Math.min(200, zoomLevel + 10))}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Share className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<BookmarkPlus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document Content Area */}
|
||||
<div className="flex-1 p-6 overflow-auto bg-gray-100">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Document Preview/Reader */}
|
||||
<div
|
||||
className="bg-white shadow-lg rounded-lg overflow-hidden"
|
||||
style={{ transform: `scale(${zoomLevel / 100})`, transformOrigin: 'top center' }}
|
||||
>
|
||||
<div className="aspect-[8.5/11] p-8 bg-white">
|
||||
<div className="h-full border border-gray-200 p-6">
|
||||
<h1 className="text-[24px] font-bold mb-4 text-center">
|
||||
Leadership in the Digital Age
|
||||
</h1>
|
||||
<h2 className="text-[18px] font-medium mb-6 text-center text-muted-foreground">
|
||||
Chapter 3: Digital Transformation Leadership
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4 text-[14px] leading-relaxed">
|
||||
<p>
|
||||
In today's rapidly evolving business landscape, leaders must navigate
|
||||
unprecedented technological disruption while maintaining organizational
|
||||
cohesion and purpose. Digital transformation is not merely about
|
||||
implementing new technologies; it's about fundamentally reimagining
|
||||
how organizations operate, deliver value, and engage with stakeholders.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Successful digital leaders understand that transformation begins with
|
||||
mindset. They cultivate a culture of continuous learning, encourage
|
||||
experimentation, and view failure as a stepping stone to innovation.
|
||||
This chapter explores the core competencies required to lead
|
||||
organizations through digital transformation successfully.
|
||||
</p>
|
||||
|
||||
<h3 className="text-[16px] font-semibold mt-6 mb-3">
|
||||
Key Principles of Digital Leadership
|
||||
</h3>
|
||||
|
||||
<p>
|
||||
Digital leadership requires a unique blend of traditional leadership
|
||||
skills and digital-age competencies. Leaders must be comfortable
|
||||
with ambiguity, data-driven decision making, and rapid iteration cycles.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Right Sidebar - Document Info & Progress */}
|
||||
<aside className="w-[300px] flex-shrink-0 bg-white border-l border-border">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-[18px] font-semibold text-foreground mb-4">Reading progress</h3>
|
||||
|
||||
{/* Reading Progress */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[14px] text-foreground">Pages read</span>
|
||||
<span className="text-[14px] font-medium text-[var(--color-brand-primary)]">
|
||||
{Math.round(documentData.pageCount * documentData.progress / 100)} / {documentData.pageCount}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={documentData.progress} className="h-2 mb-3" />
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
{documentData.progress}% completed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Document Information */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[16px] font-medium text-foreground mb-4">Document Details</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[14px] text-muted-foreground">Format</span>
|
||||
<span className="text-[14px] font-medium">{documentData.fileType}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[14px] text-muted-foreground">File Size</span>
|
||||
<span className="text-[14px] font-medium">{documentData.fileSize}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[14px] text-muted-foreground">Pages</span>
|
||||
<span className="text-[14px] font-medium">{documentData.pageCount}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[14px] text-muted-foreground">Language</span>
|
||||
<span className="text-[14px] font-medium">{documentData.language}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[16px] font-medium text-foreground mb-3">Topics</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{documentData.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-[12px]">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Author Information */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[16px] font-medium text-foreground mb-3">Author</h4>
|
||||
<div className="flex items-start gap-3">
|
||||
<img
|
||||
src={documentData.author.avatar}
|
||||
alt={documentData.author.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<h5 className="text-[14px] font-medium text-foreground">{documentData.author.name}</h5>
|
||||
<p className="text-[13px] text-[var(--color-brand-primary)] mb-1">{documentData.author.title}</p>
|
||||
<p className="text-[12px] text-muted-foreground">{documentData.author.bio}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div>
|
||||
<h4 className="text-[16px] font-medium text-foreground mb-3">Actions</h4>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download PDF
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<Share className="h-4 w-4 mr-2" />
|
||||
Share Document
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
Discuss Document
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<BookmarkPlus className="h-4 w-4 mr-2" />
|
||||
Save to Library
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
509
src/components/GlobalSearch.tsx
Normal file
509
src/components/GlobalSearch.tsx
Normal file
@@ -0,0 +1,509 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Search, FileText, Video, BookOpen, MessageCircle, X, Loader2, Brain } from 'lucide-react';
|
||||
import { Card, CardContent } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Label } from './ui/label';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'Course' | 'Webcast' | 'Blog' | 'Article' | 'Forum';
|
||||
theme: 'Leadership' | 'Digital Transformation' | 'Team Building' | 'Strategy' | 'Innovation' | 'Project Management';
|
||||
summary?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface GlobalSearchProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Mock dataset
|
||||
const mockData: SearchResult[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Advanced Leadership Development Program',
|
||||
type: 'Course',
|
||||
theme: 'Leadership',
|
||||
summary: 'Comprehensive leadership training covering strategic thinking, team management, and executive decision-making skills.',
|
||||
url: '/courses/advanced-leadership-development'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Digital Transformation Strategies',
|
||||
type: 'Webcast',
|
||||
theme: 'Digital Transformation',
|
||||
summary: 'Learn how to lead your organization through digital transformation with proven methodologies and real-world case studies.',
|
||||
url: '/webcasts/digital-transformation-strategies'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Building High-Performance Teams',
|
||||
type: 'Article',
|
||||
theme: 'Team Building',
|
||||
summary: 'Essential techniques for creating cohesive, productive teams that deliver exceptional results in challenging environments.',
|
||||
url: '/articles/building-high-performance-teams'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Strategic Planning Best Practices',
|
||||
type: 'Blog',
|
||||
theme: 'Strategy',
|
||||
summary: 'A comprehensive guide to developing and implementing strategic plans that drive organizational success.',
|
||||
url: '/blog/strategic-planning-best-practices'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Innovation in the Workplace',
|
||||
type: 'Forum',
|
||||
theme: 'Innovation',
|
||||
url: '/forums/innovation-workplace'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Project Management Fundamentals',
|
||||
type: 'Course',
|
||||
theme: 'Project Management',
|
||||
summary: 'Master the core principles of project management including planning, execution, monitoring, and closure.',
|
||||
url: '/courses/project-management-fundamentals'
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'Communication Skills for Leaders',
|
||||
type: 'Webcast',
|
||||
theme: 'Leadership',
|
||||
summary: 'Develop powerful communication skills that inspire teams and drive organizational change.',
|
||||
url: '/webcasts/communication-skills-leaders'
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: 'Team Dynamics and Collaboration',
|
||||
type: 'Article',
|
||||
theme: 'Team Building',
|
||||
summary: 'Understanding team dynamics and fostering collaboration in diverse, multi-generational workplaces.',
|
||||
url: '/articles/team-dynamics-collaboration'
|
||||
}
|
||||
];
|
||||
|
||||
const searchSuggestions = [
|
||||
'leadership development',
|
||||
'project management',
|
||||
'team building',
|
||||
'digital transformation',
|
||||
'communication skills',
|
||||
'strategic planning'
|
||||
];
|
||||
|
||||
const typeOptions = ['Course', 'Webcast', 'Blog', 'Article', 'Forum'];
|
||||
const themeOptions = ['Leadership', 'Digital Transformation', 'Team Building', 'Strategy', 'Innovation', 'Project Management'];
|
||||
|
||||
export function GlobalSearch({ isOpen, onClose }: GlobalSearchProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
|
||||
const [selectedThemes, setSelectedThemes] = useState<string[]>([]);
|
||||
|
||||
const characterCount = searchQuery.length;
|
||||
const maxCharacters = 200;
|
||||
|
||||
// Generate placeholder summary for items without one
|
||||
const generatePlaceholderSummary = (type: string): string => {
|
||||
const summaries = {
|
||||
Course: 'Interactive learning experience with comprehensive modules and practical exercises.',
|
||||
Webcast: 'Live streaming session with expert insights and interactive Q&A opportunities.',
|
||||
Blog: 'Thought-provoking article with actionable insights and industry perspectives.',
|
||||
Article: 'In-depth analysis with research-backed insights and practical recommendations.',
|
||||
Forum: 'Community discussion forum for sharing experiences and best practices.'
|
||||
};
|
||||
return summaries[type as keyof typeof summaries] || 'Comprehensive learning resource with valuable insights.';
|
||||
};
|
||||
|
||||
// Get icon by content type
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'Course':
|
||||
return <BookOpen className="h-5 w-5" />;
|
||||
case 'Webcast':
|
||||
return <Video className="h-5 w-5" />;
|
||||
case 'Blog':
|
||||
case 'Article':
|
||||
return <FileText className="h-5 w-5" />;
|
||||
case 'Forum':
|
||||
return <MessageCircle className="h-5 w-5" />;
|
||||
default:
|
||||
return <FileText className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Filter and search logic
|
||||
const performSearch = async (query: string) => {
|
||||
setIsSearching(true);
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
let results = mockData;
|
||||
|
||||
// Filter by search query
|
||||
if (query.trim()) {
|
||||
const lowercaseQuery = query.toLowerCase();
|
||||
results = results.filter(item =>
|
||||
item.title.toLowerCase().includes(lowercaseQuery) ||
|
||||
(item.summary && item.summary.toLowerCase().includes(lowercaseQuery))
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by selected types
|
||||
if (selectedTypes.length > 0) {
|
||||
results = results.filter(item => selectedTypes.includes(item.type));
|
||||
}
|
||||
|
||||
// Filter by selected themes
|
||||
if (selectedThemes.length > 0) {
|
||||
results = results.filter(item => selectedThemes.includes(item.theme));
|
||||
}
|
||||
|
||||
setSearchResults(results);
|
||||
setIsSearching(false);
|
||||
setHasSearched(true);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
performSearch(searchQuery);
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
setSearchQuery(suggestion);
|
||||
performSearch(suggestion);
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedTypes([...selectedTypes, type]);
|
||||
} else {
|
||||
setSelectedTypes(selectedTypes.filter(t => t !== type));
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeChange = (theme: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedThemes([...selectedThemes, theme]);
|
||||
} else {
|
||||
setSelectedThemes(selectedThemes.filter(t => t !== theme));
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setSelectedTypes([]);
|
||||
setSelectedThemes([]);
|
||||
};
|
||||
|
||||
const hasActiveFilters = selectedTypes.length > 0 || selectedThemes.length > 0;
|
||||
|
||||
// Re-search when filters change
|
||||
useEffect(() => {
|
||||
if (hasSearched) {
|
||||
performSearch(searchQuery);
|
||||
}
|
||||
}, [selectedTypes, selectedThemes]);
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
setHasSearched(false);
|
||||
setSelectedTypes([]);
|
||||
setSelectedThemes([]);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="!max-w-[800px] !w-[800px] max-h-[90vh] p-0 bg-white overflow-hidden sm:!max-w-[800px]" style={{maxWidth: '800px', width: '800px'}}>
|
||||
<DialogHeader className="px-6 py-4 border-b flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5" />
|
||||
Global Search
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search across all your learning resources including courses, articles, discussions, and more with AI-powered search capabilities.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex h-[calc(90vh-100px)] overflow-hidden">
|
||||
{/* Search and Filters Sidebar */}
|
||||
<div className="w-64 border-r bg-gray-50 flex-shrink-0 overflow-y-auto custom-scrollbar">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Search Input */}
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Search resources..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-10 bg-white"
|
||||
maxLength={maxCharacters}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{characterCount}/{maxCharacters} characters</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
className="w-full bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary)]/90"
|
||||
disabled={isSearching}
|
||||
>
|
||||
{isSearching ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Searching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Search
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search Suggestions */}
|
||||
{!hasSearched && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Suggested searches:</h4>
|
||||
<div className="space-y-2">
|
||||
{searchSuggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
className="text-left text-sm text-gray-600 hover:text-[var(--color-brand-primary)] hover:underline block w-full"
|
||||
>
|
||||
"{suggestion}"
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters - Only show after search */}
|
||||
{hasSearched && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm">Filters</h4>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllFilters}
|
||||
className="text-xs h-auto p-1 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type Filters */}
|
||||
<div className="space-y-3">
|
||||
<h5 className="font-medium text-xs text-gray-700 uppercase tracking-wider">Type</h5>
|
||||
<div className="space-y-2">
|
||||
{typeOptions.map((type) => (
|
||||
<div key={type} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`type-${type}`}
|
||||
checked={selectedTypes.includes(type)}
|
||||
onCheckedChange={(checked) => handleTypeChange(type, checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor={`type-${type}`} className="text-sm cursor-pointer">
|
||||
{type}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Filters */}
|
||||
<div className="space-y-3">
|
||||
<h5 className="font-medium text-xs text-gray-700 uppercase tracking-wider">Theme</h5>
|
||||
<div className="space-y-2">
|
||||
{themeOptions.map((theme) => (
|
||||
<div key={theme} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`theme-${theme}`}
|
||||
checked={selectedThemes.includes(theme)}
|
||||
onCheckedChange={(checked) => handleThemeChange(theme, checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor={`theme-${theme}`} className="text-sm cursor-pointer">
|
||||
{theme}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Area */}
|
||||
<div className="flex-1 min-w-0 overflow-y-auto custom-scrollbar">
|
||||
<div className="p-6">
|
||||
{!hasSearched ? (
|
||||
/* Placeholder State */
|
||||
<div className="flex flex-col items-center justify-center h-full text-center space-y-4 py-20">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<Search className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium text-gray-900">Search Learning Resources</h3>
|
||||
<p className="text-gray-500 max-w-md">
|
||||
Find courses, articles, webcasts, and discussions across your learning library.
|
||||
Use the search bar or try one of the suggested searches to get started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isSearching ? (
|
||||
/* Loading State */
|
||||
<div className="flex flex-col items-center justify-center h-full text-center space-y-4 py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[var(--color-brand-primary)]" />
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium text-gray-900">Searching...</h3>
|
||||
<p className="text-gray-500">
|
||||
Finding the best resources for your query
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
/* No Results State */
|
||||
<div className="flex flex-col items-center justify-center h-full text-center space-y-4 py-20">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<Search className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium text-gray-900">No results found</h3>
|
||||
<p className="text-gray-500 max-w-md">
|
||||
We couldn't find any resources matching your search. Try adjusting your search terms or filters.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-[var(--color-brand-accent)] hover:bg-[var(--color-brand-accent)]/90 text-black mt-4"
|
||||
>
|
||||
<Brain className="h-4 w-4 mr-2" />
|
||||
Open AI Mentor
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
/* Results State */
|
||||
<div className="space-y-6">
|
||||
{/* Results Header */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-medium">
|
||||
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''}
|
||||
{searchQuery && ` for "${searchQuery}"`}
|
||||
</h3>
|
||||
|
||||
{/* Active Filters */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTypes.map((type) => (
|
||||
<Badge key={type} variant="secondary" className="text-xs">
|
||||
Type: {type}
|
||||
<button
|
||||
onClick={() => handleTypeChange(type, false)}
|
||||
className="ml-1 hover:text-red-600"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{selectedThemes.map((theme) => (
|
||||
<Badge key={theme} variant="secondary" className="text-xs">
|
||||
Theme: {theme}
|
||||
<button
|
||||
onClick={() => handleThemeChange(theme, false)}
|
||||
className="ml-1 hover:text-red-600"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results List */}
|
||||
<div className="space-y-6">
|
||||
{searchResults.map((result) => (
|
||||
<Card key={result.id} className="hover:shadow-lg transition-all duration-200 cursor-pointer bg-white border hover:border-[var(--color-brand-primary)]/20">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="text-[var(--color-brand-primary)] mt-1 flex-shrink-0">
|
||||
{getTypeIcon(result.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h4
|
||||
className="font-medium text-lg hover:text-[var(--color-brand-primary)] leading-relaxed flex-1 min-w-0"
|
||||
style={{
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word'
|
||||
}}
|
||||
>
|
||||
{result.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{result.type}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{result.theme}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
className="text-gray-600 leading-relaxed min-w-0"
|
||||
style={{
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word'
|
||||
}}
|
||||
>
|
||||
{result.summary || generatePlaceholderSummary(result.type)}
|
||||
</p>
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<span
|
||||
className="text-sm text-gray-500 block min-w-0"
|
||||
style={{
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word'
|
||||
}}
|
||||
>
|
||||
{result.url}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
246
src/components/Leaderboard.tsx
Normal file
246
src/components/Leaderboard.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Trophy, Medal, Award, Crown, Star, Target } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
|
||||
interface User {
|
||||
persona: 'corporate' | 'individual';
|
||||
orgName?: string;
|
||||
}
|
||||
|
||||
interface LeaderboardEntry {
|
||||
rank: number;
|
||||
userName: string;
|
||||
exp: number;
|
||||
milestone: number;
|
||||
timeToMilestoneSec: number;
|
||||
badges?: number;
|
||||
avatar?: string;
|
||||
isCurrentUser?: boolean;
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockCurrentUser = {
|
||||
rank: 7,
|
||||
exp: 2450,
|
||||
milestone: 3000,
|
||||
timeToMilestoneSec: 86400 * 5, // 5 days
|
||||
};
|
||||
|
||||
const mockLeaderboard: LeaderboardEntry[] = [
|
||||
{ rank: 1, userName: 'Rajesh Kumar', exp: 4850, milestone: 5000, timeToMilestoneSec: 86400 * 2, badges: 12, avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=64&h=64&fit=crop&crop=face' },
|
||||
{ rank: 2, userName: 'Priya Sharma', exp: 4200, milestone: 5000, timeToMilestoneSec: 86400 * 8, badges: 10, avatar: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?w=64&h=64&fit=crop&crop=face' },
|
||||
{ rank: 3, userName: 'Amit Singh', exp: 3950, milestone: 4000, timeToMilestoneSec: 86400 * 1, badges: 11, avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=64&h=64&fit=crop&crop=face' },
|
||||
{ rank: 4, userName: 'Sneha Patel', exp: 3600, milestone: 4000, timeToMilestoneSec: 86400 * 4, badges: 9 },
|
||||
{ rank: 5, userName: 'Vikram Rao', exp: 3250, milestone: 4000, timeToMilestoneSec: 86400 * 7, badges: 8 },
|
||||
{ rank: 6, userName: 'Kavya Iyer', exp: 2800, milestone: 3000, timeToMilestoneSec: 86400 * 3, badges: 7 },
|
||||
{ rank: 7, userName: 'You', exp: 2450, milestone: 3000, timeToMilestoneSec: 86400 * 5, badges: 6, isCurrentUser: true },
|
||||
{ rank: 8, userName: 'Arjun Mehta', exp: 2200, milestone: 3000, timeToMilestoneSec: 86400 * 10, badges: 5 },
|
||||
];
|
||||
|
||||
const mockBadges = [
|
||||
{ id: '1', name: 'First Course', iconUrl: '🎯', description: 'Complete your first course', earnedAt: '2024-01-01' },
|
||||
{ id: '2', name: 'Quick Learner', iconUrl: '⚡', description: 'Complete a course in under 2 days', earnedAt: '2024-01-08' },
|
||||
{ id: '3', name: 'Consistency Master', iconUrl: '🔥', description: 'Maintain a 14-day learning streak', earnedAt: '2024-01-15' },
|
||||
{ id: '4', name: 'Knowledge Seeker', iconUrl: '📚', description: 'Complete 5 courses', earnedAt: '2024-01-20' },
|
||||
{ id: '5', name: 'Mentor Material', iconUrl: '🧠', description: 'Help 10 peers through AI mentor', earnedAt: '2024-01-25' },
|
||||
{ id: '6', name: 'Discussion Leader', iconUrl: '💬', description: 'Start 5 discussion threads', earnedAt: '2024-01-30' }
|
||||
];
|
||||
|
||||
export function Leaderboard({ user }: { user: User }) {
|
||||
const [scope, setScope] = useState<string>(user.persona === 'corporate' ? 'cohort' : 'global');
|
||||
|
||||
const getRankIcon = (rank: number) => {
|
||||
switch (rank) {
|
||||
case 1: return <Crown className="h-5 w-5 text-yellow-500" />;
|
||||
case 2: return <Medal className="h-5 w-5 text-gray-400" />;
|
||||
case 3: return <Award className="h-5 w-5 text-amber-600" />;
|
||||
default: return <span className="text-sm font-medium w-5 text-center">#{rank}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimeToMilestone = (seconds: number) => {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
if (days === 0) return 'Today';
|
||||
if (days === 1) return '1 day';
|
||||
return `${days} days`;
|
||||
};
|
||||
|
||||
const getScopeLabel = () => {
|
||||
switch (scope) {
|
||||
case 'cohort': return 'Cohort Leaderboard';
|
||||
case 'org': return 'Organization Leaderboard';
|
||||
case 'global': return 'Global Leaderboard';
|
||||
default: return 'Leaderboard';
|
||||
}
|
||||
};
|
||||
|
||||
const progressToNextMilestone = (mockCurrentUser.exp / mockCurrentUser.milestone) * 100;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 bg-white min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium">Leaderboard</h1>
|
||||
<p className="text-gray-600">Track your progress and compete with peers</p>
|
||||
</div>
|
||||
|
||||
{user.persona === 'corporate' && (
|
||||
<Select value={scope} onValueChange={setScope}>
|
||||
<SelectTrigger className="w-48 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="cohort">Cohort</SelectItem>
|
||||
<SelectItem value="org">Organization</SelectItem>
|
||||
<SelectItem value="global">Global</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current User Progress */}
|
||||
<Card className="bg-gradient-to-r from-[var(--color-brand-primary)] to-blue-600 text-white border-0">
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<p className="text-blue-100">Your Rank</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{getRankIcon(mockCurrentUser.rank)}
|
||||
<span className="text-2xl font-bold">#{mockCurrentUser.rank}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-blue-100">Experience Points</p>
|
||||
<div className="space-y-2">
|
||||
<span className="text-2xl font-bold">{mockCurrentUser.exp.toLocaleString()} XP</span>
|
||||
<Progress
|
||||
value={progressToNextMilestone}
|
||||
className="bg-blue-400/30 h-2"
|
||||
indicatorClassName="bg-[#F8C301]"
|
||||
/>
|
||||
<p className="text-xs text-blue-100">
|
||||
{mockCurrentUser.milestone - mockCurrentUser.exp} XP to next milestone
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-blue-100">Time to Milestone</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-blue-200" />
|
||||
<span className="text-xl font-bold">
|
||||
{formatTimeToMilestone(mockCurrentUser.timeToMilestoneSec)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Badges Section */}
|
||||
<Card className="bg-white border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-[var(--color-brand-accent)]" />
|
||||
Your Badges ({mockBadges.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{mockBadges.map((badge) => (
|
||||
<div key={badge.id} className="text-center space-y-2 p-3 rounded-lg bg-gray-50">
|
||||
<div className="text-2xl">{badge.iconUrl}</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{badge.name}</p>
|
||||
<p className="text-xs text-gray-600">{badge.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Leaderboard */}
|
||||
<Card className="bg-white border">
|
||||
<CardHeader>
|
||||
<CardTitle>{getScopeLabel()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{mockLeaderboard.slice(0, 20).map((entry) => (
|
||||
<div
|
||||
key={entry.rank}
|
||||
className={`flex items-center gap-4 p-4 rounded-lg transition-colors ${
|
||||
entry.isCurrentUser
|
||||
? 'bg-[var(--color-brand-primary)]/10 border border-[var(--color-brand-primary)]/20'
|
||||
: 'bg-gray-50 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center w-8">
|
||||
{getRankIcon(entry.rank)}
|
||||
</div>
|
||||
|
||||
<Avatar className="h-10 w-10">
|
||||
{entry.avatar && <AvatarImage src={entry.avatar} alt={entry.userName} />}
|
||||
<AvatarFallback>{entry.userName.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1">
|
||||
<p className={`font-medium ${entry.isCurrentUser ? 'text-[var(--color-brand-primary)]' : ''}`}>
|
||||
{entry.userName}
|
||||
{entry.isCurrentUser && <span className="ml-2 text-sm text-gray-600">(You)</span>}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{formatTimeToMilestone(entry.timeToMilestoneSec)} to milestone
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right space-y-1">
|
||||
<p className="font-bold text-lg">{entry.exp.toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-600">XP</p>
|
||||
</div>
|
||||
|
||||
{entry.badges && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{entry.badges} badges
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Current user position if outside top 20 */}
|
||||
{mockCurrentUser.rank > 20 && (
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-[var(--color-brand-primary)]/10 border border-[var(--color-brand-primary)]/20">
|
||||
<div className="flex items-center justify-center w-8">
|
||||
<span className="text-sm font-medium">#{mockCurrentUser.rank}</span>
|
||||
</div>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback>You</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-[var(--color-brand-primary)]">You</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{formatTimeToMilestone(mockCurrentUser.timeToMilestoneSec)} to milestone
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-lg">{mockCurrentUser.exp.toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-600">XP</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
226
src/components/LeadershipProfiler.tsx
Normal file
226
src/components/LeadershipProfiler.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import exampleImage from 'figma:asset/6b17aafb4d0b31099f8eec7b69e7d0a8b29ad00f.png';
|
||||
|
||||
interface Question {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
minWords: number;
|
||||
}
|
||||
|
||||
interface LeadershipProfilerProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const profilerQuestions: Question[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Think about leadership in the context of understanding. What kind of understanding do you consider to be essential for effective leadership?",
|
||||
description: "Consider areas like team dynamics, market trends, organizational culture, stakeholder needs, and personal strengths.",
|
||||
prompt: "Write about your understanding of essential leadership areas...",
|
||||
minWords: 50
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Reflect on a time when you had to lead through uncertainty. How did your thinking approach help or hinder your leadership effectiveness?",
|
||||
description: "Think about your decision-making process, how you communicated with your team, and what you learned from the experience.",
|
||||
prompt: "Describe your experience leading through uncertainty...",
|
||||
minWords: 50
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "What cognitive biases do you think most commonly affect leadership decisions, and how do you work to mitigate them?",
|
||||
description: "Consider confirmation bias, anchoring bias, availability heuristic, and other thinking patterns that might influence leadership choices.",
|
||||
prompt: "Discuss cognitive biases in leadership and your mitigation strategies...",
|
||||
minWords: 50
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "How do you balance analytical thinking with intuitive decision-making in your leadership approach?",
|
||||
description: "Reflect on situations where data-driven analysis was crucial versus times when gut instinct guided your decisions.",
|
||||
prompt: "Explain your balance between analytical and intuitive leadership...",
|
||||
minWords: 50
|
||||
}
|
||||
];
|
||||
|
||||
export function LeadershipProfiler({ onBack }: LeadershipProfilerProps) {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [responses, setResponses] = useState<Record<number, string>>({});
|
||||
const [currentResponse, setCurrentResponse] = useState('');
|
||||
|
||||
const currentQuestion = profilerQuestions[currentStep - 1];
|
||||
const totalSteps = profilerQuestions.length;
|
||||
const progressPercentage = (currentStep / totalSteps) * 100;
|
||||
|
||||
const getWordCount = (text: string) => {
|
||||
return text.trim().split(/\s+/).filter(word => word.length > 0).length;
|
||||
};
|
||||
|
||||
const getCharacterCount = (text: string) => {
|
||||
return text.length;
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
// Save current response
|
||||
setResponses(prev => ({
|
||||
...prev,
|
||||
[currentStep]: currentResponse
|
||||
}));
|
||||
|
||||
if (currentStep < totalSteps) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
setCurrentResponse(responses[currentStep + 1] || '');
|
||||
} else {
|
||||
// Completed all questions
|
||||
alert('Profiler completed! Your responses have been saved.');
|
||||
// Return to course content instead of navigating away
|
||||
onBack();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 1) {
|
||||
// Save current response
|
||||
setResponses(prev => ({
|
||||
...prev,
|
||||
[currentStep]: currentResponse
|
||||
}));
|
||||
|
||||
setCurrentStep(currentStep - 1);
|
||||
setCurrentResponse(responses[currentStep - 1] || '');
|
||||
}
|
||||
};
|
||||
|
||||
// Load saved response when component mounts or step changes
|
||||
React.useEffect(() => {
|
||||
setCurrentResponse(responses[currentStep] || '');
|
||||
}, [currentStep, responses]);
|
||||
|
||||
const wordCount = getWordCount(currentResponse);
|
||||
const characterCount = getCharacterCount(currentResponse);
|
||||
const isMinimumMet = wordCount >= currentQuestion.minWords;
|
||||
|
||||
return (
|
||||
<div className="h-full bg-background">
|
||||
{/* Header Section */}
|
||||
<div className="bg-[var(--color-brand-primary)] text-white">
|
||||
<div className="px-6 py-4">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-[24px] font-semibold">
|
||||
Thinking Orientation Reflection Exercise
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-[16px] text-white/90 mb-6 leading-relaxed">
|
||||
Based on your learning and current thinking state reflect on the questions and think of your responses
|
||||
in the context of thinking with the understanding that they will be used for generating insights.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[16px]">Step {currentStep} of {totalSteps}</span>
|
||||
<span className="text-[16px]">{Math.round(progressPercentage)}% Complete</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Progress
|
||||
value={progressPercentage}
|
||||
className="h-2 bg-white/20"
|
||||
indicatorClassName="bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="flex-1 px-6 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Question Card */}
|
||||
<div className="bg-white rounded-lg border border-border shadow-sm mb-8">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--color-brand-primary)] flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-white text-[14px] font-medium">Q{currentQuestion.id}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-[20px] font-medium text-foreground mb-3 leading-relaxed">
|
||||
{currentQuestion.title}
|
||||
</h2>
|
||||
<p className="text-[16px] text-muted-foreground">
|
||||
{currentQuestion.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
placeholder={currentQuestion.prompt}
|
||||
value={currentResponse}
|
||||
onChange={(e) => setCurrentResponse(e.target.value)}
|
||||
className="min-h-[200px] resize-none text-[16px] leading-relaxed"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between text-[14px]">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={wordCount >= currentQuestion.minWords ? 'text-[#21A36A]' : 'text-[#F59E0B]'}>
|
||||
Word count: {wordCount} {wordCount < currentQuestion.minWords && `(minimum ${currentQuestion.minWords} words)`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
Character count: {characterCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handlePrevious}
|
||||
disabled={currentStep === 1}
|
||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[14px] text-muted-foreground">
|
||||
Step {currentStep} of {totalSteps}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: totalSteps }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
i + 1 === currentStep
|
||||
? 'bg-[var(--color-brand-primary)]'
|
||||
: i + 1 < currentStep
|
||||
? 'bg-[#21A36A]'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!isMinimumMet}
|
||||
className="flex items-center gap-2 bg-[#8B7BE8] hover:bg-[#7A6AE6] text-white"
|
||||
>
|
||||
{currentStep === totalSteps ? 'Complete' : 'Next'}
|
||||
{currentStep < totalSteps && <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
441
src/components/Library.tsx
Normal file
441
src/components/Library.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Grid3X3,
|
||||
List,
|
||||
Clock,
|
||||
Star,
|
||||
Users,
|
||||
Play,
|
||||
Book,
|
||||
Video,
|
||||
FileText,
|
||||
Headphones,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Badge } from './ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from './ui/select';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Separator } from './ui/separator';
|
||||
import { AspectRatio } from './ui/aspect-ratio';
|
||||
import { ImageWithFallback } from './figma/ImageWithFallback';
|
||||
import { useAppContext } from './AppShell';
|
||||
|
||||
// Mock data for library resources
|
||||
const mockLibraryData = {
|
||||
courses: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Strategic Leadership Development',
|
||||
programName: 'Executive Leadership Programme',
|
||||
type: 'course',
|
||||
level: 'Advanced',
|
||||
duration: '8 weeks',
|
||||
rating: 4.8,
|
||||
ratingCount: 156,
|
||||
currentLearners: 89,
|
||||
progress: 65,
|
||||
instructor: 'Dr. Rajesh Kumar',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1552664730-d307ca884978?w=400',
|
||||
status: 'in-progress',
|
||||
tags: ['Leadership', 'Strategy', 'Management']
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Financial Management for Leaders',
|
||||
programName: 'Leadership Foundation Programme',
|
||||
type: 'course',
|
||||
level: 'Intermediate',
|
||||
duration: '6 weeks',
|
||||
rating: 4.6,
|
||||
ratingCount: 203,
|
||||
currentLearners: 67,
|
||||
progress: 0,
|
||||
instructor: 'Prof. Priya Sharma',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1554224155-6726b3ff858f?w=400',
|
||||
status: 'available',
|
||||
tags: ['Finance', 'Leadership', 'Analytics']
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Team Building Essentials',
|
||||
programName: 'Management Skills Programme',
|
||||
type: 'course',
|
||||
level: 'Beginner',
|
||||
duration: '4 weeks',
|
||||
rating: 4.9,
|
||||
ratingCount: 89,
|
||||
currentLearners: 134,
|
||||
progress: 100,
|
||||
instructor: 'Mr. Arjun Mehta',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=400',
|
||||
status: 'completed',
|
||||
tags: ['Team Building', 'Communication', 'Leadership']
|
||||
}
|
||||
],
|
||||
webinars: [
|
||||
{
|
||||
id: 'w1',
|
||||
title: 'Future of Leadership in Digital Age',
|
||||
type: 'webinar',
|
||||
date: '2024-09-15T14:00:00Z',
|
||||
duration: '90 minutes',
|
||||
speaker: 'Dr. Anita Singh',
|
||||
attendees: 245,
|
||||
status: 'upcoming',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1560472354-b33ff0c44a43?w=400',
|
||||
tags: ['Digital Transformation', 'Future Trends']
|
||||
}
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
id: 'r1',
|
||||
title: 'Leadership Assessment Framework',
|
||||
type: 'document',
|
||||
fileType: 'PDF',
|
||||
size: '2.3 MB',
|
||||
downloads: 456,
|
||||
thumbnail: 'https://images.unsplash.com/photo-1586953208448-b95a79798f07?w=400',
|
||||
tags: ['Assessment', 'Framework']
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
title: 'Decision Making Podcast Series',
|
||||
type: 'audio',
|
||||
duration: '45 minutes',
|
||||
episodes: 8,
|
||||
thumbnail: 'https://images.unsplash.com/photo-1478737270239-2f02b77fc618?w=400',
|
||||
tags: ['Decision Making', 'Podcast']
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export function Library() {
|
||||
const { user } = useAppContext();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [selectedFilters, setSelectedFilters] = useState({
|
||||
type: 'all',
|
||||
level: 'all',
|
||||
status: 'all',
|
||||
tags: [] as string[]
|
||||
});
|
||||
|
||||
const allResources = [
|
||||
...mockLibraryData.courses,
|
||||
...mockLibraryData.webinars,
|
||||
...mockLibraryData.resources
|
||||
];
|
||||
|
||||
const getResourceIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'course': return Book;
|
||||
case 'webinar': return Video;
|
||||
case 'document': return FileText;
|
||||
case 'audio': return Headphones;
|
||||
default: return Book;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'bg-green-100 text-green-800';
|
||||
case 'in-progress': return 'bg-blue-100 text-blue-800';
|
||||
case 'available': return 'bg-gray-100 text-gray-800';
|
||||
case 'upcoming': return 'bg-yellow-100 text-yellow-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const ResourceCard = ({ resource }: { resource: any }) => {
|
||||
const Icon = getResourceIcon(resource.type);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden hover:shadow-md transition-shadow">
|
||||
<div className="relative">
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<ImageWithFallback
|
||||
src={resource.thumbnail}
|
||||
alt={resource.title}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</AspectRatio>
|
||||
<div className="absolute top-2 left-2">
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
<Icon className="h-3 w-3 mr-1" />
|
||||
{resource.type}
|
||||
</Badge>
|
||||
</div>
|
||||
{resource.status && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<Badge className={`capitalize ${getStatusColor(resource.status)}`}>
|
||||
{resource.status.replace('-', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardContent className="p-4 space-y-3">
|
||||
{resource.programName && (
|
||||
<p className="text-xs text-muted-foreground">{resource.programName}</p>
|
||||
)}
|
||||
|
||||
<h3 className="font-medium line-clamp-2">{resource.title}</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{resource.tags?.slice(0, 2).map((tag: string) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
{resource.duration && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{resource.duration}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resource.rating && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
|
||||
{resource.rating} ({resource.ratingCount})
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resource.currentLearners && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{resource.currentLearners}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{resource.progress !== undefined && resource.progress > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span>Progress</span>
|
||||
<span>{resource.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-[#04045B] h-1.5 rounded-full"
|
||||
style={{ width: `${resource.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
{resource.status === 'completed' ? (
|
||||
<Button variant="outline" size="sm" className="flex-1">
|
||||
Recap Course
|
||||
</Button>
|
||||
) : resource.type === 'course' ? (
|
||||
<Button size="sm" className="flex-1">
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
{resource.progress > 0 ? 'Continue' : 'Start Course'}
|
||||
</Button>
|
||||
) : resource.type === 'document' ? (
|
||||
<Button size="sm" className="flex-1">
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
Download
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" className="flex-1">
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-medium">Library</h1>
|
||||
<p className="text-muted-foreground">Explore courses, webinars, and resources</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<Grid3X3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Filters Sidebar */}
|
||||
<div className="w-64 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Filters</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Search */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Search</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search library..."
|
||||
className="pl-10"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Resource Type */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Type</label>
|
||||
<Select value={selectedFilters.type} onValueChange={(value) =>
|
||||
setSelectedFilters(prev => ({ ...prev, type: value }))
|
||||
}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="course">Courses</SelectItem>
|
||||
<SelectItem value="webinar">Webinars</SelectItem>
|
||||
<SelectItem value="document">Documents</SelectItem>
|
||||
<SelectItem value="audio">Audio</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Level */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Level</label>
|
||||
<Select value={selectedFilters.level} onValueChange={(value) =>
|
||||
setSelectedFilters(prev => ({ ...prev, level: value }))
|
||||
}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Levels</SelectItem>
|
||||
<SelectItem value="beginner">Beginner</SelectItem>
|
||||
<SelectItem value="intermediate">Intermediate</SelectItem>
|
||||
<SelectItem value="advanced">Advanced</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Status</label>
|
||||
<Select value={selectedFilters.status} onValueChange={(value) =>
|
||||
setSelectedFilters(prev => ({ ...prev, status: value }))
|
||||
}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="available">Available</SelectItem>
|
||||
<SelectItem value="in-progress">In Progress</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="upcoming">Upcoming</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Popular Tags */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Popular Tags</label>
|
||||
<div className="space-y-2">
|
||||
{['Leadership', 'Strategy', 'Finance', 'Communication', 'Team Building'].map((tag) => (
|
||||
<div key={tag} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={tag}
|
||||
checked={selectedFilters.tags.includes(tag)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedFilters(prev => ({
|
||||
...prev,
|
||||
tags: [...prev.tags, tag]
|
||||
}));
|
||||
} else {
|
||||
setSelectedFilters(prev => ({
|
||||
...prev,
|
||||
tags: prev.tags.filter(t => t !== tag)
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={tag} className="text-sm">{tag}</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 space-y-6">
|
||||
{/* Results Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground">
|
||||
Showing {allResources.length} results
|
||||
</p>
|
||||
|
||||
<Select defaultValue="relevance">
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="relevance">Sort by Relevance</SelectItem>
|
||||
<SelectItem value="newest">Newest First</SelectItem>
|
||||
<SelectItem value="rating">Highest Rated</SelectItem>
|
||||
<SelectItem value="popular">Most Popular</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Resource Grid */}
|
||||
<div className={`grid gap-6 ${
|
||||
viewMode === 'grid'
|
||||
? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
|
||||
: 'grid-cols-1'
|
||||
}`}>
|
||||
{allResources.map((resource) => (
|
||||
<ResourceCard key={resource.id} resource={resource} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1866
src/components/MyCourses.tsx
Normal file
1866
src/components/MyCourses.tsx
Normal file
File diff suppressed because it is too large
Load Diff
387
src/components/Notes.tsx
Normal file
387
src/components/Notes.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
Calendar,
|
||||
Book,
|
||||
StickyNote,
|
||||
Filter
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Badge } from './ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from './ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from './ui/dialog';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Separator } from './ui/separator';
|
||||
import { useAppContext } from './AppShell';
|
||||
|
||||
// Mock data for notes
|
||||
const mockNotesData = [
|
||||
{
|
||||
id: '1',
|
||||
courseId: 'course-1',
|
||||
courseTitle: 'Strategic Leadership Development',
|
||||
moduleId: 'module-3',
|
||||
moduleTitle: 'Decision Making Framework',
|
||||
lessonId: 'lesson-2',
|
||||
lessonTitle: 'Risk Assessment Strategies',
|
||||
content: 'Key insight: Risk assessment should consider both quantitative metrics and qualitative factors. The SWOT analysis framework is particularly useful for strategic decisions.\n\nRemember to:\n- Involve stakeholders in the assessment process\n- Document assumptions clearly\n- Review decisions periodically',
|
||||
createdAt: '2024-09-02T14:30:00Z',
|
||||
updatedAt: '2024-09-02T14:30:00Z',
|
||||
tags: ['risk-assessment', 'decision-making', 'strategy']
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
courseId: 'course-1',
|
||||
courseTitle: 'Strategic Leadership Development',
|
||||
moduleId: 'module-2',
|
||||
moduleTitle: 'Leadership Communication',
|
||||
lessonId: 'lesson-4',
|
||||
lessonTitle: 'Effective Presentation Skills',
|
||||
content: 'Presentation framework:\n1. Hook - grab attention in first 30 seconds\n2. Overview - tell them what you\'ll tell them\n3. Body - 3 main points maximum\n4. Summary - tell them what you told them\n5. Call to action - what do you want them to do?\n\nPractice with the "rule of 3" - people remember things in threes.',
|
||||
createdAt: '2024-08-28T10:15:00Z',
|
||||
updatedAt: '2024-08-28T10:15:00Z',
|
||||
tags: ['communication', 'presentations', 'leadership']
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
courseId: 'course-2',
|
||||
courseTitle: 'Team Building Essentials',
|
||||
moduleId: 'module-1',
|
||||
moduleTitle: 'Understanding Team Dynamics',
|
||||
lessonId: 'lesson-1',
|
||||
lessonTitle: 'Tuckman\'s Team Development Model',
|
||||
content: 'The 4 stages of team development:\n\n1. FORMING - Team members are polite, excited, anxious\n2. STORMING - Conflicts arise, different opinions emerge\n3. NORMING - Team establishes norms and ways of working\n4. PERFORMING - Team works effectively towards goals\n\nAs a leader, my role changes in each stage. In forming, I need to provide clear direction. In storming, I mediate conflicts.',
|
||||
createdAt: '2024-08-25T16:45:00Z',
|
||||
updatedAt: '2024-08-25T16:45:00Z',
|
||||
tags: ['team-development', 'tuckman-model', 'leadership']
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
courseId: 'course-2',
|
||||
courseTitle: 'Team Building Essentials',
|
||||
moduleId: 'module-2',
|
||||
moduleTitle: 'Conflict Resolution',
|
||||
lessonId: 'lesson-3',
|
||||
lessonTitle: 'Mediation Techniques',
|
||||
content: 'Thomas-Kilmann Conflict Mode Instrument - 5 approaches:\n\n• Competing (assertive, uncooperative)\n• Accommodating (unassertive, cooperative)\n• Avoiding (unassertive, uncooperative)\n• Collaborating (assertive, cooperative) ← BEST for most situations\n• Compromising (moderate assertive & cooperative)\n\nNeed to practice active listening more in conflicts.',
|
||||
createdAt: '2024-08-20T11:20:00Z',
|
||||
updatedAt: '2024-08-22T09:30:00Z',
|
||||
tags: ['conflict-resolution', 'mediation', 'thomas-kilmann']
|
||||
}
|
||||
];
|
||||
|
||||
export function Notes() {
|
||||
const { user } = useAppContext();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCourse, setSelectedCourse] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('newest');
|
||||
const [editingNote, setEditingNote] = useState<any>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Asia/Kolkata'
|
||||
});
|
||||
};
|
||||
|
||||
const getUniqueCoursesFromNotes = () => {
|
||||
const courses = mockNotesData.reduce((acc, note) => {
|
||||
if (!acc.find(c => c.id === note.courseId)) {
|
||||
acc.push({
|
||||
id: note.courseId,
|
||||
title: note.courseTitle
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, [] as any[]);
|
||||
return courses;
|
||||
};
|
||||
|
||||
const filteredNotes = mockNotesData.filter(note => {
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
note.content.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
note.courseTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
note.moduleTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
note.lessonTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
note.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
const matchesCourse = selectedCourse === 'all' || note.courseId === selectedCourse;
|
||||
|
||||
return matchesSearch && matchesCourse;
|
||||
});
|
||||
|
||||
const sortedNotes = [...filteredNotes].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'newest':
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
case 'oldest':
|
||||
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||
case 'course':
|
||||
return a.courseTitle.localeCompare(b.courseTitle);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const NoteCard = ({ note }: { note: any }) => {
|
||||
const previewText = note.content.length > 200
|
||||
? note.content.substring(0, 200) + '...'
|
||||
: note.content;
|
||||
|
||||
return (
|
||||
<Card className="group hover:shadow-lg transition-all duration-300 border border-gray-200 bg-white">
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-5">
|
||||
{/* Header with course path */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-[#04045B] to-[#030344] flex items-center justify-center flex-shrink-0">
|
||||
<Book className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="bg-blue-50 rounded-lg p-3 border border-blue-100">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<span className="font-medium text-[#04045B]">{note.courseTitle}</span>
|
||||
<span className="text-gray-400">›</span>
|
||||
<span className="text-gray-600">{note.moduleTitle}</span>
|
||||
<span className="text-gray-400">›</span>
|
||||
<span className="text-gray-600">{note.lessonTitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Calendar className="h-4 w-4 text-gray-400" />
|
||||
<span>
|
||||
{note.updatedAt !== note.createdAt ? 'Updated' : 'Created'} on {formatDate(note.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// TODO: Open lesson
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingNote(note);
|
||||
setIsDialogOpen(true);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// TODO: Delete note
|
||||
}}
|
||||
className="gap-2 text-red-600 hover:text-red-700 hover:border-red-200 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note Content */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100">
|
||||
<p className="text-sm leading-relaxed text-gray-700 whitespace-pre-line">
|
||||
{previewText}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{note.tags && note.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{note.tags.map((tag: string) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="text-xs font-medium bg-[#F8C301]/10 text-[#04045B] border-[#F8C301]/30"
|
||||
>
|
||||
#{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8 bg-white min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold text-gray-900">My Notes</h1>
|
||||
<p className="text-gray-600">
|
||||
Centralized view of all your learning notes and insights
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Note Editor Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingNote ? 'Edit Note' : 'Add New Note'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingNote ? 'Make changes to your note below.' : 'Create a new note to capture important insights from your learning.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* TODO: Add note editor form */}
|
||||
<Textarea
|
||||
placeholder="Enter your note..."
|
||||
className="min-h-[200px]"
|
||||
defaultValue={editingNote?.content || ''}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => setIsDialogOpen(false)}>
|
||||
{editingNote ? 'Save Changes' : 'Save Note'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="bg-gradient-to-r from-gray-50 to-blue-50/30 p-6 rounded-2xl border border-gray-100 shadow-sm">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1 max-w-lg">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search notes, courses, or tags..."
|
||||
className="pl-12 h-12 bg-white border-gray-200 shadow-sm focus:ring-2 focus:ring-[#04045B]/20 focus:border-[#04045B] transition-all"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Select value={selectedCourse} onValueChange={setSelectedCourse}>
|
||||
<SelectTrigger className="w-52 h-12 bg-white border-gray-200 shadow-sm">
|
||||
<Filter className="h-4 w-4 mr-2 text-gray-400" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Courses</SelectItem>
|
||||
{getUniqueCoursesFromNotes().map((course) => (
|
||||
<SelectItem key={course.id} value={course.id}>
|
||||
{course.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-44 h-12 bg-white border-gray-200 shadow-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="newest">Newest First</SelectItem>
|
||||
<SelectItem value="oldest">Oldest First</SelectItem>
|
||||
<SelectItem value="course">By Course</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
|
||||
|
||||
{/* Results */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between bg-white p-4 rounded-lg border border-gray-100 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-[#04045B] rounded-full"></div>
|
||||
<p className="text-gray-700 font-medium">
|
||||
Showing {sortedNotes.length} of {mockNotesData.length} notes
|
||||
</p>
|
||||
</div>
|
||||
{sortedNotes.length > 0 && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{sortBy === 'newest' ? 'Most recent first' :
|
||||
sortBy === 'oldest' ? 'Oldest first' :
|
||||
'Grouped by course'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sortedNotes.length === 0 ? (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-[#04045B]/10 to-blue-50 rounded-full flex items-center justify-center">
|
||||
<StickyNote className="h-10 w-10 text-[#04045B]" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">No notes found</h3>
|
||||
<p className="text-gray-600 mb-6 max-w-md mx-auto">
|
||||
{searchQuery || selectedCourse !== 'all'
|
||||
? 'Try adjusting your search criteria or filters to find more notes'
|
||||
: 'Start taking notes during your lessons to capture important insights'
|
||||
}
|
||||
</p>
|
||||
{(!searchQuery && selectedCourse === 'all') && (
|
||||
<Button className="bg-[#04045B] hover:bg-[#030344] text-white px-6 py-2.5">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Your First Note
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{sortedNotes.map((note) => (
|
||||
<NoteCard key={note.id} note={note} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
367
src/components/Notifications.tsx
Normal file
367
src/components/Notifications.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
Filter,
|
||||
Mail,
|
||||
CheckCheck,
|
||||
Calendar,
|
||||
BookOpen,
|
||||
MessageSquare,
|
||||
AlertTriangle,
|
||||
CreditCard,
|
||||
Clock,
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Badge } from './ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from './ui/select';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Separator } from './ui/separator';
|
||||
import { useAppContext } from './AppShell';
|
||||
|
||||
// Mock notifications data
|
||||
const mockNotifications = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'module-due',
|
||||
title: 'Module Due This Week',
|
||||
message: 'Module 3: Decision Making Framework is due in 2 days',
|
||||
timestamp: '2024-09-03T10:30:00Z',
|
||||
read: false,
|
||||
courseTitle: 'Strategic Leadership Development',
|
||||
priority: 'high'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'new-article',
|
||||
title: 'New Research Paper Available',
|
||||
message: 'New article: "The Future of Remote Leadership" has been added to your library',
|
||||
timestamp: '2024-09-03T09:15:00Z',
|
||||
read: false,
|
||||
priority: 'medium'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'discussion',
|
||||
title: 'New Discussion Reply',
|
||||
message: 'Dr. Kumar replied to your comment in "Risk Assessment Strategies" discussion',
|
||||
timestamp: '2024-09-02T16:45:00Z',
|
||||
read: true,
|
||||
courseTitle: 'Strategic Leadership Development',
|
||||
priority: 'low'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'course-assigned',
|
||||
title: 'New Course Assigned',
|
||||
message: 'Your organization has enrolled you in "Financial Management for Leaders"',
|
||||
timestamp: '2024-09-02T14:20:00Z',
|
||||
read: false,
|
||||
courseTitle: 'Financial Management for Leaders',
|
||||
priority: 'high'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'overdue',
|
||||
title: 'Assignment Overdue',
|
||||
message: 'Leadership Assessment for Module 2 is now overdue',
|
||||
timestamp: '2024-09-01T18:00:00Z',
|
||||
read: true,
|
||||
courseTitle: 'Strategic Leadership Development',
|
||||
priority: 'high'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'webinar',
|
||||
title: 'Webinar Reminder',
|
||||
message: 'Webinar "Future of Leadership in Digital Age" starts in 1 hour',
|
||||
timestamp: '2024-09-01T13:00:00Z',
|
||||
read: true,
|
||||
priority: 'medium'
|
||||
}
|
||||
];
|
||||
|
||||
export function Notifications() {
|
||||
const { user } = useAppContext();
|
||||
const [notifications, setNotifications] = useState(mockNotifications);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterType, setFilterType] = useState('all');
|
||||
const [selectedNotifications, setSelectedNotifications] = useState<string[]>([]);
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'module-due':
|
||||
case 'overdue':
|
||||
return Calendar;
|
||||
case 'new-article':
|
||||
return BookOpen;
|
||||
case 'discussion':
|
||||
return MessageSquare;
|
||||
case 'course-assigned':
|
||||
case 'course-purchased':
|
||||
return CreditCard;
|
||||
case 'webinar':
|
||||
return Clock;
|
||||
default:
|
||||
return Bell;
|
||||
}
|
||||
};
|
||||
|
||||
const getNotificationColor = (type: string, priority: string) => {
|
||||
if (priority === 'high') return 'border-l-red-500';
|
||||
if (priority === 'medium') return 'border-l-yellow-500';
|
||||
return 'border-l-blue-500';
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
const now = new Date();
|
||||
const notificationTime = new Date(timestamp);
|
||||
const diffInHours = (now.getTime() - notificationTime.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
if (diffInHours < 1) {
|
||||
const diffInMinutes = Math.floor(diffInHours * 60);
|
||||
return `${diffInMinutes}m ago`;
|
||||
} else if (diffInHours < 24) {
|
||||
return `${Math.floor(diffInHours)}h ago`;
|
||||
} else {
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
return `${diffInDays}d ago`;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredNotifications = notifications.filter(notification => {
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
notification.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
notification.message.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
notification.courseTitle?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesType = filterType === 'all' ||
|
||||
(filterType === 'unread' && !notification.read) ||
|
||||
(filterType === 'overdue' && notification.type === 'overdue') ||
|
||||
(filterType === 'this-week' && ['module-due'].includes(notification.type)) ||
|
||||
(filterType === 'this-month' && ['module-due', 'course-assigned'].includes(notification.type)) ||
|
||||
notification.type === filterType;
|
||||
|
||||
return matchesSearch && matchesType;
|
||||
});
|
||||
|
||||
const markAsRead = (id: string) => {
|
||||
setNotifications(prev =>
|
||||
prev.map(notification =>
|
||||
notification.id === id
|
||||
? { ...notification, read: true }
|
||||
: notification
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const markAllAsRead = () => {
|
||||
setNotifications(prev =>
|
||||
prev.map(notification => ({ ...notification, read: true }))
|
||||
);
|
||||
};
|
||||
|
||||
const toggleNotificationSelection = (id: string) => {
|
||||
setSelectedNotifications(prev =>
|
||||
prev.includes(id)
|
||||
? prev.filter(nId => nId !== id)
|
||||
: [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const markSelectedAsRead = () => {
|
||||
setNotifications(prev =>
|
||||
prev.map(notification =>
|
||||
selectedNotifications.includes(notification.id)
|
||||
? { ...notification, read: true }
|
||||
: notification
|
||||
)
|
||||
);
|
||||
setSelectedNotifications([]);
|
||||
};
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-medium">Notifications</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Stay updated with your learning progress and activities
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={unreadCount > 0 ? "destructive" : "secondary"}>
|
||||
{unreadCount} unread
|
||||
</Badge>
|
||||
<Button variant="outline" onClick={markAllAsRead}>
|
||||
<CheckCheck className="h-4 w-4 mr-2" />
|
||||
Mark all as read
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search notifications..."
|
||||
className="pl-10"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Notifications</SelectItem>
|
||||
<SelectItem value="unread">Unread</SelectItem>
|
||||
<SelectItem value="new-article">New Articles</SelectItem>
|
||||
<SelectItem value="discussion">Discussions</SelectItem>
|
||||
<SelectItem value="this-week">Due This Week</SelectItem>
|
||||
<SelectItem value="this-month">Due This Month</SelectItem>
|
||||
<SelectItem value="overdue">Overdue</SelectItem>
|
||||
<SelectItem value="course-assigned">Course Assigned</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedNotifications.length > 0 && (
|
||||
<Button variant="outline" onClick={markSelectedAsRead}>
|
||||
Mark Selected as Read ({selectedNotifications.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className="space-y-2">
|
||||
{filteredNotifications.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center">
|
||||
<Bell className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="font-medium mb-2">No notifications found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchQuery || filterType !== 'all'
|
||||
? 'Try adjusting your search or filters'
|
||||
: 'You\'re all caught up!'
|
||||
}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredNotifications.map((notification) => {
|
||||
const Icon = getNotificationIcon(notification.type);
|
||||
const isSelected = selectedNotifications.includes(notification.id);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={notification.id}
|
||||
className={`transition-all hover:shadow-sm border-l-4 ${
|
||||
getNotificationColor(notification.type, notification.priority)
|
||||
} ${notification.read ? 'opacity-60' : ''} ${
|
||||
isSelected ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleNotificationSelection(notification.id)}
|
||||
className="mt-1"
|
||||
/>
|
||||
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
notification.priority === 'high' ? 'bg-red-100 text-red-600' :
|
||||
notification.priority === 'medium' ? 'bg-yellow-100 text-yellow-600' :
|
||||
'bg-blue-100 text-blue-600'
|
||||
}`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className={`font-medium ${!notification.read ? 'text-foreground' : 'text-muted-foreground'}`}>
|
||||
{notification.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTime(notification.timestamp)}
|
||||
</span>
|
||||
{!notification.read && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => markAsRead(notification.id)}
|
||||
className="h-6 px-2"
|
||||
>
|
||||
<Mail className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{notification.message}
|
||||
</p>
|
||||
|
||||
{notification.courseTitle && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<BookOpen className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{notification.courseTitle}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs capitalize ${
|
||||
notification.priority === 'high' ? 'border-red-200 text-red-700' :
|
||||
notification.priority === 'medium' ? 'border-yellow-200 text-yellow-700' :
|
||||
'border-blue-200 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{notification.type.replace('-', ' ')}
|
||||
</Badge>
|
||||
|
||||
{!notification.read && (
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Load More */}
|
||||
{filteredNotifications.length > 0 && (
|
||||
<div className="text-center">
|
||||
<Button variant="outline">
|
||||
Load More Notifications
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
522
src/components/OfflineVideoDetailPage.tsx
Normal file
522
src/components/OfflineVideoDetailPage.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Progress } from './ui/progress';
|
||||
import {
|
||||
ChevronRight,
|
||||
Play,
|
||||
Pause,
|
||||
Volume2,
|
||||
Maximize,
|
||||
Download,
|
||||
Share,
|
||||
BookmarkPlus,
|
||||
Clock,
|
||||
Video,
|
||||
FileVideo,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Settings,
|
||||
Subtitles,
|
||||
Wifi,
|
||||
WifiOff
|
||||
} from 'lucide-react';
|
||||
|
||||
// Mock offline video data
|
||||
interface OfflineVideoData {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
duration: string;
|
||||
category: string;
|
||||
instructor: {
|
||||
name: string;
|
||||
title: string;
|
||||
bio: string;
|
||||
avatar: string;
|
||||
};
|
||||
videoUrl: string;
|
||||
downloadUrl: string;
|
||||
fileSize: string;
|
||||
resolution: string;
|
||||
isDownloaded: boolean;
|
||||
downloadProgress: number;
|
||||
progress: number;
|
||||
lastWatched: string;
|
||||
thumbnail: string;
|
||||
chapters: VideoChapter[];
|
||||
transcriptAvailable: boolean;
|
||||
subtitlesAvailable: boolean;
|
||||
}
|
||||
|
||||
interface VideoChapter {
|
||||
id: string;
|
||||
title: string;
|
||||
startTime: string;
|
||||
duration: string;
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
const videoDatabase: Record<string, OfflineVideoData> = {
|
||||
'video-1': {
|
||||
id: 'video-1',
|
||||
title: 'Effective Communication Masterclass',
|
||||
subtitle: 'Essential Skills for Professional Success',
|
||||
description: 'Master the art of effective communication with this comprehensive video tutorial. Learn proven techniques for clear messaging, active listening, and persuasive presentation that will transform your professional interactions.',
|
||||
duration: '45 minutes',
|
||||
category: 'Communication Skills',
|
||||
instructor: {
|
||||
name: 'Jennifer Martinez',
|
||||
title: 'Communication Expert',
|
||||
bio: 'Jennifer is a renowned communication consultant with 15+ years of experience training executives and teams at Fortune 500 companies.',
|
||||
avatar: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=100&h=100&fit=crop'
|
||||
},
|
||||
videoUrl: 'https://example.com/video.mp4',
|
||||
downloadUrl: 'https://example.com/download/video.mp4',
|
||||
fileSize: '850 MB',
|
||||
resolution: '1080p',
|
||||
isDownloaded: true,
|
||||
downloadProgress: 100,
|
||||
progress: 67,
|
||||
lastWatched: '2024-03-15T10:30:00Z',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=800&h=450&fit=crop',
|
||||
transcriptAvailable: true,
|
||||
subtitlesAvailable: true,
|
||||
chapters: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
title: 'Introduction to Effective Communication',
|
||||
startTime: '0:00',
|
||||
duration: '8 min',
|
||||
isCompleted: true
|
||||
},
|
||||
{
|
||||
id: 'chapter-2',
|
||||
title: 'Verbal Communication Techniques',
|
||||
startTime: '8:00',
|
||||
duration: '12 min',
|
||||
isCompleted: true
|
||||
},
|
||||
{
|
||||
id: 'chapter-3',
|
||||
title: 'Non-Verbal Communication',
|
||||
startTime: '20:00',
|
||||
duration: '10 min',
|
||||
isCompleted: true
|
||||
},
|
||||
{
|
||||
id: 'chapter-4',
|
||||
title: 'Active Listening Skills',
|
||||
startTime: '30:00',
|
||||
duration: '8 min',
|
||||
isCompleted: false
|
||||
},
|
||||
{
|
||||
id: 'chapter-5',
|
||||
title: 'Presentation Techniques',
|
||||
startTime: '38:00',
|
||||
duration: '7 min',
|
||||
isCompleted: false
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
interface OfflineVideoDetailPageProps {
|
||||
videoId: string;
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
export function OfflineVideoDetailPage({ videoId, onNavigateBack }: OfflineVideoDetailPageProps) {
|
||||
const [videoData] = useState<OfflineVideoData>(videoDatabase[videoId] || videoDatabase['video-1']);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [showTranscript, setShowTranscript] = useState(false);
|
||||
const [selectedChapter, setSelectedChapter] = useState<string | null>(null);
|
||||
|
||||
const formatLastWatched = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
// In a real app, this would trigger the download
|
||||
console.log('Downloading video...');
|
||||
};
|
||||
|
||||
const jumpToChapter = (startTime: string) => {
|
||||
// In a real app, this would seek to the specific time
|
||||
console.log(`Jumping to ${startTime}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full bg-background">
|
||||
{/* Breadcrumb Navigation - Full Width */}
|
||||
<div className="bg-background border-b border-border py-3">
|
||||
<div className="px-6">
|
||||
<nav className="flex items-center gap-2 text-[14px] text-muted-foreground">
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
className="hover:text-[var(--color-brand-primary)] transition-colors"
|
||||
>
|
||||
Library
|
||||
</button>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span>{videoData.category}</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-foreground font-medium">Offline Videos</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-foreground font-medium">{videoData.title}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Header - Full Width */}
|
||||
<div className="bg-background border-b border-border py-6">
|
||||
<div className="px-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Badge variant="secondary" className="bg-[var(--color-brand-primary)]/10 text-[var(--color-brand-primary)] border-[var(--color-brand-primary)]/20">
|
||||
<FileVideo className="h-3 w-3 mr-1" />
|
||||
Offline Video
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[12px]">
|
||||
{videoData.resolution}
|
||||
</Badge>
|
||||
{videoData.isDownloaded && (
|
||||
<Badge className="bg-green-100 text-green-800 border-green-200">
|
||||
<WifiOff className="h-3 w-3 mr-1" />
|
||||
Downloaded
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-[28px] font-bold leading-[34px] tracking-[-1px] mb-2 text-gray-900">
|
||||
{videoData.title}
|
||||
</h1>
|
||||
<p className="text-[16px] text-muted-foreground mb-3">
|
||||
{videoData.subtitle}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-[14px] text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{videoData.duration}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Video className="h-4 w-4" />
|
||||
<span>{videoData.fileSize}</span>
|
||||
</div>
|
||||
{videoData.lastWatched && (
|
||||
<span>Last watched: {formatLastWatched(videoData.lastWatched)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!videoData.isDownloaded ? (
|
||||
<Button onClick={handleDownload}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download ({videoData.fileSize})
|
||||
</Button>
|
||||
) : (
|
||||
<Button>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Resume Watching
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Layout */}
|
||||
<div className="flex h-full">
|
||||
{/* Left Sidebar - Video Chapters */}
|
||||
<aside className="w-[300px] flex-shrink-0 bg-white border-r border-border">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-[16px] font-semibold text-foreground mb-6">Video Chapters</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{videoData.chapters.map((chapter, index) => (
|
||||
<div
|
||||
key={chapter.id}
|
||||
className={`p-3 rounded-lg cursor-pointer transition-colors border ${
|
||||
selectedChapter === chapter.id
|
||||
? 'bg-[var(--color-brand-primary)]/5 border-[var(--color-brand-primary)]'
|
||||
: 'border-border hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedChapter(chapter.id);
|
||||
jumpToChapter(chapter.startTime);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
chapter.isCompleted ? 'bg-[#21A36A]' : 'bg-gray-200'
|
||||
}`}>
|
||||
{chapter.isCompleted ? (
|
||||
<Video className="h-3 w-3 text-white" />
|
||||
) : (
|
||||
<span className="text-[12px] font-medium text-gray-600">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-[14px] font-medium text-foreground">{chapter.title}</p>
|
||||
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
|
||||
<span>{chapter.startTime}</span>
|
||||
<span>•</span>
|
||||
<span>{chapter.duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Video Controls */}
|
||||
<div className="mt-8 pt-6 border-t border-border">
|
||||
<h4 className="text-[14px] font-medium text-foreground mb-4">Video Options</h4>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setShowTranscript(!showTranscript)}
|
||||
>
|
||||
<Subtitles className="h-4 w-4 mr-2" />
|
||||
{showTranscript ? 'Hide' : 'Show'} Transcript
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Video Settings
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<Share className="h-4 w-4 mr-2" />
|
||||
Share Video
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<BookmarkPlus className="h-4 w-4 mr-2" />
|
||||
Save to Playlist
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 min-w-0">
|
||||
<div className="p-6">
|
||||
{/* Video Player */}
|
||||
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden mb-6">
|
||||
<img
|
||||
src={videoData.thumbnail}
|
||||
alt={videoData.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => setIsPlaying(!isPlaying)}
|
||||
className="w-16 h-16 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center hover:bg-white/30 transition-colors"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-8 w-8 text-white" />
|
||||
) : (
|
||||
<Play className="h-8 w-8 text-white ml-1" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Video Progress Bar */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/50 to-transparent p-4">
|
||||
<div className="flex items-center gap-4 text-white text-[14px] mb-2">
|
||||
<button className="hover:opacity-75">
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</button>
|
||||
<span>30:45 / {videoData.duration}</span>
|
||||
<button className="hover:opacity-75">
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button className="hover:opacity-75">
|
||||
<Subtitles className="h-4 w-4" />
|
||||
</button>
|
||||
<button className="hover:opacity-75">
|
||||
<Volume2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button className="hover:opacity-75">
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
<button className="hover:opacity-75">
|
||||
<Maximize className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Progress
|
||||
value={videoData.progress}
|
||||
className="h-1 bg-white/20 [&>div]:bg-[var(--color-brand-accent)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Description */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-[20px] font-semibold text-foreground mb-4">About this video</h2>
|
||||
<p className="text-[16px] text-muted-foreground leading-relaxed">
|
||||
{videoData.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Transcript (if shown) */}
|
||||
{showTranscript && videoData.transcriptAvailable && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-[20px] font-semibold text-foreground mb-4">Video Transcript</h3>
|
||||
<div className="bg-muted/30 rounded-lg p-4 max-h-64 overflow-y-auto">
|
||||
<p className="text-[14px] text-muted-foreground leading-relaxed">
|
||||
[00:00] Welcome to this comprehensive communication masterclass. Today we'll explore the fundamental principles that make communication truly effective...
|
||||
</p>
|
||||
<p className="text-[14px] text-muted-foreground leading-relaxed mt-3">
|
||||
[01:30] The first key element of effective communication is clarity. When we speak with clarity, we ensure our message is understood exactly as intended...
|
||||
</p>
|
||||
<p className="text-[14px] text-muted-foreground leading-relaxed mt-3">
|
||||
[03:15] Active listening is perhaps the most underrated communication skill. It's not just about hearing words, but understanding the complete message...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructor Information */}
|
||||
<div>
|
||||
<h3 className="text-[20px] font-semibold text-foreground mb-4">Instructor</h3>
|
||||
<div className="flex items-start gap-4 p-4 bg-muted/50 rounded-lg">
|
||||
<img
|
||||
src={videoData.instructor.avatar}
|
||||
alt={videoData.instructor.name}
|
||||
className="w-16 h-16 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-[18px] font-semibold text-foreground">{videoData.instructor.name}</h4>
|
||||
<p className="text-[16px] text-[var(--color-brand-primary)] mb-2">{videoData.instructor.title}</p>
|
||||
<p className="text-[16px] text-muted-foreground">{videoData.instructor.bio}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Right Sidebar - Download & Progress */}
|
||||
<aside className="w-[300px] flex-shrink-0 bg-white border-l border-border">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-[18px] font-semibold text-foreground mb-4">Download details</h3>
|
||||
|
||||
{/* Download Status */}
|
||||
<div className="mb-6">
|
||||
{videoData.isDownloaded ? (
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<WifiOff className="h-4 w-4 text-green-600" />
|
||||
<span className="text-[14px] font-medium text-green-800">Available Offline</span>
|
||||
</div>
|
||||
<p className="text-[12px] text-green-600">
|
||||
This video is downloaded and ready to watch without internet connection.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Wifi className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-[14px] font-medium text-blue-800">Online Only</span>
|
||||
</div>
|
||||
<p className="text-[12px] text-blue-600 mb-3">
|
||||
Download for offline viewing
|
||||
</p>
|
||||
<Button size="sm" onClick={handleDownload}>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
Download ({videoData.fileSize})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Video Details */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[16px] font-medium text-foreground mb-4">Video Information</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[14px] text-muted-foreground">Duration</span>
|
||||
<span className="text-[14px] font-medium">{videoData.duration}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[14px] text-muted-foreground">File Size</span>
|
||||
<span className="text-[14px] font-medium">{videoData.fileSize}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[14px] text-muted-foreground">Resolution</span>
|
||||
<span className="text-[14px] font-medium">{videoData.resolution}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[14px] text-muted-foreground">Subtitles</span>
|
||||
<span className="text-[14px] font-medium">
|
||||
{videoData.subtitlesAvailable ? 'Available' : 'Not available'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Watch Progress */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[16px] font-medium text-foreground mb-4">Your Progress</h4>
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[14px] text-muted-foreground">Completed</span>
|
||||
<span className="text-[14px] font-medium text-[var(--color-brand-primary)]">{videoData.progress}%</span>
|
||||
</div>
|
||||
<Progress value={videoData.progress} className="h-2" />
|
||||
</div>
|
||||
{videoData.lastWatched && (
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
Last watched: {formatLastWatched(videoData.lastWatched)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Related Actions */}
|
||||
<div>
|
||||
<h4 className="text-[16px] font-medium text-foreground mb-4">Actions</h4>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download Materials
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<Share className="h-4 w-4 mr-2" />
|
||||
Share Video
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<BookmarkPlus className="h-4 w-4 mr-2" />
|
||||
Add to Favorites
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
229
src/components/ProfileSwitchDropdown.tsx
Normal file
229
src/components/ProfileSwitchDropdown.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
BookOpen,
|
||||
Users,
|
||||
Building2,
|
||||
UserCircle2,
|
||||
Settings,
|
||||
LogOut,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger
|
||||
} from './ui/dropdown-menu';
|
||||
import { Button } from './ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
import { Separator } from './ui/separator';
|
||||
import { Badge } from './ui/badge';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
persona: 'corporate' | 'individual';
|
||||
orgName?: string;
|
||||
canSwitchMode: boolean;
|
||||
canSwitchAccount: boolean;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface ProfileSwitchDropdownProps {
|
||||
user: User;
|
||||
currentMode: 'corporate' | 'hr';
|
||||
currentAccount: 'corporate' | 'personal';
|
||||
onModeSwitch: (mode: 'corporate' | 'hr') => void;
|
||||
onAccountSwitch: (account: 'corporate' | 'personal') => void;
|
||||
onNavigate: (page: string) => void;
|
||||
onSignOut?: () => void;
|
||||
}
|
||||
|
||||
const recentAccounts = [
|
||||
{ id: 'design-org', label: 'Design Org', avatar: 'D', active: false },
|
||||
{ id: 'marketing-org', label: 'Marketing Org', avatar: 'M', active: false },
|
||||
{ id: 'product-org', label: 'Product Org', avatar: 'P', active: false }
|
||||
];
|
||||
|
||||
export function ProfileSwitchDropdown({
|
||||
user,
|
||||
currentMode,
|
||||
currentAccount,
|
||||
onModeSwitch,
|
||||
onAccountSwitch,
|
||||
onNavigate,
|
||||
onSignOut
|
||||
}: ProfileSwitchDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleSignOut = () => {
|
||||
setIsOpen(false);
|
||||
if (onSignOut) {
|
||||
onSignOut();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigateToSettings = () => {
|
||||
setIsOpen(false);
|
||||
onNavigate('settings');
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 px-2 py-1.5 h-auto hover:bg-gray-50 rounded-lg"
|
||||
>
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={user.avatar} alt={`${user.firstName} ${user.lastName}`} />
|
||||
<AvatarFallback className="text-xs bg-[var(--color-brand-primary)] text-white">
|
||||
{user.firstName[0]}{user.lastName[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{user.firstName}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-gray-500" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-80 p-0 bg-white border border-gray-200 shadow-lg rounded-lg"
|
||||
sideOffset={8}
|
||||
>
|
||||
{/* User Info Section */}
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={user.avatar} alt={`${user.firstName} ${user.lastName}`} />
|
||||
<AvatarFallback className="bg-[var(--color-brand-primary)] text-white">
|
||||
{user.firstName[0]}{user.lastName[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">
|
||||
{user.firstName} {user.lastName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Switch Mode Section */}
|
||||
{user.canSwitchMode && (
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<p className="text-sm font-medium text-gray-900 mb-3">Switch Mode</p>
|
||||
<div className="flex bg-gray-100 rounded-lg p-1 gap-1">
|
||||
<button
|
||||
onClick={() => onModeSwitch('corporate')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
currentMode === 'corporate'
|
||||
? 'bg-white text-[var(--color-brand-primary)] shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Corporate Learning
|
||||
{currentMode === 'corporate' && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onModeSwitch('hr')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
currentMode === 'hr'
|
||||
? 'bg-white text-[var(--color-brand-primary)] shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
HR Mode
|
||||
{currentMode === 'hr' && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Switch Accounts Section */}
|
||||
{user.canSwitchAccount && (
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<p className="text-sm font-medium text-gray-900 mb-3">Switch Accounts</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => onAccountSwitch('corporate')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-all ${
|
||||
currentAccount === 'corporate'
|
||||
? 'bg-blue-50 text-[var(--color-brand-primary)]'
|
||||
: 'hover:bg-gray-50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="flex-1 text-sm font-medium">Corporate</span>
|
||||
{currentAccount === 'corporate' && (
|
||||
<Check className="h-4 w-4 text-[var(--color-brand-primary)]" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAccountSwitch('personal')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-all ${
|
||||
currentAccount === 'personal'
|
||||
? 'bg-blue-50 text-[var(--color-brand-primary)]'
|
||||
: 'hover:bg-gray-50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<UserCircle2 className="h-4 w-4" />
|
||||
<span className="flex-1 text-sm font-medium">Personal</span>
|
||||
{currentAccount === 'personal' && (
|
||||
<Check className="h-4 w-4 text-[var(--color-brand-primary)]" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Accounts Section */}
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<p className="text-sm font-medium text-gray-900 mb-3">Recent Accounts</p>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{recentAccounts.map((account) => (
|
||||
<button
|
||||
key={account.id}
|
||||
className="flex-shrink-0 flex flex-col items-center gap-2 p-2 rounded-lg hover:bg-gray-50 transition-colors min-w-[60px]"
|
||||
>
|
||||
<div className="w-8 h-8 bg-[var(--color-brand-primary)] text-white rounded-full flex items-center justify-center text-sm font-medium">
|
||||
{account.avatar}
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 text-center leading-tight">
|
||||
{account.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Section */}
|
||||
<div className="p-2">
|
||||
<button
|
||||
onClick={handleNavigateToSettings}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors text-gray-700"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Profile & Settings</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors text-gray-700"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
425
src/components/ProgramDetailPage.tsx
Normal file
425
src/components/ProgramDetailPage.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Badge } from './ui/badge';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Check,
|
||||
Play,
|
||||
Calendar,
|
||||
Clock,
|
||||
BookOpen,
|
||||
Video,
|
||||
Target,
|
||||
User,
|
||||
Award,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
|
||||
// Mock program data
|
||||
interface ProgramData {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
duration: string;
|
||||
level: string;
|
||||
progress: number;
|
||||
totalCourses: number;
|
||||
completedCourses: number;
|
||||
category: string;
|
||||
instructor: {
|
||||
name: string;
|
||||
title: string;
|
||||
bio: string;
|
||||
avatar: string;
|
||||
};
|
||||
courses: ProgramCourse[];
|
||||
learningOutcomes: string[];
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
interface ProgramCourse {
|
||||
id: string;
|
||||
title: string;
|
||||
duration: string;
|
||||
isCompleted: boolean;
|
||||
isActive: boolean;
|
||||
progress: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const programDatabase: Record<string, ProgramData> = {
|
||||
'program-1': {
|
||||
id: 'program-1',
|
||||
title: 'Executive Leadership Development Program',
|
||||
subtitle: 'Comprehensive Leadership Transformation',
|
||||
description: 'An intensive 12-week program designed to transform mid-level managers into strategic leaders. This comprehensive program covers essential leadership competencies, strategic thinking, and organizational transformation skills.',
|
||||
duration: '12 weeks',
|
||||
level: 'Advanced',
|
||||
progress: 42,
|
||||
totalCourses: 6,
|
||||
completedCourses: 2,
|
||||
category: 'Leadership Development',
|
||||
instructor: {
|
||||
name: 'Dr. Sarah Williams',
|
||||
title: 'Executive Leadership Expert',
|
||||
bio: 'Dr. Williams has over 20 years of experience in executive coaching and leadership development, having worked with Fortune 100 companies.',
|
||||
avatar: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=100&h=100&fit=crop'
|
||||
},
|
||||
thumbnail: 'https://images.unsplash.com/photo-1552664730-d307ca884978?w=800&h=450&fit=crop',
|
||||
learningOutcomes: [
|
||||
'Develop strategic leadership vision and execution capabilities',
|
||||
'Master advanced communication and influence techniques',
|
||||
'Build high-performance teams and organizational culture',
|
||||
'Navigate complex business challenges and drive transformation'
|
||||
],
|
||||
courses: [
|
||||
{
|
||||
id: 'course-1',
|
||||
title: 'Strategic Leadership Foundations',
|
||||
duration: '2 weeks',
|
||||
isCompleted: true,
|
||||
isActive: false,
|
||||
progress: 100,
|
||||
description: 'Core principles of strategic leadership and vision development'
|
||||
},
|
||||
{
|
||||
id: 'course-2',
|
||||
title: 'Advanced Communication & Influence',
|
||||
duration: '2 weeks',
|
||||
isCompleted: true,
|
||||
isActive: false,
|
||||
progress: 100,
|
||||
description: 'Mastering executive communication and stakeholder influence'
|
||||
},
|
||||
{
|
||||
id: 'course-3',
|
||||
title: 'Building High-Performance Teams',
|
||||
duration: '2 weeks',
|
||||
isCompleted: false,
|
||||
isActive: true,
|
||||
progress: 67,
|
||||
description: 'Team dynamics, culture building, and performance optimization'
|
||||
},
|
||||
{
|
||||
id: 'course-4',
|
||||
title: 'Change Management & Innovation',
|
||||
duration: '2 weeks',
|
||||
isCompleted: false,
|
||||
isActive: false,
|
||||
progress: 0,
|
||||
description: 'Leading organizational change and fostering innovation'
|
||||
},
|
||||
{
|
||||
id: 'course-5',
|
||||
title: 'Financial Acumen for Leaders',
|
||||
duration: '2 weeks',
|
||||
isCompleted: false,
|
||||
isActive: false,
|
||||
progress: 0,
|
||||
description: 'Understanding financial metrics and business strategy'
|
||||
},
|
||||
{
|
||||
id: 'course-6',
|
||||
title: 'Executive Capstone Project',
|
||||
duration: '2 weeks',
|
||||
isCompleted: false,
|
||||
isActive: false,
|
||||
progress: 0,
|
||||
description: 'Applied leadership project with real business impact'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
interface ProgramDetailPageProps {
|
||||
programId: string;
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
export function ProgramDetailPage({ programId, onNavigateBack }: ProgramDetailPageProps) {
|
||||
const [programData] = useState<ProgramData>(programDatabase[programId] || programDatabase['program-1']);
|
||||
const [expandedCourse, setExpandedCourse] = useState<string | null>(null);
|
||||
|
||||
const toggleCourse = (courseId: string) => {
|
||||
setExpandedCourse(expandedCourse === courseId ? null : courseId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full bg-background">
|
||||
{/* Breadcrumb Navigation - Full Width */}
|
||||
<div className="bg-background border-b border-border py-3">
|
||||
<div className="px-6">
|
||||
<nav className="flex items-center gap-2 text-[14px] text-muted-foreground">
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
className="hover:text-[var(--color-brand-primary)] transition-colors"
|
||||
>
|
||||
Library
|
||||
</button>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span>{programData.category}</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-foreground font-medium">Programs</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-foreground font-medium">{programData.title}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Program Header - Full Width */}
|
||||
<div className="bg-background border-b border-border py-6">
|
||||
<div className="px-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Badge variant="secondary" className="bg-[var(--color-brand-primary)]/10 text-[var(--color-brand-primary)] border-[var(--color-brand-primary)]/20">
|
||||
Program
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[12px]">
|
||||
{programData.level}
|
||||
</Badge>
|
||||
</div>
|
||||
<h1 className="text-[28px] font-bold leading-[34px] tracking-[-1px] mb-2 text-gray-900">
|
||||
{programData.title}
|
||||
</h1>
|
||||
<p className="text-[16px] text-muted-foreground mb-3">
|
||||
{programData.subtitle} • {programData.duration}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-[14px] text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<span>{programData.completedCourses}/{programData.totalCourses} courses completed</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{programData.duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<p className="text-[14px] text-muted-foreground">Overall Progress</p>
|
||||
<p className="text-[24px] font-bold text-[var(--color-brand-primary)]">{programData.progress}%</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-full bg-[var(--color-brand-primary)] flex items-center justify-center relative">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full bg-[var(--color-brand-accent)]"
|
||||
style={{
|
||||
clipPath: `inset(0 ${100 - programData.progress}% 0 0)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Layout */}
|
||||
<div className="flex h-full">
|
||||
{/* Left Sidebar - Program Structure */}
|
||||
<aside className="w-[300px] flex-shrink-0 bg-white border-r border-border">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-[16px] font-semibold text-foreground mb-6">Program Structure</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{programData.courses.map((course, index) => (
|
||||
<div key={course.id} className="mb-4">
|
||||
<div
|
||||
className="flex items-center justify-between mb-3 cursor-pointer hover:bg-gray-50 p-3 rounded-lg border border-border"
|
||||
onClick={() => toggleCourse(course.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
course.isCompleted
|
||||
? 'bg-[#21A36A]'
|
||||
: course.isActive
|
||||
? 'bg-[var(--color-brand-primary)]'
|
||||
: 'bg-gray-200'
|
||||
}`}>
|
||||
{course.isCompleted ? (
|
||||
<Check className="h-4 w-4 text-white" />
|
||||
) : (
|
||||
<span className="text-[12px] font-medium text-white">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-[14px] font-medium text-foreground">
|
||||
{course.title}
|
||||
</h4>
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
{course.duration}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{expandedCourse === course.id ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expandedCourse === course.id && (
|
||||
<div className="ml-11 mb-3">
|
||||
<p className="text-[13px] text-muted-foreground mb-3">
|
||||
{course.description}
|
||||
</p>
|
||||
{!course.isCompleted && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[12px] text-muted-foreground">Progress</span>
|
||||
<span className="text-[12px] text-[var(--color-brand-primary)]">{course.progress}%</span>
|
||||
</div>
|
||||
<Progress value={course.progress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
variant={course.isCompleted ? "outline" : "default"}
|
||||
>
|
||||
{course.isCompleted ? "Review" : course.isActive ? "Continue" : "Start Course"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 min-w-0">
|
||||
<div className="p-6">
|
||||
{/* Program Overview */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-[20px] font-semibold text-foreground mb-4">Program Overview</h2>
|
||||
<img
|
||||
src={programData.thumbnail}
|
||||
alt={programData.title}
|
||||
className="w-full h-64 object-cover rounded-lg mb-4"
|
||||
/>
|
||||
<p className="text-[16px] text-muted-foreground leading-relaxed">
|
||||
{programData.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Learning Outcomes */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-[20px] font-semibold text-foreground mb-4">What you'll achieve</h3>
|
||||
<div className="space-y-3">
|
||||
{programData.learningOutcomes.map((outcome, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-[#21A36A] flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Check className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
<p className="text-[16px] text-foreground">
|
||||
{outcome}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Program Leader */}
|
||||
<div>
|
||||
<h3 className="text-[20px] font-semibold text-foreground mb-4">Program Leader</h3>
|
||||
<div className="flex items-start gap-4 p-4 bg-muted/50 rounded-lg">
|
||||
<img
|
||||
src={programData.instructor.avatar}
|
||||
alt={programData.instructor.name}
|
||||
className="w-16 h-16 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-[18px] font-semibold text-foreground">{programData.instructor.name}</h4>
|
||||
<p className="text-[16px] text-[var(--color-brand-primary)] mb-2">{programData.instructor.title}</p>
|
||||
<p className="text-[16px] text-muted-foreground">{programData.instructor.bio}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Right Sidebar - Program Plan */}
|
||||
<aside className="w-[300px] flex-shrink-0 bg-white border-l border-border">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-[18px] font-semibold text-foreground mb-4">Program plan</h3>
|
||||
|
||||
<p className="text-[14px] text-muted-foreground mb-6">
|
||||
I'm committed to completing this program in 12 weeks.
|
||||
</p>
|
||||
|
||||
{/* Current Progress */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-[16px] font-medium text-foreground">Current Progress</h4>
|
||||
<span className="text-[14px] font-medium text-muted-foreground">Week 5</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-[14px] text-muted-foreground mb-2">Your next milestone</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-[var(--color-brand-primary)] flex items-center justify-center">
|
||||
<div className="w-2 h-2 bg-[var(--color-brand-primary)] rounded-full" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[14px] font-medium text-foreground">Team Building Course</p>
|
||||
<p className="text-[12px] text-muted-foreground">Due in 5 days</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Program Timeline */}
|
||||
<div>
|
||||
<h4 className="text-[16px] font-medium text-foreground mb-4">Program timeline</h4>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Start date */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-[#21A36A] flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Check className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[14px] font-medium text-foreground">Start date: January 15, 2024</p>
|
||||
<p className="text-[12px] text-muted-foreground">Program commenced</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current course */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-[var(--color-brand-primary)] flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-[12px] font-medium text-white">3</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[14px] font-medium text-foreground">Building High-Performance Teams</p>
|
||||
<p className="text-[12px] text-muted-foreground">In progress - Due March 15</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* End date */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-5 h-5 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Award className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[14px] font-medium text-foreground">Expected completion: April 15, 2024</p>
|
||||
<p className="text-[12px] text-muted-foreground">Program certificate</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
437
src/components/ReportsAndCertificates.tsx
Normal file
437
src/components/ReportsAndCertificates.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Download,
|
||||
Share2,
|
||||
Eye,
|
||||
FileText,
|
||||
Award,
|
||||
Calendar,
|
||||
ExternalLink,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Separator } from './ui/separator';
|
||||
import { useAppContext } from './AppShell';
|
||||
|
||||
// Mock data for reports and certificates
|
||||
const mockReportsData = {
|
||||
certificates: [
|
||||
{
|
||||
id: 'cert-1',
|
||||
title: 'Strategic Leadership Development Certificate',
|
||||
courseTitle: 'Strategic Leadership Development',
|
||||
issuedOn: '2024-08-15T00:00:00Z',
|
||||
status: 'ready',
|
||||
downloadUrl: '#',
|
||||
shareUrl: '#',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1586953208448-b95a79798f07?w=400',
|
||||
credentialId: 'KLC-SLD-2024-001'
|
||||
},
|
||||
{
|
||||
id: 'cert-2',
|
||||
title: 'Team Building Essentials Certificate',
|
||||
courseTitle: 'Team Building Essentials',
|
||||
issuedOn: '2024-07-20T00:00:00Z',
|
||||
status: 'ready',
|
||||
downloadUrl: '#',
|
||||
shareUrl: '#',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1586953208448-b95a79798f07?w=400',
|
||||
credentialId: 'KLC-TBE-2024-002'
|
||||
},
|
||||
{
|
||||
id: 'cert-3',
|
||||
title: 'Financial Management for Leaders Certificate',
|
||||
courseTitle: 'Financial Management for Leaders',
|
||||
issuedOn: null,
|
||||
status: 'in-progress',
|
||||
downloadUrl: null,
|
||||
shareUrl: null,
|
||||
thumbnail: 'https://images.unsplash.com/photo-1586953208448-b95a79798f07?w=400',
|
||||
credentialId: null,
|
||||
expectedDate: '2024-10-15T00:00:00Z'
|
||||
}
|
||||
],
|
||||
leadershipReports: [
|
||||
{
|
||||
id: 'report-1',
|
||||
title: 'Leadership Assessment Report - Q3 2024',
|
||||
type: 'assessment',
|
||||
courseId: 'course-1',
|
||||
courseTitle: 'Strategic Leadership Development',
|
||||
createdAt: '2024-08-20T00:00:00Z',
|
||||
status: 'ready',
|
||||
pdfUrl: '#',
|
||||
summary: 'Comprehensive analysis of leadership competencies including strategic thinking, decision making, and team management capabilities.',
|
||||
keyInsights: [
|
||||
'Strong strategic thinking capabilities',
|
||||
'Opportunity for improvement in delegation',
|
||||
'Excellent communication skills'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'report-2',
|
||||
title: '360-Degree Feedback Report',
|
||||
type: '360-feedback',
|
||||
courseId: 'course-1',
|
||||
courseTitle: 'Strategic Leadership Development',
|
||||
createdAt: '2024-08-25T00:00:00Z',
|
||||
status: 'ready',
|
||||
pdfUrl: '#',
|
||||
summary: 'Multi-perspective evaluation including self-assessment, peer feedback, and supervisor input on leadership effectiveness.',
|
||||
keyInsights: [
|
||||
'High emotional intelligence scores',
|
||||
'Consistent feedback on visionary leadership',
|
||||
'Development area: conflict resolution'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'report-3',
|
||||
title: 'Leadership Style Analysis',
|
||||
type: 'profiler',
|
||||
courseId: null,
|
||||
courseTitle: null,
|
||||
createdAt: '2024-09-01T00:00:00Z',
|
||||
status: 'in-progress',
|
||||
pdfUrl: null,
|
||||
summary: 'Analysis of predominant leadership style and recommendations for situational leadership adaptation.',
|
||||
expectedDate: '2024-09-10T00:00:00Z'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export function ReportsAndCertificates() {
|
||||
const { user } = useAppContext();
|
||||
const [activeTab, setActiveTab] = useState('certificates');
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'Asia/Kolkata'
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'ready': return CheckCircle;
|
||||
case 'in-progress': return Clock;
|
||||
case 'eligible': return AlertCircle;
|
||||
default: return Clock;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'ready': return 'bg-green-100 text-green-800';
|
||||
case 'in-progress': return 'bg-blue-100 text-blue-800';
|
||||
case 'eligible': return 'bg-yellow-100 text-yellow-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const CertificateCard = ({ certificate }: { certificate: any }) => {
|
||||
const StatusIcon = getStatusIcon(certificate.status);
|
||||
|
||||
return (
|
||||
<Card className="group hover:shadow-lg transition-all duration-300 border border-gray-200 bg-white">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 space-y-4">
|
||||
{/* Header with status */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-[#04045B] to-[#030344] flex items-center justify-center flex-shrink-0">
|
||||
<Award className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 leading-tight">{certificate.title}</h3>
|
||||
<p className="text-sm text-gray-600">{certificate.courseTitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className={`h-4 w-4 ${
|
||||
certificate.status === 'ready' ? 'text-green-600' :
|
||||
certificate.status === 'in-progress' ? 'text-blue-600' : 'text-yellow-600'
|
||||
}`} />
|
||||
<Badge className={`capitalize font-medium ${getStatusColor(certificate.status)} border-0`}>
|
||||
{certificate.status.replace('-', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Information */}
|
||||
<div className="space-y-2">
|
||||
{certificate.issuedOn && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Calendar className="h-4 w-4 text-gray-400" />
|
||||
<span>Issued on {formatDate(certificate.issuedOn)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{certificate.expectedDate && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Clock className="h-4 w-4 text-gray-400" />
|
||||
<span>Expected by {formatDate(certificate.expectedDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Credential ID */}
|
||||
{certificate.credentialId && (
|
||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">Credential ID</span>
|
||||
<span className="text-sm font-mono text-gray-900">{certificate.credentialId}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{certificate.status === 'ready' && (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" className="gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
View
|
||||
</Button>
|
||||
<Button size="sm" className="gap-2 bg-[#04045B] hover:bg-[#030344]">
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="gap-2">
|
||||
<Share2 className="h-4 w-4" />
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const ReportCard = ({ report }: { report: any }) => {
|
||||
const StatusIcon = getStatusIcon(report.status);
|
||||
|
||||
return (
|
||||
<Card className="group hover:shadow-lg transition-all duration-300 border border-gray-200 bg-white">
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-[#04045B] to-[#030344] flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 leading-tight">{report.title}</h3>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className={`h-4 w-4 ${
|
||||
report.status === 'ready' ? 'text-green-600' :
|
||||
report.status === 'in-progress' ? 'text-blue-600' : 'text-yellow-600'
|
||||
}`} />
|
||||
<Badge className={`capitalize font-medium ${getStatusColor(report.status)} border-0`}>
|
||||
{report.status.replace('-', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" className="capitalize font-medium bg-[#F8C301]/10 text-[#04045B] border-[#F8C301]/30">
|
||||
{report.type.replace('-', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
{report.status === 'ready' && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" className="gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
View
|
||||
</Button>
|
||||
<Button size="sm" className="gap-2 bg-[#04045B] hover:bg-[#030344]">
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{report.courseId && (
|
||||
<Button size="sm" variant="outline" className="gap-2">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Course
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="space-y-3">
|
||||
{report.courseTitle && (
|
||||
<div className="bg-blue-50 rounded-lg p-3 border border-blue-100">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-600">Related to:</span>
|
||||
<span className="font-medium text-[#04045B]">{report.courseTitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Calendar className="h-4 w-4 text-gray-400" />
|
||||
<span>
|
||||
{report.status === 'ready' ? 'Generated' : 'Expected'} on {formatDate(report.status === 'ready' ? report.createdAt : report.expectedDate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100">
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{report.summary}</p>
|
||||
</div>
|
||||
|
||||
{/* Key Insights */}
|
||||
{report.keyInsights && report.keyInsights.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-[#F8C301] rounded-full"></div>
|
||||
Key Insights
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{report.keyInsights.map((insight: string, index: number) => (
|
||||
<div key={index} className="flex items-start gap-3 text-sm text-gray-700">
|
||||
<div className="w-1.5 h-1.5 bg-[#04045B] rounded-full mt-2 flex-shrink-0" />
|
||||
<span className="leading-relaxed">{insight}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8 bg-white min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold text-gray-900">Reports & Certificates</h1>
|
||||
<p className="text-gray-600">
|
||||
Access your leadership reports and download certificates to showcase your achievements
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-8">
|
||||
<TabsList className="grid w-full max-w-lg grid-cols-2 bg-gray-50/80 border border-gray-200 p-1 rounded-xl shadow-sm h-auto">
|
||||
<TabsTrigger
|
||||
value="certificates"
|
||||
className="flex items-center gap-3 px-6 py-3 rounded-lg font-medium transition-all data-[state=active]:bg-white data-[state=active]:text-[#04045B] data-[state=active]:shadow-md data-[state=active]:border data-[state=active]:border-[#04045B]/10"
|
||||
>
|
||||
<Award className="h-4 w-4" />
|
||||
Certificates
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="reports"
|
||||
className="flex items-center gap-3 px-6 py-3 rounded-lg font-medium transition-all data-[state=active]:bg-white data-[state=active]:text-[#04045B] data-[state=active]:shadow-md data-[state=active]:border data-[state=active]:border-[#04045B]/10"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
Reports
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Certificates Tab */}
|
||||
<TabsContent value="certificates" className="space-y-6">
|
||||
<div className="flex items-center justify-between bg-gradient-to-r from-[#04045B]/5 to-[#F8C301]/5 p-6 rounded-xl border border-[#04045B]/10">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Your Certificates</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
{mockReportsData.certificates.filter(c => c.status === 'ready').length} ready
|
||||
</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||
{mockReportsData.certificates.filter(c => c.status === 'in-progress').length} in progress
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{mockReportsData.certificates.map((certificate) => (
|
||||
<CertificateCard key={certificate.id} certificate={certificate} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mockReportsData.certificates.length === 0 && (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-6 bg-gradient-to-br from-[#04045B]/10 to-[#F8C301]/10 rounded-full flex items-center justify-center">
|
||||
<Award className="h-8 w-8 text-[#04045B]" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">No certificates yet</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Complete courses to earn certificates
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Reports Tab */}
|
||||
<TabsContent value="reports" className="space-y-6">
|
||||
<div className="flex items-center justify-between bg-gradient-to-r from-[#04045B]/5 to-blue-50 p-6 rounded-xl border border-[#04045B]/10">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Leadership Reports</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
{mockReportsData.leadershipReports.filter(r => r.status === 'ready').length} ready
|
||||
</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||
{mockReportsData.leadershipReports.filter(r => r.status === 'in-progress').length} in progress
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{mockReportsData.leadershipReports.map((report) => (
|
||||
<ReportCard key={report.id} report={report} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mockReportsData.leadershipReports.length === 0 && (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-6 bg-gradient-to-br from-[#04045B]/10 to-blue-50 rounded-full flex items-center justify-center">
|
||||
<FileText className="h-8 w-8 text-[#04045B]" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">No reports yet</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Complete assessments and profilers to generate reports
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
576
src/components/Settings.tsx
Normal file
576
src/components/Settings.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Switch } from './ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Separator } from './ui/separator';
|
||||
import {
|
||||
User,
|
||||
Shield,
|
||||
Settings as SettingsIcon,
|
||||
Building2,
|
||||
Bell,
|
||||
Globe,
|
||||
Eye,
|
||||
Smartphone,
|
||||
Mail,
|
||||
Lock
|
||||
} from 'lucide-react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
email: string;
|
||||
persona: 'corporate' | 'individual';
|
||||
orgName?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function Settings({ user }: SettingsProps) {
|
||||
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||
const [pushNotifications, setPushNotifications] = useState(true);
|
||||
const [weeklyDigest, setWeeklyDigest] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8 bg-white min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold text-gray-900">Settings</h1>
|
||||
<p className="text-gray-600">Manage your account settings and preferences</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="profile" className="space-y-8">
|
||||
<TabsList className={`grid w-full ${user.persona === 'corporate' ? 'grid-cols-4' : 'grid-cols-3'} bg-gray-50/80 border border-gray-200 p-1 rounded-xl shadow-sm h-auto`}>
|
||||
<TabsTrigger
|
||||
value="profile"
|
||||
className="flex items-center gap-3 px-6 py-3 rounded-lg font-medium transition-all data-[state=active]:bg-white data-[state=active]:text-[#04045B] data-[state=active]:shadow-md data-[state=active]:border data-[state=active]:border-[#04045B]/10"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
className="flex items-center gap-3 px-6 py-3 rounded-lg font-medium transition-all data-[state=active]:bg-white data-[state=active]:text-[#04045B] data-[state=active]:shadow-md data-[state=active]:border data-[state=active]:border-[#04045B]/10"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
Security
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="preferences"
|
||||
className="flex items-center gap-3 px-6 py-3 rounded-lg font-medium transition-all data-[state=active]:bg-white data-[state=active]:text-[#04045B] data-[state=active]:shadow-md data-[state=active]:border data-[state=active]:border-[#04045B]/10"
|
||||
>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
Preferences
|
||||
</TabsTrigger>
|
||||
{user.persona === 'corporate' && (
|
||||
<TabsTrigger
|
||||
value="organization"
|
||||
className="flex items-center gap-3 px-6 py-3 rounded-lg font-medium transition-all data-[state=active]:bg-white data-[state=active]:text-[#04045B] data-[state=active]:shadow-md data-[state=active]:border data-[state=active]:border-[#04045B]/10"
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
Organization
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{/* Profile Tab */}
|
||||
<TabsContent value="profile">
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-gray-200 bg-white">
|
||||
<CardHeader className="pb-6">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#04045B] to-[#030344] flex items-center justify-center">
|
||||
<User className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
Profile Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<Avatar className="h-20 w-20 border-4 border-gray-100">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback className="text-lg bg-gradient-to-br from-[#04045B] to-[#030344] text-white">
|
||||
{user.firstName.charAt(0)}{user.lastName?.charAt(0) || ''}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="space-y-3">
|
||||
<Button variant="outline" className="gap-2">
|
||||
Change Photo
|
||||
</Button>
|
||||
<p className="text-sm text-gray-500">
|
||||
JPG, PNG or GIF. Max file size 2MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName" className="text-sm font-medium text-gray-700">First Name</Label>
|
||||
<Input id="firstName" defaultValue={user.firstName} className="bg-white border-gray-200" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName" className="text-sm font-medium text-gray-700">Last Name</Label>
|
||||
<Input id="lastName" defaultValue={user.lastName || ''} className="bg-white border-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-medium text-gray-700">Email</Label>
|
||||
<Input id="email" type="email" defaultValue={user.email} className="bg-white border-gray-200" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio" className="text-sm font-medium text-gray-700">Bio</Label>
|
||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
||||
<textarea
|
||||
id="bio"
|
||||
className="w-full min-h-[100px] bg-white border border-gray-200 rounded-md px-3 py-2 text-sm resize-none"
|
||||
placeholder="Tell us about yourself..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="bg-[#04045B] text-white border-0">
|
||||
{user.persona === 'corporate' ? 'Corporate Learner' : 'Individual Learner'}
|
||||
</Badge>
|
||||
{user.orgName && (
|
||||
<Badge variant="outline" className="bg-white text-[#04045B] border-[#04045B]/20">
|
||||
{user.orgName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button className="gap-2 bg-[#04045B] hover:bg-[#030344] text-white px-6">
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Security Tab */}
|
||||
<TabsContent value="security">
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-gray-200 bg-white">
|
||||
<CardHeader className="pb-6">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#04045B] to-[#030344] flex items-center justify-center">
|
||||
<Lock className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
Password
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword" className="text-sm font-medium text-gray-700">Current Password</Label>
|
||||
<Input id="currentPassword" type="password" className="bg-white border-gray-200" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword" className="text-sm font-medium text-gray-700">New Password</Label>
|
||||
<Input id="newPassword" type="password" className="bg-white border-gray-200" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword" className="text-sm font-medium text-gray-700">Confirm New Password</Label>
|
||||
<Input id="confirmPassword" type="password" className="bg-white border-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" className="gap-2">
|
||||
Update Password
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 bg-white">
|
||||
<CardHeader className="pb-6">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#04045B] to-[#030344] flex items-center justify-center">
|
||||
<Shield className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
Two-Factor Authentication
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-900">Enable 2FA</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Add an extra layer of security to your account
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={twoFactorEnabled}
|
||||
onCheckedChange={setTwoFactorEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{twoFactorEnabled && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center flex-shrink-0">
|
||||
<Smartphone className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">Authenticator App</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Use an app like Google Authenticator or Authy
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
Setup
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 bg-white">
|
||||
<CardHeader className="pb-6">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#04045B] to-[#030344] flex items-center justify-center">
|
||||
<Globe className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
Active Sessions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center">
|
||||
<Globe className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Current session</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Chrome on macOS • Mumbai, India
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="bg-green-100 text-green-800 border-0">
|
||||
Active
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="destructive" size="sm" className="gap-2">
|
||||
Sign out all other sessions
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Preferences Tab */}
|
||||
<TabsContent value="preferences">
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-gray-200 bg-white">
|
||||
<CardHeader className="pb-6">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#04045B] to-[#030344] flex items-center justify-center">
|
||||
<Bell className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
Notifications
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 text-[#04045B]" />
|
||||
<p className="text-sm font-medium text-gray-900">Email Notifications</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Receive notifications about course updates and deadlines
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={emailNotifications}
|
||||
onCheckedChange={setEmailNotifications}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4 text-[#04045B]" />
|
||||
<p className="text-sm font-medium text-gray-900">Push Notifications</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Get notified about important updates and reminders
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={pushNotifications}
|
||||
onCheckedChange={setPushNotifications}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-900">Weekly Learning Digest</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Summary of your learning progress and recommendations
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={weeklyDigest}
|
||||
onCheckedChange={setWeeklyDigest}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 bg-white">
|
||||
<CardHeader className="pb-6">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#04045B] to-[#030344] flex items-center justify-center">
|
||||
<SettingsIcon className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
Learning Preferences
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">Preferred Learning Time</Label>
|
||||
<Select defaultValue="morning">
|
||||
<SelectTrigger className="bg-white border-gray-200">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="morning">Morning (6 AM - 12 PM)</SelectItem>
|
||||
<SelectItem value="afternoon">Afternoon (12 PM - 6 PM)</SelectItem>
|
||||
<SelectItem value="evening">Evening (6 PM - 10 PM)</SelectItem>
|
||||
<SelectItem value="late">Late Night (10 PM - 12 AM)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">Language</Label>
|
||||
<Select defaultValue="en">
|
||||
<SelectTrigger className="bg-white border-gray-200">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="hi">Hindi</SelectItem>
|
||||
<SelectItem value="es">Spanish</SelectItem>
|
||||
<SelectItem value="fr">French</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">Time Zone</Label>
|
||||
<Select defaultValue="ist">
|
||||
<SelectTrigger className="bg-white border-gray-200">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ist">India Standard Time (GMT+5:30)</SelectItem>
|
||||
<SelectItem value="utc">UTC (GMT+0)</SelectItem>
|
||||
<SelectItem value="pst">Pacific Time (GMT-8)</SelectItem>
|
||||
<SelectItem value="est">Eastern Time (GMT-5)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 bg-white">
|
||||
<CardHeader className="pb-6">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#04045B] to-[#030344] flex items-center justify-center">
|
||||
<Eye className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
Accessibility
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-[#04045B]" />
|
||||
<p className="text-sm font-medium text-gray-900">High Contrast Mode</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Improve visibility with higher contrast colors
|
||||
</p>
|
||||
</div>
|
||||
<Switch />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">Font Size</Label>
|
||||
<Select defaultValue="medium">
|
||||
<SelectTrigger className="bg-white border-gray-200">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="small">Small</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="large">Large</SelectItem>
|
||||
<SelectItem value="extra-large">Extra Large</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Organization Tab (Corporate only) */}
|
||||
{user.persona === 'corporate' && (
|
||||
<TabsContent value="organization">
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-gray-200 bg-white">
|
||||
<CardHeader className="pb-6">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#04045B] to-[#030344] flex items-center justify-center">
|
||||
<Building2 className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
Organization Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-100 mb-6">
|
||||
<p className="text-sm text-gray-600">
|
||||
Contact your HR administrator to update organization details
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">Organization Name</Label>
|
||||
<Input defaultValue={user.orgName} disabled className="bg-gray-50 border-gray-200" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">Employee ID</Label>
|
||||
<Input defaultValue="EMP001234" disabled className="bg-gray-50 border-gray-200" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">Department</Label>
|
||||
<Input defaultValue="Engineering" disabled className="bg-gray-50 border-gray-200" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">Manager</Label>
|
||||
<Input defaultValue="Sarah Johnson" disabled className="bg-gray-50 border-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 bg-white">
|
||||
<CardHeader className="pb-6">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#04045B] to-[#030344] flex items-center justify-center">
|
||||
<Shield className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
Learning Permissions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Access to All Programmes</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Can access all corporate learning programmes
|
||||
</p>
|
||||
</div>
|
||||
<Badge className="bg-green-100 text-green-800 border-0">
|
||||
Enabled
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Manager Reporting</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Learning progress is shared with manager
|
||||
</p>
|
||||
</div>
|
||||
<Badge className="bg-green-100 text-green-800 border-0">
|
||||
Enabled
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Certification Authority</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Can issue official company certificates
|
||||
</p>
|
||||
</div>
|
||||
<Badge className="bg-yellow-100 text-yellow-800 border-0">
|
||||
Limited
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-100">
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
For changes to permissions, please contact your HR administrator or submit a request through the HR portal.
|
||||
</p>
|
||||
<Button variant="outline" className="gap-2">
|
||||
Contact HR
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
459
src/components/WebinarDetailPage.tsx
Normal file
459
src/components/WebinarDetailPage.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import {
|
||||
ChevronRight,
|
||||
Play,
|
||||
Pause,
|
||||
Volume2,
|
||||
Maximize,
|
||||
Calendar,
|
||||
Clock,
|
||||
Users,
|
||||
Video,
|
||||
Download,
|
||||
Share,
|
||||
BookmarkPlus,
|
||||
MessageSquare,
|
||||
ThumbsUp,
|
||||
Eye
|
||||
} from 'lucide-react';
|
||||
|
||||
// Mock webinar data
|
||||
interface WebinarData {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
duration: string;
|
||||
scheduledDate: string;
|
||||
status: 'upcoming' | 'live' | 'completed';
|
||||
category: string;
|
||||
attendees: number;
|
||||
maxAttendees: number;
|
||||
speaker: {
|
||||
name: string;
|
||||
title: string;
|
||||
bio: string;
|
||||
avatar: string;
|
||||
company: string;
|
||||
};
|
||||
topics: string[];
|
||||
thumbnail: string;
|
||||
videoUrl?: string;
|
||||
registrationRequired: boolean;
|
||||
isRegistered: boolean;
|
||||
}
|
||||
|
||||
const webinarDatabase: Record<string, WebinarData> = {
|
||||
'webinar-1': {
|
||||
id: 'webinar-1',
|
||||
title: 'Future of Leadership in Remote Work',
|
||||
subtitle: 'Strategies for Leading Distributed Teams',
|
||||
description: 'Join us for an interactive session exploring the evolving landscape of leadership in the era of remote and hybrid work. Learn practical strategies for building trust, maintaining culture, and driving performance across distributed teams.',
|
||||
duration: '60 minutes',
|
||||
scheduledDate: '2024-03-20T14:00:00Z',
|
||||
status: 'upcoming',
|
||||
category: 'Leadership Development',
|
||||
attendees: 847,
|
||||
maxAttendees: 1000,
|
||||
speaker: {
|
||||
name: 'Alexandra Chen',
|
||||
title: 'Chief People Officer',
|
||||
bio: 'Alexandra is the Chief People Officer at GlobalTech and a recognized expert in remote work leadership. She has led distributed teams of 500+ people across 25 countries.',
|
||||
avatar: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?w=100&h=100&fit=crop',
|
||||
company: 'GlobalTech Solutions'
|
||||
},
|
||||
topics: [
|
||||
'Building trust in remote teams',
|
||||
'Virtual communication best practices',
|
||||
'Performance management strategies',
|
||||
'Creating inclusive remote culture',
|
||||
'Technology tools for team collaboration'
|
||||
],
|
||||
thumbnail: 'https://images.unsplash.com/photo-1600880292203-757bb62b4baf?w=800&h=450&fit=crop',
|
||||
registrationRequired: true,
|
||||
isRegistered: true
|
||||
},
|
||||
'webinar-2': {
|
||||
id: 'webinar-2',
|
||||
title: 'AI in Learning & Development',
|
||||
subtitle: 'Transforming Corporate Training',
|
||||
description: 'Discover how artificial intelligence is revolutionizing learning and development. From personalized learning paths to intelligent content curation, explore the tools and strategies shaping the future of corporate training.',
|
||||
duration: '45 minutes',
|
||||
scheduledDate: '2024-02-15T10:00:00Z',
|
||||
status: 'completed',
|
||||
category: 'Technology & Innovation',
|
||||
attendees: 1200,
|
||||
maxAttendees: 1200,
|
||||
speaker: {
|
||||
name: 'Dr. Michael Rodriguez',
|
||||
title: 'AI Research Director',
|
||||
bio: 'Dr. Rodriguez leads AI research initiatives in educational technology and has published over 50 papers on machine learning applications in corporate learning.',
|
||||
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop',
|
||||
company: 'EduTech Innovations'
|
||||
},
|
||||
topics: [
|
||||
'AI-powered personalization',
|
||||
'Intelligent content recommendations',
|
||||
'Automated skills gap analysis',
|
||||
'Predictive learning analytics',
|
||||
'Ethical considerations in AI learning'
|
||||
],
|
||||
thumbnail: 'https://images.unsplash.com/photo-1485827404703-89b55fcc595e?w=800&h=450&fit=crop',
|
||||
videoUrl: 'https://example.com/webinar-recording',
|
||||
registrationRequired: false,
|
||||
isRegistered: false
|
||||
}
|
||||
};
|
||||
|
||||
interface WebinarDetailPageProps {
|
||||
webinarId: string;
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
export function WebinarDetailPage({ webinarId, onNavigateBack }: WebinarDetailPageProps) {
|
||||
const [webinarData] = useState<WebinarData>(webinarDatabase[webinarId] || webinarDatabase['webinar-1']);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = () => {
|
||||
switch (webinarData.status) {
|
||||
case 'upcoming':
|
||||
return <Badge className="bg-blue-100 text-blue-800 border-blue-200">Upcoming</Badge>;
|
||||
case 'live':
|
||||
return <Badge className="bg-red-100 text-red-800 border-red-200">Live Now</Badge>;
|
||||
case 'completed':
|
||||
return <Badge className="bg-green-100 text-green-800 border-green-200">Completed</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getActionButton = () => {
|
||||
if (webinarData.status === 'live') {
|
||||
return (
|
||||
<Button size="lg" className="bg-red-600 hover:bg-red-700">
|
||||
<Video className="h-4 w-4 mr-2" />
|
||||
Join Live Webinar
|
||||
</Button>
|
||||
);
|
||||
} else if (webinarData.status === 'upcoming') {
|
||||
return webinarData.isRegistered ? (
|
||||
<Button size="lg" disabled>
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Registered
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="lg">
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Register Now
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Button size="lg">
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Watch Recording
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full bg-background">
|
||||
{/* Breadcrumb Navigation - Full Width */}
|
||||
<div className="bg-background border-b border-border py-3">
|
||||
<div className="px-6">
|
||||
<nav className="flex items-center gap-2 text-[14px] text-muted-foreground">
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
className="hover:text-[var(--color-brand-primary)] transition-colors"
|
||||
>
|
||||
Library
|
||||
</button>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span>{webinarData.category}</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-foreground font-medium">Webinars</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-foreground font-medium">{webinarData.title}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Webinar Header - Full Width */}
|
||||
<div className="bg-background border-b border-border py-6">
|
||||
<div className="px-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Badge variant="secondary" className="bg-[var(--color-brand-primary)]/10 text-[var(--color-brand-primary)] border-[var(--color-brand-primary)]/20">
|
||||
Webinar
|
||||
</Badge>
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
<h1 className="text-[28px] font-bold leading-[34px] tracking-[-1px] mb-2 text-gray-900">
|
||||
{webinarData.title}
|
||||
</h1>
|
||||
<p className="text-[16px] text-muted-foreground mb-3">
|
||||
{webinarData.subtitle}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-[14px] text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{formatDate(webinarData.scheduledDate)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{webinarData.duration}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>{webinarData.attendees.toLocaleString()} attendees</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{getActionButton()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Layout */}
|
||||
<div className="flex h-full">
|
||||
{/* Left Sidebar - Webinar Topics */}
|
||||
<aside className="w-[300px] flex-shrink-0 bg-white border-r border-border">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-[16px] font-semibold text-foreground mb-6">What we'll cover</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{webinarData.topics.map((topic, index) => (
|
||||
<div key={index} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="w-6 h-6 rounded-full bg-[var(--color-brand-primary)] flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-[12px] font-medium text-white">{index + 1}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[14px] font-medium text-foreground">{topic}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-8 pt-6 border-t border-border">
|
||||
<h4 className="text-[14px] font-medium text-foreground mb-4">Quick Actions</h4>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<BookmarkPlus className="h-4 w-4 mr-2" />
|
||||
Save for Later
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<Share className="h-4 w-4 mr-2" />
|
||||
Share Webinar
|
||||
</Button>
|
||||
|
||||
{webinarData.status === 'completed' && (
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download Materials
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 min-w-0">
|
||||
<div className="p-6">
|
||||
{/* Video Player or Placeholder */}
|
||||
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden mb-6">
|
||||
<img
|
||||
src={webinarData.thumbnail}
|
||||
alt={webinarData.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
{webinarData.status === 'completed' && webinarData.videoUrl && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => setIsPlaying(!isPlaying)}
|
||||
className="w-16 h-16 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center hover:bg-white/30 transition-colors"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-8 w-8 text-white" />
|
||||
) : (
|
||||
<Play className="h-8 w-8 text-white ml-1" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webinarData.status === 'upcoming' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div className="text-center text-white">
|
||||
<Calendar className="h-12 w-12 mx-auto mb-3" />
|
||||
<p className="text-[18px] font-medium">Webinar starts soon</p>
|
||||
<p className="text-[14px] opacity-80">{formatDate(webinarData.scheduledDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webinarData.status === 'live' && (
|
||||
<div className="absolute top-4 left-4">
|
||||
<Badge className="bg-red-600 text-white">
|
||||
<div className="w-2 h-2 bg-white rounded-full animate-pulse mr-2" />
|
||||
LIVE
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webinarData.status === 'completed' && (
|
||||
<div className="absolute bottom-4 left-4 right-4 flex items-center justify-between text-white text-[14px]">
|
||||
<span>Recorded session available</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Volume2 className="h-4 w-4" />
|
||||
<Maximize className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Webinar Description */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-[20px] font-semibold text-foreground mb-4">About this webinar</h2>
|
||||
<p className="text-[16px] text-muted-foreground leading-relaxed">
|
||||
{webinarData.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Speaker Information */}
|
||||
<div>
|
||||
<h3 className="text-[20px] font-semibold text-foreground mb-4">Featured Speaker</h3>
|
||||
<div className="flex items-start gap-4 p-4 bg-muted/50 rounded-lg">
|
||||
<img
|
||||
src={webinarData.speaker.avatar}
|
||||
alt={webinarData.speaker.name}
|
||||
className="w-16 h-16 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-[18px] font-semibold text-foreground">{webinarData.speaker.name}</h4>
|
||||
<p className="text-[16px] text-[var(--color-brand-primary)] mb-1">{webinarData.speaker.title}</p>
|
||||
<p className="text-[14px] text-muted-foreground mb-2">{webinarData.speaker.company}</p>
|
||||
<p className="text-[16px] text-muted-foreground">{webinarData.speaker.bio}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Right Sidebar - Webinar Details */}
|
||||
<aside className="w-[300px] flex-shrink-0 bg-white border-l border-border">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-[18px] font-semibold text-foreground mb-4">Webinar details</h3>
|
||||
|
||||
{/* Event Information */}
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-[14px] font-medium text-foreground">Date & Time</span>
|
||||
</div>
|
||||
<p className="text-[14px] text-muted-foreground">
|
||||
{formatDate(webinarData.scheduledDate)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-[14px] font-medium text-foreground">Duration</span>
|
||||
</div>
|
||||
<p className="text-[14px] text-muted-foreground">
|
||||
{webinarData.duration}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-[14px] font-medium text-foreground">Attendance</span>
|
||||
</div>
|
||||
<p className="text-[14px] text-muted-foreground">
|
||||
{webinarData.attendees.toLocaleString()} of {webinarData.maxAttendees.toLocaleString()} registered
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Engagement Stats (for completed webinars) */}
|
||||
{webinarData.status === 'completed' && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-[16px] font-medium text-foreground mb-4">Engagement</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-[14px] text-foreground">Views</span>
|
||||
</div>
|
||||
<span className="text-[14px] font-medium">2,450</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ThumbsUp className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-[14px] text-foreground">Likes</span>
|
||||
</div>
|
||||
<span className="text-[14px] font-medium">342</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-[14px] text-foreground">Q&A</span>
|
||||
</div>
|
||||
<span className="text-[14px] font-medium">78</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Content */}
|
||||
<div className="mt-6">
|
||||
<h4 className="text-[16px] font-medium text-foreground mb-4">Related Content</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 border border-border rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<p className="text-[14px] font-medium text-foreground">Remote Leadership Toolkit</p>
|
||||
<p className="text-[12px] text-muted-foreground">Download guide</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 border border-border rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<p className="text-[14px] font-medium text-foreground">Team Collaboration Course</p>
|
||||
<p className="text-[12px] text-muted-foreground">4-week program</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/components/figma/ImageWithFallback.tsx
Normal file
27
src/components/figma/ImageWithFallback.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
const ERROR_IMG_SRC =
|
||||
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=='
|
||||
|
||||
export function ImageWithFallback(props: React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||
const [didError, setDidError] = useState(false)
|
||||
|
||||
const handleError = () => {
|
||||
setDidError(true)
|
||||
}
|
||||
|
||||
const { src, alt, style, className, ...rest } = props
|
||||
|
||||
return didError ? (
|
||||
<div
|
||||
className={`inline-block bg-gray-100 text-center align-middle ${className ?? ''}`}
|
||||
style={style}
|
||||
>
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<img src={ERROR_IMG_SRC} alt="Error loading image" {...rest} data-original-url={src} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<img src={src} alt={alt} className={className} style={style} {...rest} onError={handleError} />
|
||||
)
|
||||
}
|
||||
66
src/components/ui/accordion.tsx
Normal file
66
src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion@1.2.3";
|
||||
import { ChevronDownIcon } from "lucide-react@0.487.0";
|
||||
|
||||
import { cn } from "./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 };
|
||||
157
src/components/ui/alert-dialog.tsx
Normal file
157
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog@1.1.6";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { buttonVariants } from "./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,
|
||||
};
|
||||
66
src/components/ui/alert.tsx
Normal file
66
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
|
||||
|
||||
import { cn } from "./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 };
|
||||
11
src/components/ui/aspect-ratio.tsx
Normal file
11
src/components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio@1.1.2";
|
||||
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
|
||||
}
|
||||
|
||||
export { AspectRatio };
|
||||
53
src/components/ui/avatar.tsx
Normal file
53
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar@1.1.3";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-10 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 };
|
||||
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot@1.1.2";
|
||||
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md 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 };
|
||||
109
src/components/ui/breadcrumb.tsx
Normal file
109
src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot@1.1.2";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react@0.487.0";
|
||||
|
||||
import { cn } from "./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,
|
||||
};
|
||||
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot@1.1.2";
|
||||
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
|
||||
|
||||
import { cn } from "./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 text-foreground 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 rounded-md",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Button = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
75
src/components/ui/calendar.tsx
Normal file
75
src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react@0.487.0";
|
||||
import { DayPicker } from "react-day-picker@8.10.1";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { buttonVariants } from "./button";
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker>) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row gap-2",
|
||||
month: "flex flex-col gap-4",
|
||||
caption: "flex justify-center pt-1 relative items-center w-full",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "flex items-center gap-1",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-x-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: cn(
|
||||
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
|
||||
props.mode === "range"
|
||||
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
|
||||
: "[&:has([aria-selected])]:rounded-md",
|
||||
),
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"size-8 p-0 font-normal aria-selected:opacity-100",
|
||||
),
|
||||
day_range_start:
|
||||
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||
day_range_end:
|
||||
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ className, ...props }) => (
|
||||
<ChevronLeft className={cn("size-4", className)} {...props} />
|
||||
),
|
||||
IconRight: ({ className, ...props }) => (
|
||||
<ChevronRight className={cn("size-4", className)} {...props} />
|
||||
),
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar };
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "./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",
|
||||
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-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<h4
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground", 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 [&:last-child]:pb-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 pb-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
241
src/components/ui/carousel.tsx
Normal file
241
src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react@8.6.0";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react@0.487.0";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { Button } from "./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,
|
||||
};
|
||||
353
src/components/ui/chart.tsx
Normal file
353
src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts@2.15.2";
|
||||
|
||||
import { cn } from "./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.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.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,
|
||||
};
|
||||
32
src/components/ui/checkbox.tsx
Normal file
32
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox@1.1.4";
|
||||
import { CheckIcon } from "lucide-react@0.487.0";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border bg-input-background 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="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
33
src/components/ui/collapsible.tsx
Normal file
33
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible@1.1.3";
|
||||
|
||||
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 };
|
||||
177
src/components/ui/command.tsx
Normal file
177
src/components/ui/command.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk@1.1.1";
|
||||
import { SearchIcon } from "lucide-react@0.487.0";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./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,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<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,
|
||||
};
|
||||
252
src/components/ui/context-menu.tsx
Normal file
252
src/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu@2.2.6";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
|
||||
|
||||
import { cn } from "./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 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,
|
||||
};
|
||||
140
src/components/ui/dialog.tsx
Normal file
140
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog@1.1.6";
|
||||
import { XIcon } from "lucide-react@0.487.0";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
const DialogTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return <DialogPrimitive.Trigger ref={ref} data-slot="dialog-trigger" {...props} />;
|
||||
});
|
||||
DialogTrigger.displayName = "DialogTrigger";
|
||||
|
||||
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} />;
|
||||
}
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
DialogOverlay.displayName = "DialogOverlay";
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
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 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.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>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
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,
|
||||
};
|
||||
132
src/components/ui/drawer.tsx
Normal file
132
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul@1.1.2";
|
||||
|
||||
import { cn } from "./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-1.5 p-4", 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,
|
||||
};
|
||||
257
src/components/ui/dropdown-menu.tsx
Normal file
257
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu@2.1.6";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
|
||||
|
||||
import { cn } from "./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 flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
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,
|
||||
};
|
||||
168
src/components/ui/form.tsx
Normal file
168
src/components/ui/form.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label@2.1.2";
|
||||
import { Slot } from "@radix-ui/react-slot@1.1.2";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form@7.55.0";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { Label } from "./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,
|
||||
};
|
||||
44
src/components/ui/hover-card.tsx
Normal file
44
src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card@1.1.6";
|
||||
|
||||
import { cn } from "./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 };
|
||||
77
src/components/ui/input-otp.tsx
Normal file
77
src/components/ui/input-otp.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { OTPInput, OTPInputContext } from "input-otp@1.4.2";
|
||||
import { MinusIcon } from "lucide-react@0.487.0";
|
||||
|
||||
import { cn } from "./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 gap-1", 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 bg-input-background 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 };
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "./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 flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background 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 };
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label@2.1.2";
|
||||
|
||||
import { cn } from "./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 };
|
||||
276
src/components/ui/menubar.tsx
Normal file
276
src/components/ui/menubar.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar@1.1.6";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
|
||||
|
||||
import { cn } from "./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,
|
||||
};
|
||||
168
src/components/ui/navigation-menu.tsx
Normal file
168
src/components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import * as React from "react";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu@1.2.5";
|
||||
import { cva } from "class-variance-authority@0.7.1";
|
||||
import { ChevronDownIcon } from "lucide-react@0.487.0";
|
||||
|
||||
import { cn } from "./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,
|
||||
};
|
||||
127
src/components/ui/pagination.tsx
Normal file
127
src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react@0.487.0";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { Button, buttonVariants } from "./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,
|
||||
};
|
||||
48
src/components/ui/popover.tsx
Normal file
48
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover@1.1.6";
|
||||
|
||||
import { cn } from "./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 };
|
||||
37
src/components/ui/progress.tsx
Normal file
37
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress@1.1.2";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
indicatorClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root> & {
|
||||
indicatorClassName?: string;
|
||||
}) {
|
||||
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={cn(
|
||||
"bg-primary h-full w-full flex-1 transition-all",
|
||||
indicatorClassName
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
45
src/components/ui/radio-group.tsx
Normal file
45
src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group@1.2.3";
|
||||
import { CircleIcon } from "lucide-react@0.487.0";
|
||||
|
||||
import { cn } from "./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 };
|
||||
56
src/components/ui/resizable.tsx
Normal file
56
src/components/ui/resizable.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { GripVerticalIcon } from "lucide-react@0.487.0";
|
||||
import * as ResizablePrimitive from "react-resizable-panels@2.1.7";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
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.PanelResizeHandle> & {
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
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-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
58
src/components/ui/scroll-area.tsx
Normal file
58
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area@1.2.3";
|
||||
|
||||
import { cn } from "./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 };
|
||||
189
src/components/ui/select.tsx
Normal file
189
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select@2.1.6";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from "lucide-react@0.487.0";
|
||||
|
||||
import { cn } from "./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-full items-center justify-between gap-2 rounded-md border bg-input-background px-3 py-2 text-sm whitespace-nowrap 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 = "popper",
|
||||
...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}
|
||||
{...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 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,
|
||||
};
|
||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator@1.1.2";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
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 };
|
||||
139
src/components/ui/sheet.tsx
Normal file
139
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog@1.1.6";
|
||||
import { XIcon } from "lucide-react@0.487.0";
|
||||
|
||||
import { cn } from "./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,
|
||||
};
|
||||
726
src/components/ui/sidebar.tsx
Normal file
726
src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot@1.1.2";
|
||||
import { VariantProps, cva } from "class-variance-authority@0.7.1";
|
||||
import { PanelLeftIcon } from "lucide-react@0.487.0";
|
||||
|
||||
import { useIsMobile } from "./use-mobile";
|
||||
import { cn } from "./utils";
|
||||
import { Button } from "./button";
|
||||
import { Input } from "./input";
|
||||
import { Separator } from "./separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "./sheet";
|
||||
import { Skeleton } from "./skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./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,
|
||||
};
|
||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "./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 };
|
||||
63
src/components/ui/slider.tsx
Normal file
63
src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider@1.2.3";
|
||||
|
||||
import { cn } from "./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-4 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 bg-background ring-ring/50 block size-4 shrink-0 rounded-full border 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 };
|
||||
25
src/components/ui/sonner.tsx
Normal file
25
src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes@0.4.6";
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner@2.0.3";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
31
src/components/ui/switch.tsx
Normal file
31
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch@1.1.3";
|
||||
|
||||
import { cn } from "./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-switch-background 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 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-card dark:data-[state=unchecked]:bg-card-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 };
|
||||
116
src/components/ui/table.tsx
Normal file
116
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "./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,
|
||||
};
|
||||
66
src/components/ui/tabs.tsx
Normal file
66
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs@1.1.3";
|
||||
|
||||
import { cn } from "./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-xl p-[3px] flex",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-card 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-xl 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 [&_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 };
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"resize-none 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-input-background px-3 py-2 text-base transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
73
src/components/ui/toggle-group.tsx
Normal file
73
src/components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group@1.1.2";
|
||||
import { type VariantProps } from "class-variance-authority@0.7.1";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { toggleVariants } from "./toggle";
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{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}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
47
src/components/ui/toggle.tsx
Normal file
47
src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle@1.1.2";
|
||||
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
|
||||
|
||||
import { cn } from "./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 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 };
|
||||
61
src/components/ui/tooltip.tsx
Normal file
61
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip@1.1.8";
|
||||
|
||||
import { cn } from "./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-primary text-primary-foreground 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-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
21
src/components/ui/use-mobile.ts
Normal file
21
src/components/ui/use-mobile.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
}
|
||||
6
src/components/ui/utils.ts
Normal file
6
src/components/ui/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
26
src/global.d.ts
vendored
Normal file
26
src/global.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
// declarations.d.ts
|
||||
|
||||
declare module "*.png" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.jpg" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.jpeg" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.svg" {
|
||||
import * as React from "react";
|
||||
const ReactComponent: React.FunctionComponent<
|
||||
React.SVGProps<SVGSVGElement> & { title?: string }
|
||||
>;
|
||||
export { ReactComponent };
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
61
src/guidelines/Guidelines.md
Normal file
61
src/guidelines/Guidelines.md
Normal file
@@ -0,0 +1,61 @@
|
||||
**Add your own guidelines here**
|
||||
<!--
|
||||
|
||||
System Guidelines
|
||||
|
||||
Use this file to provide the AI with rules and guidelines you want it to follow.
|
||||
This template outlines a few examples of things you can add. You can add your own sections and format it to suit your needs
|
||||
|
||||
TIP: More context isn't always better. It can confuse the LLM. Try and add the most important rules you need
|
||||
|
||||
# General guidelines
|
||||
|
||||
Any general rules you want the AI to follow.
|
||||
For example:
|
||||
|
||||
* Only use absolute positioning when necessary. Opt for responsive and well structured layouts that use flexbox and grid by default
|
||||
* Refactor code as you go to keep code clean
|
||||
* Keep file sizes small and put helper functions and components in their own files.
|
||||
|
||||
--------------
|
||||
|
||||
# Design system guidelines
|
||||
Rules for how the AI should make generations look like your company's design system
|
||||
|
||||
Additionally, if you select a design system to use in the prompt box, you can reference
|
||||
your design system's components, tokens, variables and components.
|
||||
For example:
|
||||
|
||||
* Use a base font-size of 14px
|
||||
* Date formats should always be in the format “Jun 10”
|
||||
* The bottom toolbar should only ever have a maximum of 4 items
|
||||
* Never use the floating action button with the bottom toolbar
|
||||
* Chips should always come in sets of 3 or more
|
||||
* Don't use a dropdown if there are 2 or fewer options
|
||||
|
||||
You can also create sub sections and add more specific details
|
||||
For example:
|
||||
|
||||
|
||||
## Button
|
||||
The Button component is a fundamental interactive element in our design system, designed to trigger actions or navigate
|
||||
users through the application. It provides visual feedback and clear affordances to enhance user experience.
|
||||
|
||||
### Usage
|
||||
Buttons should be used for important actions that users need to take, such as form submissions, confirming choices,
|
||||
or initiating processes. They communicate interactivity and should have clear, action-oriented labels.
|
||||
|
||||
### Variants
|
||||
* Primary Button
|
||||
* Purpose : Used for the main action in a section or page
|
||||
* Visual Style : Bold, filled with the primary brand color
|
||||
* Usage : One primary button per section to guide users toward the most important action
|
||||
* Secondary Button
|
||||
* Purpose : Used for alternative or supporting actions
|
||||
* Visual Style : Outlined with the primary color, transparent background
|
||||
* Usage : Can appear alongside a primary button for less important actions
|
||||
* Tertiary Button
|
||||
* Purpose : Used for the least important actions
|
||||
* Visual Style : Text-only with no border, using primary color
|
||||
* Usage : For actions that should be available but not emphasized
|
||||
-->
|
||||
7
src/main.tsx
Normal file
7
src/main.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles/globals.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
412
src/styles/globals.css
Normal file
412
src/styles/globals.css
Normal file
@@ -0,0 +1,412 @@
|
||||
@import "tailwindcss";
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
/* === BRAND COLORS === */
|
||||
--color-primary: #04045B; /* Brand Blue */
|
||||
--color-accent: #F8C301; /* Highlight Yellow */
|
||||
--color-black: #26231A; /* Body Text */
|
||||
--color-gray-muted: #6F6F6F; /* Subtext or muted content - Updated to darker gray for better readability */
|
||||
--color-bg-white: #FFFFFF; /* White Background - DEFAULT FOR ALL SECTIONS */
|
||||
--color-bg-light: #FFFFFF; /* Section Background - CHANGED FROM #F9F9F9 TO WHITE */
|
||||
|
||||
/* Modern Dashboard Colors */
|
||||
--color-brand-primary: #04045B;
|
||||
--color-brand-secondary: #6F6F6F;
|
||||
--color-brand-accent: #F8C301;
|
||||
--color-brand-highlight: #04045B;
|
||||
--color-brand-neutral: #FFFFFF;
|
||||
--color-divider: #E5E7EB;
|
||||
|
||||
--font-size: 16px;
|
||||
--background: #ffffff;
|
||||
--foreground: #26231A;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #26231A;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #26231A;
|
||||
--primary: var(--color-brand-primary);
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: var(--color-brand-secondary);
|
||||
--secondary-foreground: #ffffff;
|
||||
--muted: #F3F4F6;
|
||||
--muted-foreground: var(--color-brand-secondary);
|
||||
--accent: var(--color-brand-neutral);
|
||||
--accent-foreground: #26231A;
|
||||
--destructive: #DC2626;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: var(--color-divider);
|
||||
--input: transparent;
|
||||
--input-background: #FFFFFF;
|
||||
--switch-background: #D1D5DB;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-normal: 400;
|
||||
--ring: var(--color-brand-highlight);
|
||||
--chart-1: var(--color-brand-primary);
|
||||
--chart-2: var(--color-brand-highlight);
|
||||
--chart-3: var(--color-brand-accent);
|
||||
--chart-4: #10B981;
|
||||
--chart-5: #F59E0B;
|
||||
--radius: 0.5rem;
|
||||
--sidebar: #FFFFFF;
|
||||
--sidebar-foreground: #26231A;
|
||||
--sidebar-primary: var(--color-brand-primary);
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #FFFFFF;
|
||||
--sidebar-accent-foreground: #6F6F6F;
|
||||
--sidebar-border: var(--color-divider);
|
||||
--sidebar-ring: var(--color-brand-highlight);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-normal: 400;
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-input-background: var(--input-background);
|
||||
--color-switch-background: var(--switch-background);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Modern focus styles */
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible,
|
||||
[role="button"]:focus-visible {
|
||||
outline: 2px solid var(--color-brand-highlight);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Enhanced interactive states */
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
[aria-disabled="true"] {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* Line clamp utilities for text truncation */
|
||||
@layer utilities {
|
||||
.line-clamp-1 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
/* Modern scrollbar styling */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #D1D5DB #FFFFFF;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #FFFFFF;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: #D1D5DB;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-brand-secondary);
|
||||
}
|
||||
|
||||
/* Modern component utilities */
|
||||
.modern-card {
|
||||
background: white;
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modern-card:hover {
|
||||
border-color: var(--color-brand-highlight);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.modern-button-primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: white;
|
||||
border: 1px solid var(--color-brand-primary);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modern-button-primary:hover {
|
||||
background: #030344;
|
||||
border-color: #030344;
|
||||
box-shadow: 0 4px 6px -1px rgba(4, 4, 91, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.modern-button-secondary {
|
||||
background: transparent;
|
||||
color: var(--color-brand-primary);
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modern-button-secondary:hover {
|
||||
background: var(--color-brand-neutral);
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.modern-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 9999px;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modern-badge-primary {
|
||||
background: rgba(4, 4, 91, 0.1);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.modern-badge-secondary {
|
||||
background: #FFFFFF;
|
||||
color: var(--color-brand-secondary);
|
||||
border: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.modern-input {
|
||||
background: var(--input-background);
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modern-input:focus {
|
||||
border-color: var(--color-brand-highlight);
|
||||
box-shadow: 0 0 0 3px rgba(4, 4, 91, 0.1);
|
||||
}
|
||||
|
||||
/* Consistent spacing utilities (8px grid) */
|
||||
.space-xs { margin: 0.5rem; }
|
||||
.space-sm { margin: 1rem; }
|
||||
.space-md { margin: 1.5rem; }
|
||||
.space-lg { margin: 2rem; }
|
||||
.space-xl { margin: 3rem; }
|
||||
|
||||
.gap-xs { gap: 0.5rem; }
|
||||
.gap-sm { gap: 1rem; }
|
||||
.gap-md { gap: 1.5rem; }
|
||||
.gap-lg { gap: 2rem; }
|
||||
.gap-xl { gap: 3rem; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Modern Typography System - Clean hierarchy with semibold headings
|
||||
*/
|
||||
@layer base {
|
||||
:where(:not(:has([class*=" text-"]), :not(:has([class^="text-"])))) {
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.25;
|
||||
color: #26231A;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.375;
|
||||
color: #26231A;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.375;
|
||||
color: #26231A;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.5;
|
||||
color: #26231A;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.5;
|
||||
color: #26231A;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.5;
|
||||
color: #6F6F6F;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 1.625;
|
||||
color: #6F6F6F;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.5;
|
||||
color: #26231A;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 1.5;
|
||||
color: #26231A;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 1.5;
|
||||
color: var(--color-brand-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: var(--font-size);
|
||||
}
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
8
tsconfig.node.json
Normal file
8
tsconfig.node.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
62
vite.config.ts
Normal file
62
vite.config.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
|
||||
alias: {
|
||||
'vaul@1.1.2': 'vaul',
|
||||
'sonner@2.0.3': 'sonner',
|
||||
'recharts@2.15.2': 'recharts',
|
||||
'react-resizable-panels@2.1.7': 'react-resizable-panels',
|
||||
'react-hook-form@7.55.0': 'react-hook-form',
|
||||
'react-day-picker@8.10.1': 'react-day-picker',
|
||||
'next-themes@0.4.6': 'next-themes',
|
||||
'lucide-react@0.487.0': 'lucide-react',
|
||||
'input-otp@1.4.2': 'input-otp',
|
||||
'figma:asset/6b17aafb4d0b31099f8eec7b69e7d0a8b29ad00f.png': path.resolve(__dirname, './src/assets/6b17aafb4d0b31099f8eec7b69e7d0a8b29ad00f.png'),
|
||||
'figma:asset/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png': path.resolve(__dirname, './src/assets/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png'),
|
||||
'embla-carousel-react@8.6.0': 'embla-carousel-react',
|
||||
'cmdk@1.1.1': 'cmdk',
|
||||
'class-variance-authority@0.7.1': 'class-variance-authority',
|
||||
'@radix-ui/react-tooltip@1.1.8': '@radix-ui/react-tooltip',
|
||||
'@radix-ui/react-toggle@1.1.2': '@radix-ui/react-toggle',
|
||||
'@radix-ui/react-toggle-group@1.1.2': '@radix-ui/react-toggle-group',
|
||||
'@radix-ui/react-tabs@1.1.3': '@radix-ui/react-tabs',
|
||||
'@radix-ui/react-switch@1.1.3': '@radix-ui/react-switch',
|
||||
'@radix-ui/react-slot@1.1.2': '@radix-ui/react-slot',
|
||||
'@radix-ui/react-slider@1.2.3': '@radix-ui/react-slider',
|
||||
'@radix-ui/react-separator@1.1.2': '@radix-ui/react-separator',
|
||||
'@radix-ui/react-select@2.1.6': '@radix-ui/react-select',
|
||||
'@radix-ui/react-scroll-area@1.2.3': '@radix-ui/react-scroll-area',
|
||||
'@radix-ui/react-radio-group@1.2.3': '@radix-ui/react-radio-group',
|
||||
'@radix-ui/react-progress@1.1.2': '@radix-ui/react-progress',
|
||||
'@radix-ui/react-popover@1.1.6': '@radix-ui/react-popover',
|
||||
'@radix-ui/react-navigation-menu@1.2.5': '@radix-ui/react-navigation-menu',
|
||||
'@radix-ui/react-menubar@1.1.6': '@radix-ui/react-menubar',
|
||||
'@radix-ui/react-label@2.1.2': '@radix-ui/react-label',
|
||||
'@radix-ui/react-hover-card@1.1.6': '@radix-ui/react-hover-card',
|
||||
'@radix-ui/react-dropdown-menu@2.1.6': '@radix-ui/react-dropdown-menu',
|
||||
'@radix-ui/react-dialog@1.1.6': '@radix-ui/react-dialog',
|
||||
'@radix-ui/react-context-menu@2.2.6': '@radix-ui/react-context-menu',
|
||||
'@radix-ui/react-collapsible@1.1.3': '@radix-ui/react-collapsible',
|
||||
'@radix-ui/react-checkbox@1.1.4': '@radix-ui/react-checkbox',
|
||||
'@radix-ui/react-avatar@1.1.3': '@radix-ui/react-avatar',
|
||||
'@radix-ui/react-aspect-ratio@1.1.2': '@radix-ui/react-aspect-ratio',
|
||||
'@radix-ui/react-alert-dialog@1.1.6': '@radix-ui/react-alert-dialog',
|
||||
'@radix-ui/react-accordion@1.2.3': '@radix-ui/react-accordion',
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
outDir: 'build',
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user