Spaces:
Sleeping
Sleeping
Upload 72 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- web/README.md +11 -0
- web/index.html +15 -0
- web/package.json +59 -0
- web/src/.DS_Store +0 -0
- web/src/App.tsx +377 -0
- web/src/Attributions.md +3 -0
- web/src/components/.DS_Store +0 -0
- web/src/components/ChatArea.tsx +343 -0
- web/src/components/FileUploadArea.tsx +273 -0
- web/src/components/FloatingActionButtons.tsx +111 -0
- web/src/components/GroupMembers.tsx +57 -0
- web/src/components/Header.tsx +79 -0
- web/src/components/LearningModeSelector.tsx +94 -0
- web/src/components/LeftSidebar.tsx +121 -0
- web/src/components/MemoryLine.tsx +108 -0
- web/src/components/Message.tsx +221 -0
- web/src/components/RightPanel.tsx +398 -0
- web/src/components/UserGuide.tsx +151 -0
- web/src/components/figma/ImageWithFallback.tsx +27 -0
- web/src/components/ui/accordion.tsx +66 -0
- web/src/components/ui/alert-dialog.tsx +157 -0
- web/src/components/ui/alert.tsx +66 -0
- web/src/components/ui/aspect-ratio.tsx +11 -0
- web/src/components/ui/avatar.tsx +53 -0
- web/src/components/ui/badge.tsx +46 -0
- web/src/components/ui/breadcrumb.tsx +109 -0
- web/src/components/ui/button.tsx +58 -0
- web/src/components/ui/calendar.tsx +75 -0
- web/src/components/ui/card.tsx +92 -0
- web/src/components/ui/carousel.tsx +241 -0
- web/src/components/ui/chart.tsx +353 -0
- web/src/components/ui/checkbox.tsx +32 -0
- web/src/components/ui/collapsible.tsx +33 -0
- web/src/components/ui/command.tsx +177 -0
- web/src/components/ui/context-menu.tsx +252 -0
- web/src/components/ui/dialog.tsx +137 -0
- web/src/components/ui/drawer.tsx +132 -0
- web/src/components/ui/dropdown-menu.tsx +257 -0
- web/src/components/ui/form.tsx +168 -0
- web/src/components/ui/hover-card.tsx +44 -0
- web/src/components/ui/input-otp.tsx +77 -0
- web/src/components/ui/input.tsx +21 -0
- web/src/components/ui/label.tsx +24 -0
- web/src/components/ui/menubar.tsx +276 -0
- web/src/components/ui/navigation-menu.tsx +168 -0
- web/src/components/ui/pagination.tsx +127 -0
- web/src/components/ui/popover.tsx +48 -0
- web/src/components/ui/progress.tsx +31 -0
- web/src/components/ui/radio-group.tsx +45 -0
- web/src/components/ui/resizable.tsx +56 -0
web/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# Clare AI Tutor UI Redesign (Copy)
|
| 3 |
+
|
| 4 |
+
This is a code bundle for Clare AI Tutor UI Redesign (Copy). The original project is available at https://www.figma.com/design/yC1iBsNtBGpBH8sXO43uah/Clare-AI-Tutor-UI-Redesign--Copy-.
|
| 5 |
+
|
| 6 |
+
## Running the code
|
| 7 |
+
|
| 8 |
+
Run `npm i` to install the dependencies.
|
| 9 |
+
|
| 10 |
+
Run `npm run dev` to start the development server.
|
| 11 |
+
|
web/index.html
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
<!DOCTYPE html>
|
| 3 |
+
<html lang="en">
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Clare AI Tutor UI Redesign (Copy)</title>
|
| 8 |
+
</head>
|
| 9 |
+
|
| 10 |
+
<body>
|
| 11 |
+
<div id="root"></div>
|
| 12 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 13 |
+
</body>
|
| 14 |
+
</html>
|
| 15 |
+
|
web/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
{
|
| 3 |
+
"name": "Clare AI Tutor UI Redesign (Copy)",
|
| 4 |
+
"version": "0.1.0",
|
| 5 |
+
"private": true,
|
| 6 |
+
"dependencies": {
|
| 7 |
+
"@radix-ui/react-accordion": "^1.2.3",
|
| 8 |
+
"@radix-ui/react-alert-dialog": "^1.1.6",
|
| 9 |
+
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
| 10 |
+
"@radix-ui/react-avatar": "^1.1.3",
|
| 11 |
+
"@radix-ui/react-checkbox": "^1.1.4",
|
| 12 |
+
"@radix-ui/react-collapsible": "^1.1.3",
|
| 13 |
+
"@radix-ui/react-context-menu": "^2.2.6",
|
| 14 |
+
"@radix-ui/react-dialog": "^1.1.6",
|
| 15 |
+
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
| 16 |
+
"@radix-ui/react-hover-card": "^1.1.6",
|
| 17 |
+
"@radix-ui/react-label": "^2.1.2",
|
| 18 |
+
"@radix-ui/react-menubar": "^1.1.6",
|
| 19 |
+
"@radix-ui/react-navigation-menu": "^1.2.5",
|
| 20 |
+
"@radix-ui/react-popover": "^1.1.6",
|
| 21 |
+
"@radix-ui/react-progress": "^1.1.2",
|
| 22 |
+
"@radix-ui/react-radio-group": "^1.2.3",
|
| 23 |
+
"@radix-ui/react-scroll-area": "^1.2.3",
|
| 24 |
+
"@radix-ui/react-select": "^2.1.6",
|
| 25 |
+
"@radix-ui/react-separator": "^1.1.2",
|
| 26 |
+
"@radix-ui/react-slider": "^1.2.3",
|
| 27 |
+
"@radix-ui/react-slot": "^1.1.2",
|
| 28 |
+
"@radix-ui/react-switch": "^1.1.3",
|
| 29 |
+
"@radix-ui/react-tabs": "^1.1.3",
|
| 30 |
+
"@radix-ui/react-toggle": "^1.1.2",
|
| 31 |
+
"@radix-ui/react-toggle-group": "^1.1.2",
|
| 32 |
+
"@radix-ui/react-tooltip": "^1.1.8",
|
| 33 |
+
"class-variance-authority": "^0.7.1",
|
| 34 |
+
"clsx": "*",
|
| 35 |
+
"cmdk": "^1.1.1",
|
| 36 |
+
"embla-carousel-react": "^8.6.0",
|
| 37 |
+
"input-otp": "^1.4.2",
|
| 38 |
+
"lucide-react": "^0.487.0",
|
| 39 |
+
"next-themes": "^0.4.6",
|
| 40 |
+
"react": "^18.3.1",
|
| 41 |
+
"react-day-picker": "^8.10.1",
|
| 42 |
+
"react-dom": "^18.3.1",
|
| 43 |
+
"react-hook-form": "^7.55.0",
|
| 44 |
+
"react-resizable-panels": "^2.1.7",
|
| 45 |
+
"recharts": "^2.15.2",
|
| 46 |
+
"sonner": "^2.0.3",
|
| 47 |
+
"tailwind-merge": "*",
|
| 48 |
+
"vaul": "^1.1.2"
|
| 49 |
+
},
|
| 50 |
+
"devDependencies": {
|
| 51 |
+
"@types/node": "^20.10.0",
|
| 52 |
+
"@vitejs/plugin-react-swc": "^3.10.2",
|
| 53 |
+
"vite": "6.3.5"
|
| 54 |
+
},
|
| 55 |
+
"scripts": {
|
| 56 |
+
"dev": "vite",
|
| 57 |
+
"build": "vite build"
|
| 58 |
+
}
|
| 59 |
+
}
|
web/src/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
web/src/App.tsx
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { Header } from './components/Header';
|
| 3 |
+
import { LeftSidebar } from './components/LeftSidebar';
|
| 4 |
+
import { ChatArea } from './components/ChatArea';
|
| 5 |
+
import { RightPanel } from './components/RightPanel';
|
| 6 |
+
import { FloatingActionButtons } from './components/FloatingActionButtons';
|
| 7 |
+
import { Menu, X, User } from 'lucide-react';
|
| 8 |
+
import { Button } from './components/ui/button';
|
| 9 |
+
import { Toaster } from './components/ui/sonner';
|
| 10 |
+
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
| 11 |
+
import { toast } from 'sonner';
|
| 12 |
+
|
| 13 |
+
export interface Message {
|
| 14 |
+
id: string;
|
| 15 |
+
role: 'user' | 'assistant';
|
| 16 |
+
content: string;
|
| 17 |
+
timestamp: Date;
|
| 18 |
+
references?: string[];
|
| 19 |
+
sender?: GroupMember; // For group chat
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export interface User {
|
| 23 |
+
name: string;
|
| 24 |
+
email: string;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export interface GroupMember {
|
| 28 |
+
id: string;
|
| 29 |
+
name: string;
|
| 30 |
+
email: string;
|
| 31 |
+
avatar?: string;
|
| 32 |
+
isAI?: boolean;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export type SpaceType = 'individual' | 'group';
|
| 36 |
+
|
| 37 |
+
export type FileType = 'syllabus' | 'lecture-slides' | 'literature-review' | 'other';
|
| 38 |
+
|
| 39 |
+
export interface UploadedFile {
|
| 40 |
+
file: File;
|
| 41 |
+
type: FileType;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export type LearningMode = 'concept' | 'socratic' | 'exam' | 'assignment' | 'summary';
|
| 45 |
+
export type Language = 'auto' | 'en' | 'zh';
|
| 46 |
+
|
| 47 |
+
function App() {
|
| 48 |
+
const [isDarkMode, setIsDarkMode] = useState(() => {
|
| 49 |
+
const saved = localStorage.getItem('theme');
|
| 50 |
+
return saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
| 51 |
+
});
|
| 52 |
+
const [user, setUser] = useState<User | null>(null);
|
| 53 |
+
const [messages, setMessages] = useState<Message[]>([
|
| 54 |
+
{
|
| 55 |
+
id: '1',
|
| 56 |
+
role: 'assistant',
|
| 57 |
+
content: "👋 Hi! I'm Clare, your AI teaching assistant for Module 10 – Responsible AI. I'm here to help you learn through personalized tutoring. Feel free to ask me anything about the course materials, or upload your documents to get started!",
|
| 58 |
+
timestamp: new Date(),
|
| 59 |
+
}
|
| 60 |
+
]);
|
| 61 |
+
const [learningMode, setLearningMode] = useState<LearningMode>('concept');
|
| 62 |
+
const [language, setLanguage] = useState<Language>('auto');
|
| 63 |
+
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
| 64 |
+
const [memoryProgress, setMemoryProgress] = useState(36);
|
| 65 |
+
const [leftSidebarOpen, setLeftSidebarOpen] = useState(false);
|
| 66 |
+
const [rightPanelOpen, setRightPanelOpen] = useState(false);
|
| 67 |
+
const [rightPanelVisible, setRightPanelVisible] = useState(true);
|
| 68 |
+
const [spaceType, setSpaceType] = useState<SpaceType>('individual');
|
| 69 |
+
const [exportResult, setExportResult] = useState('');
|
| 70 |
+
const [resultType, setResultType] = useState<'export' | 'quiz' | 'summary' | null>(null);
|
| 71 |
+
|
| 72 |
+
// Mock group members
|
| 73 |
+
const [groupMembers] = useState<GroupMember[]>([
|
| 74 |
+
{ id: 'clare', name: 'Clare AI', email: 'clare@ai.assistant', isAI: true },
|
| 75 |
+
{ id: '1', name: 'Sarah Johnson', email: 'sarah.j@university.edu' },
|
| 76 |
+
{ id: '2', name: 'Michael Chen', email: 'michael.c@university.edu' },
|
| 77 |
+
{ id: '3', name: 'Emma Williams', email: 'emma.w@university.edu' },
|
| 78 |
+
]);
|
| 79 |
+
|
| 80 |
+
useEffect(() => {
|
| 81 |
+
document.documentElement.classList.toggle('dark', isDarkMode);
|
| 82 |
+
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
|
| 83 |
+
}, [isDarkMode]);
|
| 84 |
+
|
| 85 |
+
const handleSendMessage = (content: string) => {
|
| 86 |
+
if (!content.trim() || !user) return;
|
| 87 |
+
|
| 88 |
+
// In group mode, add sender info
|
| 89 |
+
const sender: GroupMember | undefined = spaceType === 'group'
|
| 90 |
+
? { id: user.email, name: user.name, email: user.email }
|
| 91 |
+
: undefined;
|
| 92 |
+
|
| 93 |
+
const userMessage: Message = {
|
| 94 |
+
id: Date.now().toString(),
|
| 95 |
+
role: 'user',
|
| 96 |
+
content,
|
| 97 |
+
timestamp: new Date(),
|
| 98 |
+
sender,
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
setMessages(prev => [...prev, userMessage]);
|
| 102 |
+
|
| 103 |
+
// In group mode, only respond if @Clare or @clare is mentioned
|
| 104 |
+
const shouldAIRespond = spaceType === 'individual' ||
|
| 105 |
+
content.toLowerCase().includes('@clare');
|
| 106 |
+
|
| 107 |
+
if (shouldAIRespond) {
|
| 108 |
+
// Simulate AI response
|
| 109 |
+
setTimeout(() => {
|
| 110 |
+
const responses: Record<LearningMode, string> = {
|
| 111 |
+
concept: "Great question! Let me break this concept down for you. In Responsible AI, this relates to ensuring our AI systems are fair, transparent, and accountable. Would you like me to explain any specific aspect in more detail?",
|
| 112 |
+
socratic: "That's an interesting point! Let me ask you this: What do you think are the key ethical considerations when deploying AI systems? Take a moment to think about it.",
|
| 113 |
+
exam: "Let me test your understanding with a quick question: Which of the following is NOT a principle of Responsible AI? A) Fairness B) Transparency C) Profit Maximization D) Accountability",
|
| 114 |
+
assignment: "I can help you with that assignment! Let's break it down into manageable steps. First, what specific aspect are you working on?",
|
| 115 |
+
summary: "Here's a quick summary: Responsible AI focuses on developing and deploying AI systems that are ethical, fair, transparent, and accountable to society.",
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
const assistantMessage: Message = {
|
| 119 |
+
id: (Date.now() + 1).toString(),
|
| 120 |
+
role: 'assistant',
|
| 121 |
+
content: responses[learningMode],
|
| 122 |
+
timestamp: new Date(),
|
| 123 |
+
references: ['Module 10, Section 2.3', 'Lecture Notes - Week 5'],
|
| 124 |
+
sender: spaceType === 'group' ? groupMembers.find(m => m.isAI) : undefined,
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
setMessages(prev => [...prev, assistantMessage]);
|
| 128 |
+
}, 1000);
|
| 129 |
+
}
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
const handleFileUpload = (files: File[]) => {
|
| 133 |
+
const newFiles: UploadedFile[] = files.map(file => ({
|
| 134 |
+
file,
|
| 135 |
+
type: 'other' as FileType, // Default type
|
| 136 |
+
}));
|
| 137 |
+
setUploadedFiles(prev => [...prev, ...newFiles]);
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
const handleRemoveFile = (index: number) => {
|
| 141 |
+
setUploadedFiles(prev => prev.filter((_, i) => i !== index));
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
const handleFileTypeChange = (index: number, type: FileType) => {
|
| 145 |
+
setUploadedFiles(prev => prev.map((file, i) =>
|
| 146 |
+
i === index ? { ...file, type } : file
|
| 147 |
+
));
|
| 148 |
+
};
|
| 149 |
+
|
| 150 |
+
const handleClearConversation = () => {
|
| 151 |
+
setMessages([{
|
| 152 |
+
id: '1',
|
| 153 |
+
role: 'assistant',
|
| 154 |
+
content: "👋 Hi! I'm Clare, your AI teaching assistant for Module 10 – Responsible AI. I'm here to help you learn through personalized tutoring. Feel free to ask me anything about the course materials, or upload your documents to get started!",
|
| 155 |
+
timestamp: new Date(),
|
| 156 |
+
}]);
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
const handleExport = () => {
|
| 160 |
+
const result = `# Conversation Export
|
| 161 |
+
Date: ${new Date().toLocaleDateString()}
|
| 162 |
+
Student: ${user?.name}
|
| 163 |
+
|
| 164 |
+
## Summary
|
| 165 |
+
This conversation covered key concepts in Module 10 – Responsible AI, including ethical considerations, fairness, transparency, and accountability in AI systems.
|
| 166 |
+
|
| 167 |
+
## Key Takeaways
|
| 168 |
+
1. Understanding the principles of Responsible AI
|
| 169 |
+
2. Real-world applications and implications
|
| 170 |
+
3. Best practices for ethical AI development
|
| 171 |
+
|
| 172 |
+
Exported successfully! ✓`;
|
| 173 |
+
|
| 174 |
+
setExportResult(result);
|
| 175 |
+
setResultType('export');
|
| 176 |
+
toast.success('Conversation exported!');
|
| 177 |
+
};
|
| 178 |
+
|
| 179 |
+
const handleQuiz = () => {
|
| 180 |
+
const quiz = `# Micro-Quiz: Responsible AI
|
| 181 |
+
|
| 182 |
+
**Question 1:** Which of the following is a key principle of Responsible AI?
|
| 183 |
+
a) Profit maximization
|
| 184 |
+
b) Transparency
|
| 185 |
+
c) Rapid deployment
|
| 186 |
+
d) Cost reduction
|
| 187 |
+
|
| 188 |
+
**Question 2:** What is algorithmic fairness?
|
| 189 |
+
(Short answer expected)
|
| 190 |
+
|
| 191 |
+
**Question 3:** True or False: AI systems should always prioritize accuracy over fairness.
|
| 192 |
+
|
| 193 |
+
Generate quiz based on your conversation!`;
|
| 194 |
+
|
| 195 |
+
setExportResult(quiz);
|
| 196 |
+
setResultType('quiz');
|
| 197 |
+
toast.success('Quiz generated!');
|
| 198 |
+
};
|
| 199 |
+
|
| 200 |
+
const handleSummary = () => {
|
| 201 |
+
const summary = `# Learning Summary
|
| 202 |
+
|
| 203 |
+
## Today's Session
|
| 204 |
+
**Duration:** 25 minutes
|
| 205 |
+
**Topics Covered:** 3
|
| 206 |
+
**Messages Exchanged:** 12
|
| 207 |
+
|
| 208 |
+
## Key Concepts Discussed
|
| 209 |
+
• Principles of Responsible AI
|
| 210 |
+
• Ethical considerations in AI development
|
| 211 |
+
• Fairness and transparency in algorithms
|
| 212 |
+
|
| 213 |
+
## Recommended Next Steps
|
| 214 |
+
1. Review Module 10, Section 2.3
|
| 215 |
+
2. Complete practice quiz on algorithmic fairness
|
| 216 |
+
3. Read additional resources on AI ethics
|
| 217 |
+
|
| 218 |
+
## Progress Update
|
| 219 |
+
You've covered 65% of Module 10 content. Keep up the great work! 🎉`;
|
| 220 |
+
|
| 221 |
+
setExportResult(summary);
|
| 222 |
+
setResultType('summary');
|
| 223 |
+
toast.success('Summary generated!');
|
| 224 |
+
};
|
| 225 |
+
|
| 226 |
+
return (
|
| 227 |
+
<div className="min-h-screen bg-background flex flex-col">
|
| 228 |
+
<Toaster />
|
| 229 |
+
<Header
|
| 230 |
+
user={user}
|
| 231 |
+
onMenuClick={() => setLeftSidebarOpen(!leftSidebarOpen)}
|
| 232 |
+
onUserClick={() => setRightPanelOpen(!rightPanelOpen)}
|
| 233 |
+
isDarkMode={isDarkMode}
|
| 234 |
+
onToggleDarkMode={() => setIsDarkMode(!isDarkMode)}
|
| 235 |
+
/>
|
| 236 |
+
|
| 237 |
+
<div className="flex-1 flex overflow-hidden">
|
| 238 |
+
{/* Mobile Sidebar Toggle - Left */}
|
| 239 |
+
{leftSidebarOpen && (
|
| 240 |
+
<div
|
| 241 |
+
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
| 242 |
+
onClick={() => setLeftSidebarOpen(false)}
|
| 243 |
+
/>
|
| 244 |
+
)}
|
| 245 |
+
|
| 246 |
+
{/* Left Sidebar */}
|
| 247 |
+
<aside
|
| 248 |
+
className={`
|
| 249 |
+
fixed lg:static inset-y-0 left-0 z-50
|
| 250 |
+
w-80 bg-card border-r border-border
|
| 251 |
+
transform transition-transform duration-300 ease-in-out
|
| 252 |
+
${leftSidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
| 253 |
+
lg:translate-x-0
|
| 254 |
+
flex flex-col
|
| 255 |
+
mt-16 lg:mt-0
|
| 256 |
+
`}
|
| 257 |
+
>
|
| 258 |
+
<div className="lg:hidden p-4 border-b border-border flex justify-between items-center">
|
| 259 |
+
<h3>Settings & Guide</h3>
|
| 260 |
+
<Button
|
| 261 |
+
variant="ghost"
|
| 262 |
+
size="icon"
|
| 263 |
+
onClick={() => setLeftSidebarOpen(false)}
|
| 264 |
+
>
|
| 265 |
+
<X className="h-5 w-5" />
|
| 266 |
+
</Button>
|
| 267 |
+
</div>
|
| 268 |
+
<LeftSidebar
|
| 269 |
+
learningMode={learningMode}
|
| 270 |
+
language={language}
|
| 271 |
+
onLearningModeChange={setLearningMode}
|
| 272 |
+
onLanguageChange={setLanguage}
|
| 273 |
+
spaceType={spaceType}
|
| 274 |
+
onSpaceTypeChange={setSpaceType}
|
| 275 |
+
groupMembers={groupMembers}
|
| 276 |
+
/>
|
| 277 |
+
</aside>
|
| 278 |
+
|
| 279 |
+
{/* Main Chat Area */}
|
| 280 |
+
<main className="flex-1 flex flex-col min-w-0">
|
| 281 |
+
<ChatArea
|
| 282 |
+
messages={messages}
|
| 283 |
+
onSendMessage={handleSendMessage}
|
| 284 |
+
uploadedFiles={uploadedFiles}
|
| 285 |
+
onFileUpload={handleFileUpload}
|
| 286 |
+
onRemoveFile={handleRemoveFile}
|
| 287 |
+
onFileTypeChange={handleFileTypeChange}
|
| 288 |
+
memoryProgress={memoryProgress}
|
| 289 |
+
isLoggedIn={!!user}
|
| 290 |
+
learningMode={learningMode}
|
| 291 |
+
onClearConversation={handleClearConversation}
|
| 292 |
+
onLearningModeChange={setLearningMode}
|
| 293 |
+
spaceType={spaceType}
|
| 294 |
+
/>
|
| 295 |
+
</main>
|
| 296 |
+
|
| 297 |
+
{/* Mobile Sidebar Toggle - Right */}
|
| 298 |
+
{rightPanelOpen && (
|
| 299 |
+
<div
|
| 300 |
+
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
| 301 |
+
onClick={() => setRightPanelOpen(false)}
|
| 302 |
+
/>
|
| 303 |
+
)}
|
| 304 |
+
|
| 305 |
+
{/* Right Panel */}
|
| 306 |
+
{rightPanelVisible && (
|
| 307 |
+
<aside
|
| 308 |
+
className={`
|
| 309 |
+
fixed lg:static inset-y-0 right-0 z-50
|
| 310 |
+
w-80 bg-card border-l border-border
|
| 311 |
+
transform transition-transform duration-300 ease-in-out
|
| 312 |
+
${rightPanelOpen ? 'translate-x-0' : 'translate-x-full'}
|
| 313 |
+
lg:translate-x-0
|
| 314 |
+
flex flex-col
|
| 315 |
+
mt-16 lg:mt-0
|
| 316 |
+
`}
|
| 317 |
+
>
|
| 318 |
+
<div className="lg:hidden p-4 border-b border-border flex justify-between items-center">
|
| 319 |
+
<h3>Account & Actions</h3>
|
| 320 |
+
<Button
|
| 321 |
+
variant="ghost"
|
| 322 |
+
size="icon"
|
| 323 |
+
onClick={() => setRightPanelOpen(false)}
|
| 324 |
+
>
|
| 325 |
+
<X className="h-5 w-5" />
|
| 326 |
+
</Button>
|
| 327 |
+
</div>
|
| 328 |
+
<RightPanel
|
| 329 |
+
user={user}
|
| 330 |
+
onLogin={setUser}
|
| 331 |
+
onLogout={() => setUser(null)}
|
| 332 |
+
isLoggedIn={!!user}
|
| 333 |
+
onClose={() => setRightPanelVisible(false)}
|
| 334 |
+
exportResult={exportResult}
|
| 335 |
+
setExportResult={setExportResult}
|
| 336 |
+
resultType={resultType}
|
| 337 |
+
setResultType={setResultType}
|
| 338 |
+
/>
|
| 339 |
+
</aside>
|
| 340 |
+
)}
|
| 341 |
+
|
| 342 |
+
{/* Toggle Right Panel Button - Desktop only */}
|
| 343 |
+
<Button
|
| 344 |
+
variant="outline"
|
| 345 |
+
size="icon"
|
| 346 |
+
onClick={() => setRightPanelVisible(!rightPanelVisible)}
|
| 347 |
+
className={`hidden lg:flex fixed top-20 z-[70] h-8 w-5 shadow-lg transition-all rounded-l-full rounded-r-none border-r-0 ${
|
| 348 |
+
rightPanelVisible
|
| 349 |
+
? 'right-[320px]'
|
| 350 |
+
: 'right-0'
|
| 351 |
+
}`}
|
| 352 |
+
title={rightPanelVisible ? 'Close panel' : 'Open panel'}
|
| 353 |
+
>
|
| 354 |
+
{rightPanelVisible ? (
|
| 355 |
+
<ChevronRight className="h-3 w-3" />
|
| 356 |
+
) : (
|
| 357 |
+
<ChevronLeft className="h-3 w-3" />
|
| 358 |
+
)}
|
| 359 |
+
</Button>
|
| 360 |
+
|
| 361 |
+
{/* Floating Action Buttons - Desktop only, when panel is closed */}
|
| 362 |
+
{!rightPanelVisible && (
|
| 363 |
+
<FloatingActionButtons
|
| 364 |
+
user={user}
|
| 365 |
+
isLoggedIn={!!user}
|
| 366 |
+
onOpenPanel={() => setRightPanelVisible(true)}
|
| 367 |
+
onExport={handleExport}
|
| 368 |
+
onQuiz={handleQuiz}
|
| 369 |
+
onSummary={handleSummary}
|
| 370 |
+
/>
|
| 371 |
+
)}
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
);
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
export default App;
|
web/src/Attributions.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
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).
|
| 2 |
+
|
| 3 |
+
This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license).
|
web/src/components/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
web/src/components/ChatArea.tsx
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { Button } from './ui/button';
|
| 3 |
+
import { Textarea } from './ui/textarea';
|
| 4 |
+
import { Send, ArrowDown, AlertCircle, Trash2, Share2 } from 'lucide-react';
|
| 5 |
+
import { Message } from './Message';
|
| 6 |
+
import { FileUploadArea } from './FileUploadArea';
|
| 7 |
+
import { MemoryLine } from './MemoryLine';
|
| 8 |
+
import { Alert, AlertDescription } from './ui/alert';
|
| 9 |
+
import { Badge } from './ui/badge';
|
| 10 |
+
import type { Message as MessageType, LearningMode, UploadedFile, FileType, SpaceType } from '../App';
|
| 11 |
+
import { toast } from 'sonner';
|
| 12 |
+
import {
|
| 13 |
+
DropdownMenu,
|
| 14 |
+
DropdownMenuContent,
|
| 15 |
+
DropdownMenuItem,
|
| 16 |
+
DropdownMenuTrigger,
|
| 17 |
+
} from './ui/dropdown-menu';
|
| 18 |
+
|
| 19 |
+
interface ChatAreaProps {
|
| 20 |
+
messages: MessageType[];
|
| 21 |
+
onSendMessage: (content: string) => void;
|
| 22 |
+
uploadedFiles: UploadedFile[];
|
| 23 |
+
onFileUpload: (files: File[]) => void;
|
| 24 |
+
onRemoveFile: (index: number) => void;
|
| 25 |
+
onFileTypeChange: (index: number, type: FileType) => void;
|
| 26 |
+
memoryProgress: number;
|
| 27 |
+
isLoggedIn: boolean;
|
| 28 |
+
learningMode: LearningMode;
|
| 29 |
+
onClearConversation: () => void;
|
| 30 |
+
onLearningModeChange: (mode: LearningMode) => void;
|
| 31 |
+
spaceType: SpaceType;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export function ChatArea({
|
| 35 |
+
messages,
|
| 36 |
+
onSendMessage,
|
| 37 |
+
uploadedFiles,
|
| 38 |
+
onFileUpload,
|
| 39 |
+
onRemoveFile,
|
| 40 |
+
onFileTypeChange,
|
| 41 |
+
memoryProgress,
|
| 42 |
+
isLoggedIn,
|
| 43 |
+
learningMode,
|
| 44 |
+
onClearConversation,
|
| 45 |
+
onLearningModeChange,
|
| 46 |
+
spaceType,
|
| 47 |
+
}: ChatAreaProps) {
|
| 48 |
+
const [input, setInput] = useState('');
|
| 49 |
+
const [isTyping, setIsTyping] = useState(false);
|
| 50 |
+
const [showScrollButton, setShowScrollButton] = useState(false);
|
| 51 |
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 52 |
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
| 53 |
+
|
| 54 |
+
const scrollToBottom = () => {
|
| 55 |
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
useEffect(() => {
|
| 59 |
+
scrollToBottom();
|
| 60 |
+
}, [messages]);
|
| 61 |
+
|
| 62 |
+
useEffect(() => {
|
| 63 |
+
const handleScroll = () => {
|
| 64 |
+
if (scrollContainerRef.current) {
|
| 65 |
+
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
| 66 |
+
setShowScrollButton(scrollHeight - scrollTop - clientHeight > 100);
|
| 67 |
+
}
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
const container = scrollContainerRef.current;
|
| 71 |
+
container?.addEventListener('scroll', handleScroll);
|
| 72 |
+
return () => container?.removeEventListener('scroll', handleScroll);
|
| 73 |
+
}, []);
|
| 74 |
+
|
| 75 |
+
const handleSubmit = (e: React.FormEvent) => {
|
| 76 |
+
e.preventDefault();
|
| 77 |
+
if (!input.trim() || !isLoggedIn) return;
|
| 78 |
+
|
| 79 |
+
onSendMessage(input);
|
| 80 |
+
setInput('');
|
| 81 |
+
setIsTyping(true);
|
| 82 |
+
setTimeout(() => setIsTyping(false), 1500);
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
| 86 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 87 |
+
e.preventDefault();
|
| 88 |
+
handleSubmit(e);
|
| 89 |
+
}
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
const modeLabels: Record<LearningMode, string> = {
|
| 93 |
+
concept: 'Concept Explainer',
|
| 94 |
+
socratic: 'Socratic Tutor',
|
| 95 |
+
exam: 'Exam Prep',
|
| 96 |
+
assignment: 'Assignment Helper',
|
| 97 |
+
summary: 'Quick Summary',
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const handleClearClick = () => {
|
| 101 |
+
if (messages.length <= 1) {
|
| 102 |
+
toast.info('No conversation to clear');
|
| 103 |
+
return;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
if (window.confirm('Are you sure you want to clear the conversation? This cannot be undone.')) {
|
| 107 |
+
onClearConversation();
|
| 108 |
+
toast.success('Conversation cleared');
|
| 109 |
+
}
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const handleShareClick = () => {
|
| 113 |
+
if (messages.length <= 1) {
|
| 114 |
+
toast.info('No conversation to share');
|
| 115 |
+
return;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// Create a shareable text version of the conversation
|
| 119 |
+
const conversationText = messages
|
| 120 |
+
.map(msg => `${msg.sender === 'user' ? 'You' : 'Clare'}: ${msg.content}`)
|
| 121 |
+
.join('\n\n');
|
| 122 |
+
|
| 123 |
+
// Copy to clipboard
|
| 124 |
+
navigator.clipboard.writeText(conversationText).then(() => {
|
| 125 |
+
toast.success('Conversation copied to clipboard!');
|
| 126 |
+
}).catch(() => {
|
| 127 |
+
toast.error('Failed to copy conversation');
|
| 128 |
+
});
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
return (
|
| 132 |
+
<div className="flex flex-col h-full">
|
| 133 |
+
{/* Chat Area with Floating Input */}
|
| 134 |
+
<div className="flex-1 relative border-b-2 border-border">
|
| 135 |
+
{/* Action Buttons - Fixed at top right */}
|
| 136 |
+
{messages.length > 1 && (
|
| 137 |
+
<div className="absolute top-4 right-12 z-10 flex gap-2">
|
| 138 |
+
<Button
|
| 139 |
+
variant="ghost"
|
| 140 |
+
size="sm"
|
| 141 |
+
onClick={handleShareClick}
|
| 142 |
+
disabled={!isLoggedIn}
|
| 143 |
+
className="gap-2 bg-background/95 backdrop-blur-sm shadow-sm hover:shadow-md transition-all group"
|
| 144 |
+
>
|
| 145 |
+
<Share2 className="h-4 w-4" />
|
| 146 |
+
<span className="hidden group-hover:inline">Share</span>
|
| 147 |
+
</Button>
|
| 148 |
+
<Button
|
| 149 |
+
variant="ghost"
|
| 150 |
+
size="sm"
|
| 151 |
+
onClick={handleClearClick}
|
| 152 |
+
disabled={!isLoggedIn}
|
| 153 |
+
className="gap-2 bg-background/95 backdrop-blur-sm shadow-sm hover:shadow-md transition-all group"
|
| 154 |
+
>
|
| 155 |
+
<Trash2 className="h-4 w-4" />
|
| 156 |
+
<span className="hidden group-hover:inline">Clear</span>
|
| 157 |
+
</Button>
|
| 158 |
+
</div>
|
| 159 |
+
)}
|
| 160 |
+
|
| 161 |
+
{/* Messages Area */}
|
| 162 |
+
<div
|
| 163 |
+
ref={scrollContainerRef}
|
| 164 |
+
className="h-full max-h-[600px] overflow-y-auto px-4 py-6 pb-36"
|
| 165 |
+
>
|
| 166 |
+
<div className="max-w-4xl mx-auto space-y-6">
|
| 167 |
+
{messages.map((message) => (
|
| 168 |
+
<Message
|
| 169 |
+
key={message.id}
|
| 170 |
+
message={message}
|
| 171 |
+
showSenderInfo={spaceType === 'group'}
|
| 172 |
+
/>
|
| 173 |
+
))}
|
| 174 |
+
|
| 175 |
+
{isTyping && (
|
| 176 |
+
<div className="flex gap-3">
|
| 177 |
+
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center flex-shrink-0">
|
| 178 |
+
<span className="text-white text-sm">C</span>
|
| 179 |
+
</div>
|
| 180 |
+
<div className="bg-muted rounded-2xl px-4 py-3">
|
| 181 |
+
<div className="flex gap-1">
|
| 182 |
+
<div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '0ms' }} />
|
| 183 |
+
<div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '150ms' }} />
|
| 184 |
+
<div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '300ms' }} />
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
)}
|
| 189 |
+
|
| 190 |
+
<div ref={messagesEndRef} />
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
{/* Scroll to Bottom Button - Floating above input */}
|
| 195 |
+
{showScrollButton && (
|
| 196 |
+
<div className="absolute bottom-24 left-1/2 -translate-x-1/2 z-20">
|
| 197 |
+
<Button
|
| 198 |
+
variant="secondary"
|
| 199 |
+
size="icon"
|
| 200 |
+
className="rounded-full shadow-lg hover:shadow-xl transition-shadow bg-background"
|
| 201 |
+
onClick={scrollToBottom}
|
| 202 |
+
>
|
| 203 |
+
<ArrowDown className="h-4 w-4" />
|
| 204 |
+
</Button>
|
| 205 |
+
</div>
|
| 206 |
+
)}
|
| 207 |
+
|
| 208 |
+
{/* Floating Input Area */}
|
| 209 |
+
<div className="absolute bottom-0 left-0 right-0 bg-background/95 backdrop-blur-sm z-10">
|
| 210 |
+
<div className="max-w-4xl mx-auto px-4 py-4">
|
| 211 |
+
<form onSubmit={handleSubmit}>
|
| 212 |
+
<div className="relative">
|
| 213 |
+
{/* Mode Selector - ChatGPT style at bottom left */}
|
| 214 |
+
<DropdownMenu>
|
| 215 |
+
<DropdownMenuTrigger asChild>
|
| 216 |
+
<Button
|
| 217 |
+
variant="ghost"
|
| 218 |
+
size="sm"
|
| 219 |
+
className="absolute bottom-3 left-2 gap-1.5 h-8 px-2 text-xs z-10 hover:bg-muted/50"
|
| 220 |
+
disabled={!isLoggedIn}
|
| 221 |
+
type="button"
|
| 222 |
+
>
|
| 223 |
+
<span>{modeLabels[learningMode]}</span>
|
| 224 |
+
<svg
|
| 225 |
+
className="h-3 w-3 opacity-50"
|
| 226 |
+
fill="none"
|
| 227 |
+
stroke="currentColor"
|
| 228 |
+
viewBox="0 0 24 24"
|
| 229 |
+
>
|
| 230 |
+
<path
|
| 231 |
+
strokeLinecap="round"
|
| 232 |
+
strokeLinejoin="round"
|
| 233 |
+
strokeWidth={2}
|
| 234 |
+
d="M19 9l-7 7-7-7"
|
| 235 |
+
/>
|
| 236 |
+
</svg>
|
| 237 |
+
</Button>
|
| 238 |
+
</DropdownMenuTrigger>
|
| 239 |
+
<DropdownMenuContent align="start" className="w-56">
|
| 240 |
+
<DropdownMenuItem
|
| 241 |
+
onClick={() => onLearningModeChange('concept')}
|
| 242 |
+
className={learningMode === 'concept' ? 'bg-accent' : ''}
|
| 243 |
+
>
|
| 244 |
+
<div className="flex flex-col">
|
| 245 |
+
<span className="font-medium">Concept Explainer</span>
|
| 246 |
+
<span className="text-xs text-muted-foreground">
|
| 247 |
+
Get detailed explanations of concepts
|
| 248 |
+
</span>
|
| 249 |
+
</div>
|
| 250 |
+
</DropdownMenuItem>
|
| 251 |
+
<DropdownMenuItem
|
| 252 |
+
onClick={() => onLearningModeChange('socratic')}
|
| 253 |
+
className={learningMode === 'socratic' ? 'bg-accent' : ''}
|
| 254 |
+
>
|
| 255 |
+
<div className="flex flex-col">
|
| 256 |
+
<span className="font-medium">Socratic Tutor</span>
|
| 257 |
+
<span className="text-xs text-muted-foreground">
|
| 258 |
+
Learn through guided questions
|
| 259 |
+
</span>
|
| 260 |
+
</div>
|
| 261 |
+
</DropdownMenuItem>
|
| 262 |
+
<DropdownMenuItem
|
| 263 |
+
onClick={() => onLearningModeChange('exam')}
|
| 264 |
+
className={learningMode === 'exam' ? 'bg-accent' : ''}
|
| 265 |
+
>
|
| 266 |
+
<div className="flex flex-col">
|
| 267 |
+
<span className="font-medium">Exam Prep</span>
|
| 268 |
+
<span className="text-xs text-muted-foreground">
|
| 269 |
+
Practice with quiz questions
|
| 270 |
+
</span>
|
| 271 |
+
</div>
|
| 272 |
+
</DropdownMenuItem>
|
| 273 |
+
<DropdownMenuItem
|
| 274 |
+
onClick={() => onLearningModeChange('assignment')}
|
| 275 |
+
className={learningMode === 'assignment' ? 'bg-accent' : ''}
|
| 276 |
+
>
|
| 277 |
+
<div className="flex flex-col">
|
| 278 |
+
<span className="font-medium">Assignment Helper</span>
|
| 279 |
+
<span className="text-xs text-muted-foreground">
|
| 280 |
+
Get help with assignments
|
| 281 |
+
</span>
|
| 282 |
+
</div>
|
| 283 |
+
</DropdownMenuItem>
|
| 284 |
+
<DropdownMenuItem
|
| 285 |
+
onClick={() => onLearningModeChange('summary')}
|
| 286 |
+
className={learningMode === 'summary' ? 'bg-accent' : ''}
|
| 287 |
+
>
|
| 288 |
+
<div className="flex flex-col">
|
| 289 |
+
<span className="font-medium">Quick Summary</span>
|
| 290 |
+
<span className="text-xs text-muted-foreground">
|
| 291 |
+
Get concise summaries
|
| 292 |
+
</span>
|
| 293 |
+
</div>
|
| 294 |
+
</DropdownMenuItem>
|
| 295 |
+
</DropdownMenuContent>
|
| 296 |
+
</DropdownMenu>
|
| 297 |
+
|
| 298 |
+
<Textarea
|
| 299 |
+
value={input}
|
| 300 |
+
onChange={(e) => setInput(e.target.value)}
|
| 301 |
+
onKeyDown={handleKeyDown}
|
| 302 |
+
placeholder={
|
| 303 |
+
isLoggedIn
|
| 304 |
+
? spaceType === 'group'
|
| 305 |
+
? "Type a message... (mention @Clare to get AI assistance)"
|
| 306 |
+
: "Ask Clare anything about the course..."
|
| 307 |
+
: "Please log in on the right to start chatting..."
|
| 308 |
+
}
|
| 309 |
+
disabled={!isLoggedIn}
|
| 310 |
+
className="min-h-[80px] pl-4 pr-12 resize-none bg-background border-2 border-border"
|
| 311 |
+
/>
|
| 312 |
+
<Button
|
| 313 |
+
type="submit"
|
| 314 |
+
size="icon"
|
| 315 |
+
disabled={!input.trim() || !isLoggedIn}
|
| 316 |
+
className="absolute bottom-2 right-2 rounded-full"
|
| 317 |
+
>
|
| 318 |
+
<Send className="h-4 w-4" />
|
| 319 |
+
</Button>
|
| 320 |
+
</div>
|
| 321 |
+
</form>
|
| 322 |
+
</div>
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
|
| 326 |
+
{/* Course Materials Section */}
|
| 327 |
+
<div className="bg-card">
|
| 328 |
+
<div className="max-w-4xl mx-auto px-4 py-4">
|
| 329 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
| 330 |
+
<FileUploadArea
|
| 331 |
+
uploadedFiles={uploadedFiles}
|
| 332 |
+
onFileUpload={onFileUpload}
|
| 333 |
+
onRemoveFile={onRemoveFile}
|
| 334 |
+
onFileTypeChange={onFileTypeChange}
|
| 335 |
+
disabled={!isLoggedIn}
|
| 336 |
+
/>
|
| 337 |
+
<MemoryLine progress={memoryProgress} />
|
| 338 |
+
</div>
|
| 339 |
+
</div>
|
| 340 |
+
</div>
|
| 341 |
+
</div>
|
| 342 |
+
);
|
| 343 |
+
}
|
web/src/components/FileUploadArea.tsx
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useRef, useState } from 'react';
|
| 2 |
+
import { Button } from './ui/button';
|
| 3 |
+
import { Upload, File, X, FileText, FileSpreadsheet, Presentation } from 'lucide-react';
|
| 4 |
+
import { Card } from './ui/card';
|
| 5 |
+
import { Badge } from './ui/badge';
|
| 6 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
| 7 |
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
| 8 |
+
import type { UploadedFile, FileType } from '../App';
|
| 9 |
+
|
| 10 |
+
interface FileUploadAreaProps {
|
| 11 |
+
uploadedFiles: UploadedFile[];
|
| 12 |
+
onFileUpload: (files: File[]) => void;
|
| 13 |
+
onRemoveFile: (index: number) => void;
|
| 14 |
+
onFileTypeChange: (index: number, type: FileType) => void;
|
| 15 |
+
disabled?: boolean;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
interface PendingFile {
|
| 19 |
+
file: File;
|
| 20 |
+
type: FileType;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function FileUploadArea({
|
| 24 |
+
uploadedFiles,
|
| 25 |
+
onFileUpload,
|
| 26 |
+
onRemoveFile,
|
| 27 |
+
onFileTypeChange,
|
| 28 |
+
disabled = false,
|
| 29 |
+
}: FileUploadAreaProps) {
|
| 30 |
+
const [isDragging, setIsDragging] = useState(false);
|
| 31 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 32 |
+
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
| 33 |
+
const [showTypeDialog, setShowTypeDialog] = useState(false);
|
| 34 |
+
|
| 35 |
+
const handleDragOver = (e: React.DragEvent) => {
|
| 36 |
+
e.preventDefault();
|
| 37 |
+
if (!disabled) setIsDragging(true);
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const handleDragLeave = () => {
|
| 41 |
+
setIsDragging(false);
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const handleDrop = (e: React.DragEvent) => {
|
| 45 |
+
e.preventDefault();
|
| 46 |
+
setIsDragging(false);
|
| 47 |
+
if (disabled) return;
|
| 48 |
+
|
| 49 |
+
const files = Array.from(e.dataTransfer.files).filter((file) =>
|
| 50 |
+
['.pdf', '.docx', '.pptx'].some((ext) => file.name.toLowerCase().endsWith(ext))
|
| 51 |
+
);
|
| 52 |
+
|
| 53 |
+
if (files.length > 0) {
|
| 54 |
+
setPendingFiles(files.map(file => ({ file, type: 'other' as FileType })));
|
| 55 |
+
setShowTypeDialog(true);
|
| 56 |
+
}
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 60 |
+
const files = Array.from(e.target.files || []);
|
| 61 |
+
if (files.length > 0) {
|
| 62 |
+
setPendingFiles(files.map(file => ({ file, type: 'other' as FileType })));
|
| 63 |
+
setShowTypeDialog(true);
|
| 64 |
+
}
|
| 65 |
+
e.target.value = '';
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
const handleConfirmUpload = () => {
|
| 69 |
+
onFileUpload(pendingFiles.map(pf => pf.file));
|
| 70 |
+
// Update the parent's file types
|
| 71 |
+
const startIndex = uploadedFiles.length;
|
| 72 |
+
pendingFiles.forEach((pf, idx) => {
|
| 73 |
+
setTimeout(() => {
|
| 74 |
+
onFileTypeChange(startIndex + idx, pf.type);
|
| 75 |
+
}, 0);
|
| 76 |
+
});
|
| 77 |
+
setPendingFiles([]);
|
| 78 |
+
setShowTypeDialog(false);
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
const handleCancelUpload = () => {
|
| 82 |
+
setPendingFiles([]);
|
| 83 |
+
setShowTypeDialog(false);
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
const handlePendingFileTypeChange = (index: number, type: FileType) => {
|
| 87 |
+
setPendingFiles(prev => prev.map((pf, i) =>
|
| 88 |
+
i === index ? { ...pf, type } : pf
|
| 89 |
+
));
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
const getFileIcon = (filename: string) => {
|
| 93 |
+
if (filename.endsWith('.pdf')) return FileText;
|
| 94 |
+
if (filename.endsWith('.docx')) return File;
|
| 95 |
+
if (filename.endsWith('.pptx')) return Presentation;
|
| 96 |
+
return File;
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
const formatFileSize = (bytes: number) => {
|
| 100 |
+
if (bytes < 1024) return bytes + ' B';
|
| 101 |
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
| 102 |
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
const getFileTypeLabel = (type: FileType) => {
|
| 106 |
+
const labels: Record<FileType, string> = {
|
| 107 |
+
'syllabus': 'Syllabus',
|
| 108 |
+
'lecture-slides': 'Lecture Slides / PPT',
|
| 109 |
+
'literature-review': 'Literature Review / Paper',
|
| 110 |
+
'other': 'Other Course Document',
|
| 111 |
+
};
|
| 112 |
+
return labels[type];
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
return (
|
| 116 |
+
<Card className="p-4 space-y-3">
|
| 117 |
+
<div className="flex items-center justify-between">
|
| 118 |
+
<h4 className="text-sm">Course Materials</h4>
|
| 119 |
+
{uploadedFiles.length > 0 && (
|
| 120 |
+
<Badge variant="secondary">{uploadedFiles.length} file(s)</Badge>
|
| 121 |
+
)}
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
{/* Upload Area */}
|
| 125 |
+
<div
|
| 126 |
+
onDragOver={handleDragOver}
|
| 127 |
+
onDragLeave={handleDragLeave}
|
| 128 |
+
onDrop={handleDrop}
|
| 129 |
+
className={`
|
| 130 |
+
border-2 border-dashed rounded-lg p-4 text-center transition-colors
|
| 131 |
+
${isDragging ? 'border-primary bg-accent' : 'border-border'}
|
| 132 |
+
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
| 133 |
+
`}
|
| 134 |
+
onClick={() => !disabled && fileInputRef.current?.click()}
|
| 135 |
+
>
|
| 136 |
+
<Upload className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
|
| 137 |
+
<p className="text-sm text-muted-foreground mb-1">
|
| 138 |
+
{disabled ? 'Please log in to upload' : 'Drop files or click to upload'}
|
| 139 |
+
</p>
|
| 140 |
+
<p className="text-xs text-muted-foreground">
|
| 141 |
+
.pdf, .docx, .pptx
|
| 142 |
+
</p>
|
| 143 |
+
<input
|
| 144 |
+
ref={fileInputRef}
|
| 145 |
+
type="file"
|
| 146 |
+
multiple
|
| 147 |
+
accept=".pdf,.docx,.pptx"
|
| 148 |
+
onChange={handleFileSelect}
|
| 149 |
+
className="hidden"
|
| 150 |
+
disabled={disabled}
|
| 151 |
+
/>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
{/* Uploaded Files List */}
|
| 155 |
+
{uploadedFiles.length > 0 && (
|
| 156 |
+
<div className="space-y-3 max-h-64 overflow-y-auto">
|
| 157 |
+
{uploadedFiles.map((uploadedFile, index) => {
|
| 158 |
+
const Icon = getFileIcon(uploadedFile.file.name);
|
| 159 |
+
return (
|
| 160 |
+
<div
|
| 161 |
+
key={index}
|
| 162 |
+
className="p-3 bg-muted rounded-md space-y-2"
|
| 163 |
+
>
|
| 164 |
+
<div className="flex items-center gap-2 group">
|
| 165 |
+
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
| 166 |
+
<div className="flex-1 min-w-0">
|
| 167 |
+
<p className="text-sm truncate">{uploadedFile.file.name}</p>
|
| 168 |
+
<p className="text-xs text-muted-foreground">
|
| 169 |
+
{formatFileSize(uploadedFile.file.size)}
|
| 170 |
+
</p>
|
| 171 |
+
</div>
|
| 172 |
+
<Button
|
| 173 |
+
variant="ghost"
|
| 174 |
+
size="icon"
|
| 175 |
+
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
| 176 |
+
onClick={(e) => {
|
| 177 |
+
e.stopPropagation();
|
| 178 |
+
onRemoveFile(index);
|
| 179 |
+
}}
|
| 180 |
+
>
|
| 181 |
+
<X className="h-3 w-3" />
|
| 182 |
+
</Button>
|
| 183 |
+
</div>
|
| 184 |
+
<div className="space-y-1">
|
| 185 |
+
<label className="text-xs text-muted-foreground">File Type</label>
|
| 186 |
+
<Select
|
| 187 |
+
value={uploadedFile.type}
|
| 188 |
+
onValueChange={(value) => onFileTypeChange(index, value as FileType)}
|
| 189 |
+
>
|
| 190 |
+
<SelectTrigger className="h-8 text-xs">
|
| 191 |
+
<SelectValue />
|
| 192 |
+
</SelectTrigger>
|
| 193 |
+
<SelectContent>
|
| 194 |
+
<SelectItem value="syllabus">Syllabus</SelectItem>
|
| 195 |
+
<SelectItem value="lecture-slides">Lecture Slides / PPT</SelectItem>
|
| 196 |
+
<SelectItem value="literature-review">Literature Review / Paper</SelectItem>
|
| 197 |
+
<SelectItem value="other">Other Course Document</SelectItem>
|
| 198 |
+
</SelectContent>
|
| 199 |
+
</Select>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
);
|
| 203 |
+
})}
|
| 204 |
+
</div>
|
| 205 |
+
)}
|
| 206 |
+
|
| 207 |
+
{/* Type Selection Dialog */}
|
| 208 |
+
{showTypeDialog && (
|
| 209 |
+
<Dialog open={showTypeDialog} onOpenChange={setShowTypeDialog}>
|
| 210 |
+
<DialogContent className="sm:max-w-[425px]">
|
| 211 |
+
<DialogHeader>
|
| 212 |
+
<DialogTitle>Select File Types</DialogTitle>
|
| 213 |
+
<DialogDescription>
|
| 214 |
+
Please select the type for each file you are uploading.
|
| 215 |
+
</DialogDescription>
|
| 216 |
+
</DialogHeader>
|
| 217 |
+
<div className="space-y-3 max-h-64 overflow-y-auto">
|
| 218 |
+
{pendingFiles.map((pendingFile, index) => {
|
| 219 |
+
const Icon = getFileIcon(pendingFile.file.name);
|
| 220 |
+
return (
|
| 221 |
+
<div
|
| 222 |
+
key={index}
|
| 223 |
+
className="p-3 bg-muted rounded-md space-y-2"
|
| 224 |
+
>
|
| 225 |
+
<div className="flex items-center gap-2 group">
|
| 226 |
+
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
| 227 |
+
<div className="flex-1 min-w-0">
|
| 228 |
+
<p className="text-sm truncate">{pendingFile.file.name}</p>
|
| 229 |
+
<p className="text-xs text-muted-foreground">
|
| 230 |
+
{formatFileSize(pendingFile.file.size)}
|
| 231 |
+
</p>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
<div className="space-y-1">
|
| 235 |
+
<label className="text-xs text-muted-foreground">File Type</label>
|
| 236 |
+
<Select
|
| 237 |
+
value={pendingFile.type}
|
| 238 |
+
onValueChange={(value) => handlePendingFileTypeChange(index, value as FileType)}
|
| 239 |
+
>
|
| 240 |
+
<SelectTrigger className="h-8 text-xs">
|
| 241 |
+
<SelectValue />
|
| 242 |
+
</SelectTrigger>
|
| 243 |
+
<SelectContent>
|
| 244 |
+
<SelectItem value="syllabus">Syllabus</SelectItem>
|
| 245 |
+
<SelectItem value="lecture-slides">Lecture Slides / PPT</SelectItem>
|
| 246 |
+
<SelectItem value="literature-review">Literature Review / Paper</SelectItem>
|
| 247 |
+
<SelectItem value="other">Other Course Document</SelectItem>
|
| 248 |
+
</SelectContent>
|
| 249 |
+
</Select>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
);
|
| 253 |
+
})}
|
| 254 |
+
</div>
|
| 255 |
+
<DialogFooter>
|
| 256 |
+
<Button
|
| 257 |
+
variant="outline"
|
| 258 |
+
onClick={handleCancelUpload}
|
| 259 |
+
>
|
| 260 |
+
Cancel
|
| 261 |
+
</Button>
|
| 262 |
+
<Button
|
| 263 |
+
onClick={handleConfirmUpload}
|
| 264 |
+
>
|
| 265 |
+
Upload
|
| 266 |
+
</Button>
|
| 267 |
+
</DialogFooter>
|
| 268 |
+
</DialogContent>
|
| 269 |
+
</Dialog>
|
| 270 |
+
)}
|
| 271 |
+
</Card>
|
| 272 |
+
);
|
| 273 |
+
}
|
web/src/components/FloatingActionButtons.tsx
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Button } from './ui/button';
|
| 3 |
+
import { Download, ClipboardList, Sparkles } from 'lucide-react';
|
| 4 |
+
import { toast } from 'sonner@2.0.3';
|
| 5 |
+
import type { User } from '../App';
|
| 6 |
+
|
| 7 |
+
interface FloatingActionButtonsProps {
|
| 8 |
+
user: User | null;
|
| 9 |
+
isLoggedIn: boolean;
|
| 10 |
+
onOpenPanel: () => void;
|
| 11 |
+
onExport: () => void;
|
| 12 |
+
onQuiz: () => void;
|
| 13 |
+
onSummary: () => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function FloatingActionButtons({
|
| 17 |
+
user,
|
| 18 |
+
isLoggedIn,
|
| 19 |
+
onOpenPanel,
|
| 20 |
+
onExport,
|
| 21 |
+
onQuiz,
|
| 22 |
+
onSummary,
|
| 23 |
+
}: FloatingActionButtonsProps) {
|
| 24 |
+
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
|
| 25 |
+
|
| 26 |
+
const handleAction = (action: () => void, actionName: string, shouldOpenPanel: boolean = false) => {
|
| 27 |
+
if (!isLoggedIn) {
|
| 28 |
+
toast.error('Please log in to use this feature');
|
| 29 |
+
return;
|
| 30 |
+
}
|
| 31 |
+
action();
|
| 32 |
+
if (shouldOpenPanel) {
|
| 33 |
+
onOpenPanel();
|
| 34 |
+
}
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
const buttons = [
|
| 38 |
+
{
|
| 39 |
+
id: 'export',
|
| 40 |
+
icon: Download,
|
| 41 |
+
label: 'Export Conversation',
|
| 42 |
+
action: onExport,
|
| 43 |
+
openPanel: true, // Open panel for export
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
id: 'quiz',
|
| 47 |
+
icon: ClipboardList,
|
| 48 |
+
label: "Let's Try (Micro-Quiz)",
|
| 49 |
+
action: onQuiz,
|
| 50 |
+
openPanel: false, // Don't open panel for quiz
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
id: 'summary',
|
| 54 |
+
icon: Sparkles,
|
| 55 |
+
label: 'Summarization',
|
| 56 |
+
action: onSummary,
|
| 57 |
+
openPanel: true, // Open panel for summary
|
| 58 |
+
},
|
| 59 |
+
];
|
| 60 |
+
|
| 61 |
+
return (
|
| 62 |
+
<div className="fixed right-4 bottom-[28rem] z-40 flex flex-col gap-2">
|
| 63 |
+
{buttons.map((button, index) => {
|
| 64 |
+
const Icon = button.icon;
|
| 65 |
+
const isHovered = hoveredButton === button.id;
|
| 66 |
+
|
| 67 |
+
return (
|
| 68 |
+
<div
|
| 69 |
+
key={button.id}
|
| 70 |
+
className="relative group"
|
| 71 |
+
onMouseEnter={() => setHoveredButton(button.id)}
|
| 72 |
+
onMouseLeave={() => setHoveredButton(null)}
|
| 73 |
+
>
|
| 74 |
+
{/* Tooltip */}
|
| 75 |
+
<div
|
| 76 |
+
className={`
|
| 77 |
+
absolute right-full mr-3 top-1/2 -translate-y-1/2
|
| 78 |
+
px-3 py-2 rounded-lg bg-popover border border-border
|
| 79 |
+
whitespace-nowrap text-sm shadow-lg
|
| 80 |
+
transition-all duration-200
|
| 81 |
+
${isHovered ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-2 pointer-events-none'}
|
| 82 |
+
`}
|
| 83 |
+
>
|
| 84 |
+
{button.label}
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
{/* Floating Button */}
|
| 88 |
+
<Button
|
| 89 |
+
size="icon"
|
| 90 |
+
className={`
|
| 91 |
+
h-6 w-6 rounded-full shadow-md opacity-60 hover:opacity-100
|
| 92 |
+
transition-all duration-200
|
| 93 |
+
${isLoggedIn
|
| 94 |
+
? 'bg-primary hover:bg-primary/90 text-primary-foreground'
|
| 95 |
+
: 'bg-muted hover:bg-muted/90 text-muted-foreground'
|
| 96 |
+
}
|
| 97 |
+
${isHovered ? 'scale-110' : 'scale-100'}
|
| 98 |
+
`}
|
| 99 |
+
onClick={() => handleAction(button.action, button.label, button.openPanel)}
|
| 100 |
+
style={{
|
| 101 |
+
animationDelay: `${index * 100}ms`,
|
| 102 |
+
}}
|
| 103 |
+
>
|
| 104 |
+
<Icon className="h-3 w-3" />
|
| 105 |
+
</Button>
|
| 106 |
+
</div>
|
| 107 |
+
);
|
| 108 |
+
})}
|
| 109 |
+
</div>
|
| 110 |
+
);
|
| 111 |
+
}
|
web/src/components/GroupMembers.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Users, Bot } from 'lucide-react';
|
| 3 |
+
import { Badge } from './ui/badge';
|
| 4 |
+
import type { GroupMember } from '../App';
|
| 5 |
+
|
| 6 |
+
interface GroupMembersProps {
|
| 7 |
+
members: GroupMember[];
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export function GroupMembers({ members }: GroupMembersProps) {
|
| 11 |
+
return (
|
| 12 |
+
<div className="space-y-3">
|
| 13 |
+
<div className="flex items-center gap-2">
|
| 14 |
+
<Users className="h-4 w-4 text-muted-foreground" />
|
| 15 |
+
<h3 className="text-sm">Group Members ({members.length})</h3>
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
<div className="space-y-2">
|
| 19 |
+
{members.map((member) => (
|
| 20 |
+
<div
|
| 21 |
+
key={member.id}
|
| 22 |
+
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 transition-colors"
|
| 23 |
+
>
|
| 24 |
+
{/* Avatar */}
|
| 25 |
+
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
| 26 |
+
member.isAI
|
| 27 |
+
? 'bg-gradient-to-br from-purple-500 to-blue-500'
|
| 28 |
+
: 'bg-muted'
|
| 29 |
+
}`}>
|
| 30 |
+
{member.isAI ? (
|
| 31 |
+
<Bot className="h-4 w-4 text-white" />
|
| 32 |
+
) : (
|
| 33 |
+
<span className="text-sm">
|
| 34 |
+
{member.name.split(' ').map(n => n[0]).join('').toUpperCase()}
|
| 35 |
+
</span>
|
| 36 |
+
)}
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
{/* Member Info */}
|
| 40 |
+
<div className="flex-1 min-w-0">
|
| 41 |
+
<div className="flex items-center gap-2">
|
| 42 |
+
<p className="text-sm truncate">{member.name}</p>
|
| 43 |
+
{member.isAI && (
|
| 44 |
+
<Badge variant="secondary" className="text-xs">AI</Badge>
|
| 45 |
+
)}
|
| 46 |
+
</div>
|
| 47 |
+
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
{/* Online Status */}
|
| 51 |
+
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" title="Online" />
|
| 52 |
+
</div>
|
| 53 |
+
))}
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
);
|
| 57 |
+
}
|
web/src/components/Header.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Menu, User, BookOpen, Moon, Sun } from 'lucide-react';
|
| 3 |
+
import { Button } from './ui/button';
|
| 4 |
+
import { Badge } from './ui/badge';
|
| 5 |
+
import type { User as UserType } from '../App';
|
| 6 |
+
|
| 7 |
+
interface HeaderProps {
|
| 8 |
+
user: UserType | null;
|
| 9 |
+
onMenuClick: () => void;
|
| 10 |
+
onUserClick: () => void;
|
| 11 |
+
isDarkMode: boolean;
|
| 12 |
+
onToggleDarkMode: () => void;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function Header({ user, onMenuClick, onUserClick, isDarkMode, onToggleDarkMode }: HeaderProps) {
|
| 16 |
+
return (
|
| 17 |
+
<header className="h-16 border-b border-border bg-card px-4 lg:px-6 flex items-center justify-between sticky top-0 z-[60]">
|
| 18 |
+
<div className="flex items-center gap-4">
|
| 19 |
+
<Button
|
| 20 |
+
variant="ghost"
|
| 21 |
+
size="icon"
|
| 22 |
+
className="lg:hidden"
|
| 23 |
+
onClick={onMenuClick}
|
| 24 |
+
>
|
| 25 |
+
<Menu className="h-5 w-5" />
|
| 26 |
+
</Button>
|
| 27 |
+
|
| 28 |
+
<div className="flex items-center gap-3">
|
| 29 |
+
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center">
|
| 30 |
+
<BookOpen className="h-6 w-6 text-white" />
|
| 31 |
+
</div>
|
| 32 |
+
<div>
|
| 33 |
+
<h1 className="text-lg sm:text-xl tracking-tight">
|
| 34 |
+
Clare <span className="text-sm font-bold text-muted-foreground hidden sm:inline ml-2">Your Personalized AI Tutor</span>
|
| 35 |
+
</h1>
|
| 36 |
+
<p className="text-xs text-muted-foreground hidden sm:block">
|
| 37 |
+
Personalized guidance, review, and intelligent reinforcement
|
| 38 |
+
</p>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<div className="flex items-center gap-2">
|
| 44 |
+
<Badge variant="secondary" className="hidden md:flex">
|
| 45 |
+
Module 10 – Responsible AI
|
| 46 |
+
</Badge>
|
| 47 |
+
|
| 48 |
+
<Button
|
| 49 |
+
variant="ghost"
|
| 50 |
+
size="icon"
|
| 51 |
+
onClick={onToggleDarkMode}
|
| 52 |
+
aria-label="Toggle dark mode"
|
| 53 |
+
>
|
| 54 |
+
{isDarkMode ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
| 55 |
+
</Button>
|
| 56 |
+
|
| 57 |
+
{user ? (
|
| 58 |
+
<Button
|
| 59 |
+
variant="outline"
|
| 60 |
+
className="gap-2"
|
| 61 |
+
onClick={onUserClick}
|
| 62 |
+
>
|
| 63 |
+
<User className="h-4 w-4" />
|
| 64 |
+
<span className="hidden sm:inline">{user.name}</span>
|
| 65 |
+
</Button>
|
| 66 |
+
) : (
|
| 67 |
+
<Button
|
| 68 |
+
variant="default"
|
| 69 |
+
className="gap-2 lg:hidden"
|
| 70 |
+
onClick={onUserClick}
|
| 71 |
+
>
|
| 72 |
+
<User className="h-4 w-4" />
|
| 73 |
+
<span>Login</span>
|
| 74 |
+
</Button>
|
| 75 |
+
)}
|
| 76 |
+
</div>
|
| 77 |
+
</header>
|
| 78 |
+
);
|
| 79 |
+
}
|
web/src/components/LearningModeSelector.tsx
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Card } from './ui/card';
|
| 3 |
+
import {
|
| 4 |
+
Lightbulb,
|
| 5 |
+
MessageCircleQuestion,
|
| 6 |
+
GraduationCap,
|
| 7 |
+
FileEdit,
|
| 8 |
+
Zap
|
| 9 |
+
} from 'lucide-react';
|
| 10 |
+
import type { LearningMode } from '../App';
|
| 11 |
+
|
| 12 |
+
interface ModeSelectorProps {
|
| 13 |
+
selectedMode: LearningMode;
|
| 14 |
+
onModeChange: (mode: LearningMode) => void;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const modes = [
|
| 18 |
+
{
|
| 19 |
+
id: 'concept' as LearningMode,
|
| 20 |
+
icon: Lightbulb,
|
| 21 |
+
title: 'Concept Explainer',
|
| 22 |
+
description: 'Break down complex topics',
|
| 23 |
+
color: 'from-blue-500 to-blue-600',
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
id: 'socratic' as LearningMode,
|
| 27 |
+
icon: MessageCircleQuestion,
|
| 28 |
+
title: 'Socratic Tutor',
|
| 29 |
+
description: 'Learn through questions',
|
| 30 |
+
color: 'from-purple-500 to-purple-600',
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
id: 'exam' as LearningMode,
|
| 34 |
+
icon: GraduationCap,
|
| 35 |
+
title: 'Exam Prep/Quiz',
|
| 36 |
+
description: 'Test your knowledge',
|
| 37 |
+
color: 'from-green-500 to-green-600',
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
id: 'assignment' as LearningMode,
|
| 41 |
+
icon: FileEdit,
|
| 42 |
+
title: 'Assignment Helper',
|
| 43 |
+
description: 'Get homework guidance',
|
| 44 |
+
color: 'from-orange-500 to-orange-600',
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
id: 'summary' as LearningMode,
|
| 48 |
+
icon: Zap,
|
| 49 |
+
title: 'Quick Summary',
|
| 50 |
+
description: 'Fast key points review',
|
| 51 |
+
color: 'from-pink-500 to-pink-600',
|
| 52 |
+
},
|
| 53 |
+
];
|
| 54 |
+
|
| 55 |
+
export function LearningModeSelector({ selectedMode, onModeChange }: ModeSelectorProps) {
|
| 56 |
+
return (
|
| 57 |
+
<div className="space-y-2">
|
| 58 |
+
{modes.map((mode) => {
|
| 59 |
+
const Icon = mode.icon;
|
| 60 |
+
const isSelected = selectedMode === mode.id;
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<Card
|
| 64 |
+
key={mode.id}
|
| 65 |
+
className={`
|
| 66 |
+
p-3 cursor-pointer transition-all duration-200
|
| 67 |
+
${isSelected
|
| 68 |
+
? 'border-primary bg-accent shadow-sm'
|
| 69 |
+
: 'hover:border-primary/50 hover:shadow-sm'
|
| 70 |
+
}
|
| 71 |
+
`}
|
| 72 |
+
onClick={() => onModeChange(mode.id)}
|
| 73 |
+
>
|
| 74 |
+
<div className="flex items-start gap-3">
|
| 75 |
+
<div className={`
|
| 76 |
+
w-10 h-10 rounded-lg bg-gradient-to-br ${mode.color}
|
| 77 |
+
flex items-center justify-center flex-shrink-0
|
| 78 |
+
`}>
|
| 79 |
+
<Icon className="h-5 w-5 text-white" />
|
| 80 |
+
</div>
|
| 81 |
+
<div className="flex-1 min-w-0">
|
| 82 |
+
<h4 className="text-sm mb-1">{mode.title}</h4>
|
| 83 |
+
<p className="text-xs text-muted-foreground">{mode.description}</p>
|
| 84 |
+
</div>
|
| 85 |
+
{isSelected && (
|
| 86 |
+
<div className="w-2 h-2 rounded-full bg-primary flex-shrink-0 mt-2" />
|
| 87 |
+
)}
|
| 88 |
+
</div>
|
| 89 |
+
</Card>
|
| 90 |
+
);
|
| 91 |
+
})}
|
| 92 |
+
</div>
|
| 93 |
+
);
|
| 94 |
+
}
|
web/src/components/LeftSidebar.tsx
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
| 3 |
+
import { LearningModeSelector } from './LearningModeSelector';
|
| 4 |
+
import { UserGuide } from './UserGuide';
|
| 5 |
+
import { RadioGroup, RadioGroupItem } from './ui/radio-group';
|
| 6 |
+
import { Label } from './ui/label';
|
| 7 |
+
import { Button } from './ui/button';
|
| 8 |
+
import { RotateCcw, Settings, User, Users } from 'lucide-react';
|
| 9 |
+
import { Separator } from './ui/separator';
|
| 10 |
+
import { GroupMembers } from './GroupMembers';
|
| 11 |
+
import type { LearningMode, Language, SpaceType, GroupMember } from '../App';
|
| 12 |
+
|
| 13 |
+
interface LeftSidebarProps {
|
| 14 |
+
learningMode: LearningMode;
|
| 15 |
+
language: Language;
|
| 16 |
+
onLearningModeChange: (mode: LearningMode) => void;
|
| 17 |
+
onLanguageChange: (lang: Language) => void;
|
| 18 |
+
spaceType: SpaceType;
|
| 19 |
+
onSpaceTypeChange: (type: SpaceType) => void;
|
| 20 |
+
groupMembers: GroupMember[];
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function LeftSidebar({
|
| 24 |
+
learningMode,
|
| 25 |
+
language,
|
| 26 |
+
onLearningModeChange,
|
| 27 |
+
onLanguageChange,
|
| 28 |
+
spaceType,
|
| 29 |
+
onSpaceTypeChange,
|
| 30 |
+
groupMembers,
|
| 31 |
+
}: LeftSidebarProps) {
|
| 32 |
+
return (
|
| 33 |
+
<div className="flex-1 overflow-auto">
|
| 34 |
+
{/* Space Selector */}
|
| 35 |
+
<div className="p-4 border-b border-border space-y-3">
|
| 36 |
+
<Label>Workspace</Label>
|
| 37 |
+
<RadioGroup value={spaceType} onValueChange={(value) => onSpaceTypeChange(value as SpaceType)}>
|
| 38 |
+
<div className="flex items-center space-x-2 p-2 rounded-lg hover:bg-muted/50 transition-colors">
|
| 39 |
+
<RadioGroupItem value="individual" id="individual" />
|
| 40 |
+
<Label htmlFor="individual" className="cursor-pointer flex items-center gap-2 flex-1">
|
| 41 |
+
<User className="h-4 w-4" />
|
| 42 |
+
Individual Space
|
| 43 |
+
</Label>
|
| 44 |
+
</div>
|
| 45 |
+
<div className="flex items-center space-x-2 p-2 rounded-lg hover:bg-muted/50 transition-colors">
|
| 46 |
+
<RadioGroupItem value="group" id="group" />
|
| 47 |
+
<Label htmlFor="group" className="cursor-pointer flex items-center gap-2 flex-1">
|
| 48 |
+
<Users className="h-4 w-4" />
|
| 49 |
+
Group Space
|
| 50 |
+
</Label>
|
| 51 |
+
</div>
|
| 52 |
+
</RadioGroup>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
{/* Group Members - Only show in group mode */}
|
| 56 |
+
{spaceType === 'group' && (
|
| 57 |
+
<div className="p-4 border-b border-border">
|
| 58 |
+
<GroupMembers members={groupMembers} />
|
| 59 |
+
</div>
|
| 60 |
+
)}
|
| 61 |
+
|
| 62 |
+
<Tabs defaultValue="settings" className="h-full flex flex-col">
|
| 63 |
+
<div className="px-4 pt-4">
|
| 64 |
+
<TabsList className="grid w-full grid-cols-2">
|
| 65 |
+
<TabsTrigger value="settings">Settings</TabsTrigger>
|
| 66 |
+
<TabsTrigger value="guide">Guide</TabsTrigger>
|
| 67 |
+
</TabsList>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<TabsContent value="settings" className="flex-1 mt-0 p-4 space-y-6">
|
| 71 |
+
<div className="space-y-4">
|
| 72 |
+
<div>
|
| 73 |
+
<h3>Model Settings</h3>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<div className="space-y-2">
|
| 77 |
+
<Label>Model</Label>
|
| 78 |
+
<div className="px-3 py-2 bg-muted rounded-md">
|
| 79 |
+
<code className="text-sm">gpt-4.1-mini</code>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<div className="space-y-2">
|
| 84 |
+
<Label>Language</Label>
|
| 85 |
+
<RadioGroup value={language} onValueChange={(value) => onLanguageChange(value as Language)}>
|
| 86 |
+
<div className="flex items-center space-x-2">
|
| 87 |
+
<RadioGroupItem value="auto" id="auto" />
|
| 88 |
+
<Label htmlFor="auto" className="cursor-pointer">Auto</Label>
|
| 89 |
+
</div>
|
| 90 |
+
<div className="flex items-center space-x-2">
|
| 91 |
+
<RadioGroupItem value="en" id="en" />
|
| 92 |
+
<Label htmlFor="en" className="cursor-pointer">English</Label>
|
| 93 |
+
</div>
|
| 94 |
+
<div className="flex items-center space-x-2">
|
| 95 |
+
<RadioGroupItem value="zh" id="zh" />
|
| 96 |
+
<Label htmlFor="zh" className="cursor-pointer">简体中文</Label>
|
| 97 |
+
</div>
|
| 98 |
+
</RadioGroup>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<Separator />
|
| 103 |
+
|
| 104 |
+
<div className="space-y-3">
|
| 105 |
+
<Button variant="outline" className="w-full gap-2" disabled>
|
| 106 |
+
<Settings className="h-4 w-4" />
|
| 107 |
+
System Settings
|
| 108 |
+
</Button>
|
| 109 |
+
<p className="text-xs text-muted-foreground text-center">
|
| 110 |
+
© 2025 Clare AI Teaching Assistant
|
| 111 |
+
</p>
|
| 112 |
+
</div>
|
| 113 |
+
</TabsContent>
|
| 114 |
+
|
| 115 |
+
<TabsContent value="guide" className="flex-1 mt-0 p-4">
|
| 116 |
+
<UserGuide />
|
| 117 |
+
</TabsContent>
|
| 118 |
+
</Tabs>
|
| 119 |
+
</div>
|
| 120 |
+
);
|
| 121 |
+
}
|
web/src/components/MemoryLine.tsx
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Card } from './ui/card';
|
| 3 |
+
import { Button } from './ui/button';
|
| 4 |
+
import { Progress } from './ui/progress';
|
| 5 |
+
import { Brain, Calendar, Download, Info } from 'lucide-react';
|
| 6 |
+
import {
|
| 7 |
+
Tooltip,
|
| 8 |
+
TooltipContent,
|
| 9 |
+
TooltipProvider,
|
| 10 |
+
TooltipTrigger,
|
| 11 |
+
} from './ui/tooltip';
|
| 12 |
+
import { Badge } from './ui/badge';
|
| 13 |
+
|
| 14 |
+
interface MemoryLineProps {
|
| 15 |
+
progress: number;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function MemoryLine({ progress }: MemoryLineProps) {
|
| 19 |
+
const nextReview = progress < 25 ? 'T+7' : progress < 50 ? 'T+14' : progress < 75 ? 'T+30' : 'Complete';
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<Card className="p-4 space-y-3">
|
| 23 |
+
<div className="flex items-center justify-between">
|
| 24 |
+
<div className="flex items-center gap-2">
|
| 25 |
+
<Brain className="h-4 w-4 text-purple-500" />
|
| 26 |
+
<h4 className="text-sm">Memory Line</h4>
|
| 27 |
+
<TooltipProvider>
|
| 28 |
+
<Tooltip>
|
| 29 |
+
<TooltipTrigger asChild>
|
| 30 |
+
<Button variant="ghost" size="icon" className="h-5 w-5">
|
| 31 |
+
<Info className="h-3 w-3" />
|
| 32 |
+
</Button>
|
| 33 |
+
</TooltipTrigger>
|
| 34 |
+
<TooltipContent className="max-w-xs">
|
| 35 |
+
<p className="text-sm">
|
| 36 |
+
Track your learning retention using spaced repetition. Regular reviews help solidify understanding!
|
| 37 |
+
</p>
|
| 38 |
+
</TooltipContent>
|
| 39 |
+
</Tooltip>
|
| 40 |
+
</TooltipProvider>
|
| 41 |
+
</div>
|
| 42 |
+
<Badge variant="outline" className="text-xs">
|
| 43 |
+
{progress}%
|
| 44 |
+
</Badge>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
{/* Progress Bar with Milestones */}
|
| 48 |
+
<div className="space-y-2">
|
| 49 |
+
<div className="relative">
|
| 50 |
+
<Progress value={progress} className="h-2" />
|
| 51 |
+
|
| 52 |
+
{/* Milestone Markers */}
|
| 53 |
+
<div className="absolute top-0 left-0 w-full h-2 flex justify-between pointer-events-none">
|
| 54 |
+
{[0, 25, 50, 75, 100].map((milestone) => (
|
| 55 |
+
<div
|
| 56 |
+
key={milestone}
|
| 57 |
+
className={`
|
| 58 |
+
w-3 h-3 rounded-full border-2 -mt-0.5
|
| 59 |
+
${progress >= milestone
|
| 60 |
+
? 'bg-primary border-primary'
|
| 61 |
+
: 'bg-background border-border'
|
| 62 |
+
}
|
| 63 |
+
`}
|
| 64 |
+
/>
|
| 65 |
+
))}
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
{/* Milestone Labels */}
|
| 70 |
+
<div className="flex justify-between text-xs text-muted-foreground">
|
| 71 |
+
<span>T+0</span>
|
| 72 |
+
<span>T+7</span>
|
| 73 |
+
<span>T+14</span>
|
| 74 |
+
<span>T+30</span>
|
| 75 |
+
<span>Done</span>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
{/* Actions */}
|
| 80 |
+
<div className="flex items-center gap-2">
|
| 81 |
+
<Button variant="outline" size="sm" className="flex-1 gap-2">
|
| 82 |
+
<Calendar className="h-3 w-3" />
|
| 83 |
+
Next: {nextReview}
|
| 84 |
+
</Button>
|
| 85 |
+
<TooltipProvider>
|
| 86 |
+
<Tooltip>
|
| 87 |
+
<TooltipTrigger asChild>
|
| 88 |
+
<Button variant="ghost" size="sm" className="gap-2">
|
| 89 |
+
<Download className="h-3 w-3" />
|
| 90 |
+
Report
|
| 91 |
+
</Button>
|
| 92 |
+
</TooltipTrigger>
|
| 93 |
+
<TooltipContent>
|
| 94 |
+
<p>Download your learning progress report</p>
|
| 95 |
+
</TooltipContent>
|
| 96 |
+
</Tooltip>
|
| 97 |
+
</TooltipProvider>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
{/* Review Explanation */}
|
| 101 |
+
<div className="pt-2 border-t border-border">
|
| 102 |
+
<p className="text-xs text-muted-foreground">
|
| 103 |
+
Based on spaced repetition for optimal retention
|
| 104 |
+
</p>
|
| 105 |
+
</div>
|
| 106 |
+
</Card>
|
| 107 |
+
);
|
| 108 |
+
}
|
web/src/components/Message.tsx
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Button } from './ui/button';
|
| 3 |
+
import {
|
| 4 |
+
Copy,
|
| 5 |
+
ThumbsUp,
|
| 6 |
+
ThumbsDown,
|
| 7 |
+
ChevronDown,
|
| 8 |
+
ChevronUp,
|
| 9 |
+
Check,
|
| 10 |
+
Bot
|
| 11 |
+
} from 'lucide-react';
|
| 12 |
+
import { Badge } from './ui/badge';
|
| 13 |
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
| 14 |
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
| 15 |
+
import { Textarea } from './ui/textarea';
|
| 16 |
+
import { Label } from './ui/label';
|
| 17 |
+
import type { Message as MessageType } from '../App';
|
| 18 |
+
import { toast } from 'sonner@2.0.3';
|
| 19 |
+
|
| 20 |
+
interface MessageProps {
|
| 21 |
+
message: MessageType;
|
| 22 |
+
showSenderInfo?: boolean; // For group chat mode
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export function Message({ message, showSenderInfo = false }: MessageProps) {
|
| 26 |
+
const [feedback, setFeedback] = useState<'helpful' | 'not-helpful' | null>(null);
|
| 27 |
+
const [copied, setCopied] = useState(false);
|
| 28 |
+
const [referencesOpen, setReferencesOpen] = useState(false);
|
| 29 |
+
const [showFeedbackDialog, setShowFeedbackDialog] = useState(false);
|
| 30 |
+
const [feedbackType, setFeedbackType] = useState<'helpful' | 'not-helpful' | null>(null);
|
| 31 |
+
const [feedbackText, setFeedbackText] = useState('');
|
| 32 |
+
|
| 33 |
+
const isUser = message.role === 'user';
|
| 34 |
+
|
| 35 |
+
const handleCopy = async () => {
|
| 36 |
+
await navigator.clipboard.writeText(message.content);
|
| 37 |
+
setCopied(true);
|
| 38 |
+
toast.success('Message copied to clipboard');
|
| 39 |
+
setTimeout(() => setCopied(false), 2000);
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
const handleFeedback = (type: 'helpful' | 'not-helpful') => {
|
| 43 |
+
setFeedback(type);
|
| 44 |
+
toast.success(`Thanks for your feedback!`);
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const handleFeedbackDialogOpen = (type: 'helpful' | 'not-helpful') => {
|
| 48 |
+
setFeedbackType(type);
|
| 49 |
+
setShowFeedbackDialog(true);
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const handleFeedbackDialogClose = () => {
|
| 53 |
+
setShowFeedbackDialog(false);
|
| 54 |
+
setFeedbackType(null);
|
| 55 |
+
setFeedbackText('');
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
const handleFeedbackDialogSubmit = () => {
|
| 59 |
+
if (feedbackType) {
|
| 60 |
+
setFeedback(feedbackType);
|
| 61 |
+
toast.success(`Thanks for your feedback!`);
|
| 62 |
+
}
|
| 63 |
+
handleFeedbackDialogClose();
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
return (
|
| 67 |
+
<div className={`flex gap-3 ${isUser && !showSenderInfo ? 'justify-end' : 'justify-start'}`}>
|
| 68 |
+
{/* Avatar */}
|
| 69 |
+
{showSenderInfo && message.sender ? (
|
| 70 |
+
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
| 71 |
+
message.sender.isAI
|
| 72 |
+
? 'bg-gradient-to-br from-purple-500 to-blue-500'
|
| 73 |
+
: 'bg-muted'
|
| 74 |
+
}`}>
|
| 75 |
+
{message.sender.isAI ? (
|
| 76 |
+
<Bot className="h-4 w-4 text-white" />
|
| 77 |
+
) : (
|
| 78 |
+
<span className="text-sm">
|
| 79 |
+
{message.sender.name.split(' ').map(n => n[0]).join('').toUpperCase()}
|
| 80 |
+
</span>
|
| 81 |
+
)}
|
| 82 |
+
</div>
|
| 83 |
+
) : !isUser ? (
|
| 84 |
+
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center flex-shrink-0">
|
| 85 |
+
<span className="text-white text-sm">C</span>
|
| 86 |
+
</div>
|
| 87 |
+
) : null}
|
| 88 |
+
|
| 89 |
+
<div className={`flex flex-col gap-2 max-w-[80%] ${isUser && !showSenderInfo ? 'items-end' : 'items-start'}`}>
|
| 90 |
+
{/* Sender name in group chat */}
|
| 91 |
+
{showSenderInfo && message.sender && (
|
| 92 |
+
<div className="flex items-center gap-2 px-1">
|
| 93 |
+
<span className="text-xs">{message.sender.name}</span>
|
| 94 |
+
{message.sender.isAI && (
|
| 95 |
+
<Badge variant="secondary" className="text-xs h-4 px-1">AI</Badge>
|
| 96 |
+
)}
|
| 97 |
+
</div>
|
| 98 |
+
)}
|
| 99 |
+
|
| 100 |
+
<div
|
| 101 |
+
className={`
|
| 102 |
+
rounded-2xl px-4 py-3
|
| 103 |
+
${isUser && !showSenderInfo
|
| 104 |
+
? 'bg-primary text-primary-foreground'
|
| 105 |
+
: 'bg-muted'
|
| 106 |
+
}
|
| 107 |
+
`}
|
| 108 |
+
>
|
| 109 |
+
<p className="whitespace-pre-wrap">{message.content}</p>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
{/* References */}
|
| 113 |
+
{message.references && message.references.length > 0 && (
|
| 114 |
+
<Collapsible open={referencesOpen} onOpenChange={setReferencesOpen}>
|
| 115 |
+
<CollapsibleTrigger asChild>
|
| 116 |
+
<Button variant="ghost" size="sm" className="gap-1 h-7 text-xs">
|
| 117 |
+
{referencesOpen ? (
|
| 118 |
+
<ChevronUp className="h-3 w-3" />
|
| 119 |
+
) : (
|
| 120 |
+
<ChevronDown className="h-3 w-3" />
|
| 121 |
+
)}
|
| 122 |
+
{message.references.length} {message.references.length === 1 ? 'reference' : 'references'}
|
| 123 |
+
</Button>
|
| 124 |
+
</CollapsibleTrigger>
|
| 125 |
+
<CollapsibleContent className="space-y-1 mt-1">
|
| 126 |
+
{message.references.map((ref, index) => (
|
| 127 |
+
<Badge key={index} variant="outline" className="text-xs">
|
| 128 |
+
{ref}
|
| 129 |
+
</Badge>
|
| 130 |
+
))}
|
| 131 |
+
</CollapsibleContent>
|
| 132 |
+
</Collapsible>
|
| 133 |
+
)}
|
| 134 |
+
|
| 135 |
+
{/* Message Actions */}
|
| 136 |
+
<div className="flex items-center gap-1">
|
| 137 |
+
<Button
|
| 138 |
+
variant="ghost"
|
| 139 |
+
size="sm"
|
| 140 |
+
className="h-7 gap-1"
|
| 141 |
+
onClick={handleCopy}
|
| 142 |
+
>
|
| 143 |
+
{copied ? (
|
| 144 |
+
<>
|
| 145 |
+
<Check className="h-3 w-3" />
|
| 146 |
+
<span className="text-xs">Copied</span>
|
| 147 |
+
</>
|
| 148 |
+
) : (
|
| 149 |
+
<>
|
| 150 |
+
<Copy className="h-3 w-3" />
|
| 151 |
+
<span className="text-xs">Copy</span>
|
| 152 |
+
</>
|
| 153 |
+
)}
|
| 154 |
+
</Button>
|
| 155 |
+
|
| 156 |
+
{!isUser && (
|
| 157 |
+
<>
|
| 158 |
+
<Button
|
| 159 |
+
variant="ghost"
|
| 160 |
+
size="sm"
|
| 161 |
+
className={`h-7 gap-1 ${feedback === 'helpful' ? 'text-green-600' : ''}`}
|
| 162 |
+
onClick={() => handleFeedbackDialogOpen('helpful')}
|
| 163 |
+
>
|
| 164 |
+
<ThumbsUp className="h-3 w-3" />
|
| 165 |
+
<span className="text-xs">Helpful</span>
|
| 166 |
+
</Button>
|
| 167 |
+
<Button
|
| 168 |
+
variant="ghost"
|
| 169 |
+
size="sm"
|
| 170 |
+
className={`h-7 gap-1 ${feedback === 'not-helpful' ? 'text-red-600' : ''}`}
|
| 171 |
+
onClick={() => handleFeedbackDialogOpen('not-helpful')}
|
| 172 |
+
>
|
| 173 |
+
<ThumbsDown className="h-3 w-3" />
|
| 174 |
+
<span className="text-xs">Not helpful</span>
|
| 175 |
+
</Button>
|
| 176 |
+
</>
|
| 177 |
+
)}
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
{isUser && !showSenderInfo && (
|
| 182 |
+
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
|
| 183 |
+
<span className="text-sm">👤</span>
|
| 184 |
+
</div>
|
| 185 |
+
)}
|
| 186 |
+
|
| 187 |
+
{/* Feedback Dialog */}
|
| 188 |
+
<Dialog open={showFeedbackDialog} onOpenChange={handleFeedbackDialogClose}>
|
| 189 |
+
<DialogContent className="sm:max-w-[425px]">
|
| 190 |
+
<DialogHeader>
|
| 191 |
+
<DialogTitle>Provide Feedback</DialogTitle>
|
| 192 |
+
<DialogDescription>
|
| 193 |
+
{feedbackType === 'helpful' ? 'Tell us why you found this message helpful.' : 'Tell us why you found this message not helpful.'}
|
| 194 |
+
</DialogDescription>
|
| 195 |
+
</DialogHeader>
|
| 196 |
+
<Textarea
|
| 197 |
+
className="h-20"
|
| 198 |
+
value={feedbackText}
|
| 199 |
+
onChange={(e) => setFeedbackText(e.target.value)}
|
| 200 |
+
placeholder="Type your feedback here..."
|
| 201 |
+
/>
|
| 202 |
+
<DialogFooter>
|
| 203 |
+
<Button
|
| 204 |
+
type="button"
|
| 205 |
+
variant="outline"
|
| 206 |
+
onClick={handleFeedbackDialogClose}
|
| 207 |
+
>
|
| 208 |
+
Cancel
|
| 209 |
+
</Button>
|
| 210 |
+
<Button
|
| 211 |
+
type="button"
|
| 212 |
+
onClick={handleFeedbackDialogSubmit}
|
| 213 |
+
>
|
| 214 |
+
Submit
|
| 215 |
+
</Button>
|
| 216 |
+
</DialogFooter>
|
| 217 |
+
</DialogContent>
|
| 218 |
+
</Dialog>
|
| 219 |
+
</div>
|
| 220 |
+
);
|
| 221 |
+
}
|
web/src/components/RightPanel.tsx
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Button } from './ui/button';
|
| 3 |
+
import { Input } from './ui/input';
|
| 4 |
+
import { Label } from './ui/label';
|
| 5 |
+
import { Card } from './ui/card';
|
| 6 |
+
import { Separator } from './ui/separator';
|
| 7 |
+
import { Textarea } from './ui/textarea';
|
| 8 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
| 9 |
+
import {
|
| 10 |
+
LogIn,
|
| 11 |
+
LogOut,
|
| 12 |
+
Download,
|
| 13 |
+
ClipboardList,
|
| 14 |
+
FileText,
|
| 15 |
+
Sparkles,
|
| 16 |
+
ChevronUp,
|
| 17 |
+
ChevronDown,
|
| 18 |
+
PanelRightClose,
|
| 19 |
+
MessageSquare
|
| 20 |
+
} from 'lucide-react';
|
| 21 |
+
import type { User } from '../App';
|
| 22 |
+
import { toast } from 'sonner@2.0.3';
|
| 23 |
+
import {
|
| 24 |
+
Dialog,
|
| 25 |
+
DialogContent,
|
| 26 |
+
DialogDescription,
|
| 27 |
+
DialogHeader,
|
| 28 |
+
DialogTitle,
|
| 29 |
+
DialogTrigger,
|
| 30 |
+
DialogFooter,
|
| 31 |
+
} from './ui/dialog';
|
| 32 |
+
|
| 33 |
+
interface RightPanelProps {
|
| 34 |
+
user: User | null;
|
| 35 |
+
onLogin: (user: User) => void;
|
| 36 |
+
onLogout: () => void;
|
| 37 |
+
isLoggedIn: boolean;
|
| 38 |
+
onClose?: () => void;
|
| 39 |
+
exportResult: string;
|
| 40 |
+
setExportResult: (result: string) => void;
|
| 41 |
+
resultType: 'export' | 'quiz' | 'summary' | null;
|
| 42 |
+
setResultType: (type: 'export' | 'quiz' | 'summary' | null) => void;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export function RightPanel({ user, onLogin, onLogout, isLoggedIn, onClose, exportResult, setExportResult, resultType, setResultType }: RightPanelProps) {
|
| 46 |
+
const [showLoginForm, setShowLoginForm] = useState(false);
|
| 47 |
+
const [name, setName] = useState('');
|
| 48 |
+
const [email, setEmail] = useState('');
|
| 49 |
+
const [isExpanded, setIsExpanded] = useState(true);
|
| 50 |
+
const [feedbackDialogOpen, setFeedbackDialogOpen] = useState(false);
|
| 51 |
+
const [feedbackText, setFeedbackText] = useState('');
|
| 52 |
+
const [feedbackCategory, setFeedbackCategory] = useState<'general' | 'bug' | 'feature'>('general');
|
| 53 |
+
|
| 54 |
+
const handleLogin = () => {
|
| 55 |
+
if (!name.trim() || !email.trim()) {
|
| 56 |
+
toast.error('Please fill in all fields');
|
| 57 |
+
return;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
onLogin({ name: name.trim(), email: email.trim() });
|
| 61 |
+
setShowLoginForm(false);
|
| 62 |
+
setName('');
|
| 63 |
+
setEmail('');
|
| 64 |
+
toast.success(`Welcome, ${name}!`);
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const handleLogout = () => {
|
| 68 |
+
onLogout();
|
| 69 |
+
setShowLoginForm(false);
|
| 70 |
+
toast.success('Logged out successfully');
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
const handleExport = () => {
|
| 74 |
+
const result = `# Conversation Export
|
| 75 |
+
Date: ${new Date().toLocaleDateString()}
|
| 76 |
+
Student: ${user?.name}
|
| 77 |
+
|
| 78 |
+
## Summary
|
| 79 |
+
This conversation covered key concepts in Module 10 – Responsible AI, including ethical considerations, fairness, transparency, and accountability in AI systems.
|
| 80 |
+
|
| 81 |
+
## Key Takeaways
|
| 82 |
+
1. Understanding the principles of Responsible AI
|
| 83 |
+
2. Real-world applications and implications
|
| 84 |
+
3. Best practices for ethical AI development
|
| 85 |
+
|
| 86 |
+
Exported successfully! ✓`;
|
| 87 |
+
|
| 88 |
+
setExportResult(result);
|
| 89 |
+
setResultType('export');
|
| 90 |
+
toast.success('Conversation exported!');
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
const handleQuiz = () => {
|
| 94 |
+
const quiz = `# Micro-Quiz: Responsible AI
|
| 95 |
+
|
| 96 |
+
**Question 1:** Which of the following is a key principle of Responsible AI?
|
| 97 |
+
a) Profit maximization
|
| 98 |
+
b) Transparency
|
| 99 |
+
c) Rapid deployment
|
| 100 |
+
d) Cost reduction
|
| 101 |
+
|
| 102 |
+
**Question 2:** What is algorithmic fairness?
|
| 103 |
+
(Short answer expected)
|
| 104 |
+
|
| 105 |
+
**Question 3:** True or False: AI systems should always prioritize accuracy over fairness.
|
| 106 |
+
|
| 107 |
+
Generate quiz based on your conversation!`;
|
| 108 |
+
|
| 109 |
+
setExportResult(quiz);
|
| 110 |
+
setResultType('quiz');
|
| 111 |
+
toast.success('Quiz generated!');
|
| 112 |
+
};
|
| 113 |
+
|
| 114 |
+
const handleSummary = () => {
|
| 115 |
+
const summary = `# Learning Summary
|
| 116 |
+
|
| 117 |
+
## Today's Session
|
| 118 |
+
**Duration:** 25 minutes
|
| 119 |
+
**Topics Covered:** 3
|
| 120 |
+
**Messages Exchanged:** 12
|
| 121 |
+
|
| 122 |
+
## Key Concepts Discussed
|
| 123 |
+
• Principles of Responsible AI
|
| 124 |
+
• Ethical considerations in AI development
|
| 125 |
+
• Fairness and transparency in algorithms
|
| 126 |
+
|
| 127 |
+
## Recommended Next Steps
|
| 128 |
+
1. Review Module 10, Section 2.3
|
| 129 |
+
2. Complete practice quiz on algorithmic fairness
|
| 130 |
+
3. Read additional resources on AI ethics
|
| 131 |
+
|
| 132 |
+
## Progress Update
|
| 133 |
+
You've covered 65% of Module 10 content. Keep up the great work! 🎉`;
|
| 134 |
+
|
| 135 |
+
setExportResult(summary);
|
| 136 |
+
setResultType('summary');
|
| 137 |
+
toast.success('Summary generated!');
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
const handleFeedbackSubmit = () => {
|
| 141 |
+
if (!feedbackText.trim()) {
|
| 142 |
+
toast.error('Please provide feedback text');
|
| 143 |
+
return;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// Here you can add logic to send feedback to your server or handle it as needed
|
| 147 |
+
console.log('Feedback submitted:', feedbackText, feedbackCategory);
|
| 148 |
+
setFeedbackDialogOpen(false);
|
| 149 |
+
setFeedbackText('');
|
| 150 |
+
toast.success('Feedback submitted!');
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
return (
|
| 154 |
+
<div className="flex-1 overflow-auto p-4 space-y-4">
|
| 155 |
+
{isExpanded && (
|
| 156 |
+
<>
|
| 157 |
+
{/* Login Section */}
|
| 158 |
+
<Card className="p-4">
|
| 159 |
+
{!isLoggedIn ? (
|
| 160 |
+
<div className="space-y-4">
|
| 161 |
+
<div className="flex flex-col items-center py-4">
|
| 162 |
+
<img
|
| 163 |
+
src="https://images.unsplash.com/photo-1588912914049-d2664f76a947?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzdHVkZW50JTIwc3R1ZHlpbmclMjBpbGx1c3RyYXRpb258ZW58MXx8fHwxNzY2MDY2NjcyfDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral"
|
| 164 |
+
alt="Student studying"
|
| 165 |
+
className="w-20 h-20 rounded-full object-cover mb-4"
|
| 166 |
+
/>
|
| 167 |
+
<h3 className="mb-2">Welcome to Clare!</h3>
|
| 168 |
+
<p className="text-sm text-muted-foreground text-center mb-4">
|
| 169 |
+
Log in to start your personalized learning journey
|
| 170 |
+
</p>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
{!showLoginForm ? (
|
| 174 |
+
<Button onClick={() => setShowLoginForm(true)} className="w-full gap-2">
|
| 175 |
+
<LogIn className="h-4 w-4" />
|
| 176 |
+
Student Login
|
| 177 |
+
</Button>
|
| 178 |
+
) : (
|
| 179 |
+
<div className="space-y-3">
|
| 180 |
+
<div className="space-y-2">
|
| 181 |
+
<Label htmlFor="name">Name</Label>
|
| 182 |
+
<Input
|
| 183 |
+
id="name"
|
| 184 |
+
value={name}
|
| 185 |
+
onChange={(e) => setName(e.target.value)}
|
| 186 |
+
placeholder="Enter your name"
|
| 187 |
+
/>
|
| 188 |
+
</div>
|
| 189 |
+
<div className="space-y-2">
|
| 190 |
+
<Label htmlFor="email">Email / Student ID</Label>
|
| 191 |
+
<Input
|
| 192 |
+
id="email"
|
| 193 |
+
type="email"
|
| 194 |
+
value={email}
|
| 195 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 196 |
+
placeholder="Enter your email or ID"
|
| 197 |
+
/>
|
| 198 |
+
</div>
|
| 199 |
+
<div className="flex gap-2">
|
| 200 |
+
<Button onClick={handleLogin} className="flex-1">
|
| 201 |
+
Enter
|
| 202 |
+
</Button>
|
| 203 |
+
<Button
|
| 204 |
+
variant="outline"
|
| 205 |
+
onClick={() => setShowLoginForm(false)}
|
| 206 |
+
>
|
| 207 |
+
Cancel
|
| 208 |
+
</Button>
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
)}
|
| 212 |
+
</div>
|
| 213 |
+
) : (
|
| 214 |
+
<div className="space-y-4">
|
| 215 |
+
<div className="flex items-center gap-3">
|
| 216 |
+
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center text-white">
|
| 217 |
+
{user.name.charAt(0).toUpperCase()}
|
| 218 |
+
</div>
|
| 219 |
+
<div className="flex-1 min-w-0">
|
| 220 |
+
<h4 className="truncate">{user.name}</h4>
|
| 221 |
+
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
<Button
|
| 225 |
+
variant="destructive"
|
| 226 |
+
onClick={handleLogout}
|
| 227 |
+
className="w-full gap-2"
|
| 228 |
+
>
|
| 229 |
+
<LogOut className="h-4 w-4" />
|
| 230 |
+
Log Out
|
| 231 |
+
</Button>
|
| 232 |
+
</div>
|
| 233 |
+
)}
|
| 234 |
+
</Card>
|
| 235 |
+
|
| 236 |
+
<Separator />
|
| 237 |
+
|
| 238 |
+
{/* Actions section removed - functionality available via floating buttons */}
|
| 239 |
+
{/* <div className="space-y-3">
|
| 240 |
+
<h3>Actions</h3>
|
| 241 |
+
|
| 242 |
+
<Dialog>
|
| 243 |
+
<DialogTrigger asChild>
|
| 244 |
+
<Button
|
| 245 |
+
variant="outline"
|
| 246 |
+
className="w-full justify-start gap-2"
|
| 247 |
+
disabled={!isLoggedIn}
|
| 248 |
+
onClick={handleExport}
|
| 249 |
+
>
|
| 250 |
+
<Download className="h-4 w-4" />
|
| 251 |
+
Export Conversation
|
| 252 |
+
</Button>
|
| 253 |
+
</DialogTrigger>
|
| 254 |
+
</Dialog>
|
| 255 |
+
|
| 256 |
+
<Dialog>
|
| 257 |
+
<DialogTrigger asChild>
|
| 258 |
+
<Button
|
| 259 |
+
variant="outline"
|
| 260 |
+
className="w-full justify-start gap-2"
|
| 261 |
+
disabled={!isLoggedIn}
|
| 262 |
+
onClick={handleQuiz}
|
| 263 |
+
>
|
| 264 |
+
<ClipboardList className="h-4 w-4" />
|
| 265 |
+
Let's Try (Micro-Quiz)
|
| 266 |
+
</Button>
|
| 267 |
+
</DialogTrigger>
|
| 268 |
+
</Dialog>
|
| 269 |
+
|
| 270 |
+
<Dialog>
|
| 271 |
+
<DialogTrigger asChild>
|
| 272 |
+
<Button
|
| 273 |
+
variant="outline"
|
| 274 |
+
className="w-full justify-start gap-2"
|
| 275 |
+
disabled={!isLoggedIn}
|
| 276 |
+
onClick={handleSummary}
|
| 277 |
+
>
|
| 278 |
+
<Sparkles className="h-4 w-4" />
|
| 279 |
+
Summarization
|
| 280 |
+
</Button>
|
| 281 |
+
</DialogTrigger>
|
| 282 |
+
</Dialog>
|
| 283 |
+
|
| 284 |
+
{!isLoggedIn && (
|
| 285 |
+
<p className="text-xs text-muted-foreground text-center pt-2">
|
| 286 |
+
Log in to unlock all features
|
| 287 |
+
</p>
|
| 288 |
+
)}
|
| 289 |
+
</div> */}
|
| 290 |
+
|
| 291 |
+
<Separator />
|
| 292 |
+
|
| 293 |
+
{/* Results Section */}
|
| 294 |
+
<div className="space-y-3">
|
| 295 |
+
<h3>
|
| 296 |
+
{resultType === 'export' && 'Exported Conversation'}
|
| 297 |
+
{resultType === 'quiz' && 'Micro-Quiz'}
|
| 298 |
+
{resultType === 'summary' && 'Summarization'}
|
| 299 |
+
{!resultType && 'Results'}
|
| 300 |
+
</h3>
|
| 301 |
+
<Card className="p-4 min-h-[200px] bg-muted/30">
|
| 302 |
+
{exportResult ? (
|
| 303 |
+
<div className="space-y-3">
|
| 304 |
+
<div className="flex items-center justify-between">
|
| 305 |
+
<FileText className="h-4 w-4 text-muted-foreground" />
|
| 306 |
+
<Button
|
| 307 |
+
variant="ghost"
|
| 308 |
+
size="sm"
|
| 309 |
+
onClick={() => {
|
| 310 |
+
navigator.clipboard.writeText(exportResult);
|
| 311 |
+
toast.success('Copied to clipboard!');
|
| 312 |
+
}}
|
| 313 |
+
>
|
| 314 |
+
Copy
|
| 315 |
+
</Button>
|
| 316 |
+
</div>
|
| 317 |
+
<div className="text-sm whitespace-pre-wrap text-foreground">
|
| 318 |
+
{exportResult}
|
| 319 |
+
</div>
|
| 320 |
+
</div>
|
| 321 |
+
) : (
|
| 322 |
+
<div className="flex items-center justify-center h-full text-sm text-muted-foreground text-left">
|
| 323 |
+
Results (export / summary / quiz) will appear here after using the actions above
|
| 324 |
+
</div>
|
| 325 |
+
)}
|
| 326 |
+
</Card>
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
+
<Separator />
|
| 330 |
+
|
| 331 |
+
{/* Feedback Section */}
|
| 332 |
+
<div className="space-y-3">
|
| 333 |
+
<h3>Feedback</h3>
|
| 334 |
+
<Button
|
| 335 |
+
variant="outline"
|
| 336 |
+
className="w-full justify-start gap-2"
|
| 337 |
+
onClick={() => setFeedbackDialogOpen(true)}
|
| 338 |
+
>
|
| 339 |
+
<MessageSquare className="h-4 w-4" />
|
| 340 |
+
Provide Feedback
|
| 341 |
+
</Button>
|
| 342 |
+
</div>
|
| 343 |
+
|
| 344 |
+
{/* Feedback Dialog */}
|
| 345 |
+
<Dialog open={feedbackDialogOpen} onOpenChange={setFeedbackDialogOpen}>
|
| 346 |
+
<DialogContent className="sm:max-w-[425px]">
|
| 347 |
+
<DialogHeader>
|
| 348 |
+
<DialogTitle>Provide Feedback</DialogTitle>
|
| 349 |
+
<DialogDescription>
|
| 350 |
+
Help us improve Clare by sharing your thoughts and suggestions.
|
| 351 |
+
</DialogDescription>
|
| 352 |
+
</DialogHeader>
|
| 353 |
+
<div className="space-y-3">
|
| 354 |
+
<div className="space-y-2">
|
| 355 |
+
<Label htmlFor="feedbackCategory">Category</Label>
|
| 356 |
+
<Select value={feedbackCategory} onValueChange={(value) => setFeedbackCategory(value as 'general' | 'bug' | 'feature')}>
|
| 357 |
+
<SelectTrigger>
|
| 358 |
+
<SelectValue placeholder="Select a category" />
|
| 359 |
+
</SelectTrigger>
|
| 360 |
+
<SelectContent>
|
| 361 |
+
<SelectItem value="general">General Feedback</SelectItem>
|
| 362 |
+
<SelectItem value="bug">Bug Report</SelectItem>
|
| 363 |
+
<SelectItem value="feature">Feature Request</SelectItem>
|
| 364 |
+
</SelectContent>
|
| 365 |
+
</Select>
|
| 366 |
+
</div>
|
| 367 |
+
<div className="space-y-2">
|
| 368 |
+
<Label htmlFor="feedbackText">Feedback</Label>
|
| 369 |
+
<Textarea
|
| 370 |
+
id="feedbackText"
|
| 371 |
+
value={feedbackText}
|
| 372 |
+
onChange={(e) => setFeedbackText(e.target.value)}
|
| 373 |
+
placeholder="Enter your feedback here..."
|
| 374 |
+
className="min-h-[100px]"
|
| 375 |
+
/>
|
| 376 |
+
</div>
|
| 377 |
+
</div>
|
| 378 |
+
<DialogFooter>
|
| 379 |
+
<Button
|
| 380 |
+
variant="outline"
|
| 381 |
+
onClick={() => setFeedbackDialogOpen(false)}
|
| 382 |
+
>
|
| 383 |
+
Cancel
|
| 384 |
+
</Button>
|
| 385 |
+
<Button
|
| 386 |
+
variant="primary"
|
| 387 |
+
onClick={handleFeedbackSubmit}
|
| 388 |
+
>
|
| 389 |
+
Submit
|
| 390 |
+
</Button>
|
| 391 |
+
</DialogFooter>
|
| 392 |
+
</DialogContent>
|
| 393 |
+
</Dialog>
|
| 394 |
+
</>
|
| 395 |
+
)}
|
| 396 |
+
</div>
|
| 397 |
+
);
|
| 398 |
+
}
|
web/src/components/UserGuide.tsx
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import {
|
| 3 |
+
Accordion,
|
| 4 |
+
AccordionContent,
|
| 5 |
+
AccordionItem,
|
| 6 |
+
AccordionTrigger,
|
| 7 |
+
} from './ui/accordion';
|
| 8 |
+
|
| 9 |
+
export function UserGuide() {
|
| 10 |
+
return (
|
| 11 |
+
<div className="space-y-3">
|
| 12 |
+
<h3 className="mb-4">User Guide</h3>
|
| 13 |
+
|
| 14 |
+
<Accordion type="single" collapsible className="space-y-2">
|
| 15 |
+
<AccordionItem value="getting-started" className="border rounded-lg px-4">
|
| 16 |
+
<AccordionTrigger>Getting Started</AccordionTrigger>
|
| 17 |
+
<AccordionContent className="text-sm text-muted-foreground space-y-2">
|
| 18 |
+
<p>Welcome to Clare! To begin:</p>
|
| 19 |
+
<ol className="list-decimal list-inside space-y-1 ml-2">
|
| 20 |
+
<li>Log in using your student credentials</li>
|
| 21 |
+
<li>Select your preferred learning mode</li>
|
| 22 |
+
<li>Upload course materials (optional)</li>
|
| 23 |
+
<li>Start asking questions!</li>
|
| 24 |
+
</ol>
|
| 25 |
+
</AccordionContent>
|
| 26 |
+
</AccordionItem>
|
| 27 |
+
|
| 28 |
+
<AccordionItem value="modes" className="border rounded-lg px-4">
|
| 29 |
+
<AccordionTrigger>Learning Modes</AccordionTrigger>
|
| 30 |
+
<AccordionContent className="text-sm text-muted-foreground space-y-2">
|
| 31 |
+
<p><strong>Concept Explainer:</strong> Get detailed explanations of complex topics with examples.</p>
|
| 32 |
+
<p><strong>Socratic Tutor:</strong> Learn through guided questions and discovery.</p>
|
| 33 |
+
<p><strong>Exam Prep/Quiz:</strong> Test your knowledge with practice questions.</p>
|
| 34 |
+
<p><strong>Assignment Helper:</strong> Get step-by-step guidance on homework.</p>
|
| 35 |
+
<p><strong>Quick Summary:</strong> Receive concise summaries of key concepts.</p>
|
| 36 |
+
</AccordionContent>
|
| 37 |
+
</AccordionItem>
|
| 38 |
+
|
| 39 |
+
<AccordionItem value="how-it-works" className="border rounded-lg px-4">
|
| 40 |
+
<AccordionTrigger>How Clare Works</AccordionTrigger>
|
| 41 |
+
<AccordionContent className="text-sm text-muted-foreground space-y-2">
|
| 42 |
+
<p>Clare uses advanced AI to provide personalized tutoring:</p>
|
| 43 |
+
<ul className="list-disc list-inside space-y-1 ml-2">
|
| 44 |
+
<li>Analyzes your questions and learning style</li>
|
| 45 |
+
<li>References uploaded course materials</li>
|
| 46 |
+
<li>Adapts explanations to your level</li>
|
| 47 |
+
<li>Tracks your learning progress over time</li>
|
| 48 |
+
</ul>
|
| 49 |
+
</AccordionContent>
|
| 50 |
+
</AccordionItem>
|
| 51 |
+
|
| 52 |
+
<AccordionItem value="memory-line" className="border rounded-lg px-4">
|
| 53 |
+
<AccordionTrigger>Memory Line</AccordionTrigger>
|
| 54 |
+
<AccordionContent className="text-sm text-muted-foreground space-y-2">
|
| 55 |
+
<p>The Memory Line tracks your retention using spaced repetition:</p>
|
| 56 |
+
<ul className="list-disc list-inside space-y-1 ml-2">
|
| 57 |
+
<li><strong>T+0:</strong> Initial learning</li>
|
| 58 |
+
<li><strong>T+7:</strong> First review (1 week)</li>
|
| 59 |
+
<li><strong>T+14:</strong> Second review (2 weeks)</li>
|
| 60 |
+
<li><strong>T+30:</strong> Final review (1 month)</li>
|
| 61 |
+
</ul>
|
| 62 |
+
<p>Regular reviews help solidify your understanding!</p>
|
| 63 |
+
</AccordionContent>
|
| 64 |
+
</AccordionItem>
|
| 65 |
+
|
| 66 |
+
<AccordionItem value="progress" className="border rounded-lg px-4">
|
| 67 |
+
<AccordionTrigger>Learning Progress Report</AccordionTrigger>
|
| 68 |
+
<AccordionContent className="text-sm text-muted-foreground">
|
| 69 |
+
<p>Your progress report shows:</p>
|
| 70 |
+
<ul className="list-disc list-inside space-y-1 ml-2">
|
| 71 |
+
<li>Topics covered and mastered</li>
|
| 72 |
+
<li>Time spent learning</li>
|
| 73 |
+
<li>Quiz scores and performance</li>
|
| 74 |
+
<li>Recommended review topics</li>
|
| 75 |
+
</ul>
|
| 76 |
+
</AccordionContent>
|
| 77 |
+
</AccordionItem>
|
| 78 |
+
|
| 79 |
+
<AccordionItem value="files" className="border rounded-lg px-4">
|
| 80 |
+
<AccordionTrigger>Using Your Files</AccordionTrigger>
|
| 81 |
+
<AccordionContent className="text-sm text-muted-foreground">
|
| 82 |
+
<p>Upload course materials to enhance Clare's responses:</p>
|
| 83 |
+
<ul className="list-disc list-inside space-y-1 ml-2">
|
| 84 |
+
<li>Supported formats: .docx, .pdf, .pptx</li>
|
| 85 |
+
<li>Clare will reference your materials in answers</li>
|
| 86 |
+
<li>Files are processed securely and privately</li>
|
| 87 |
+
<li>You can remove files anytime</li>
|
| 88 |
+
</ul>
|
| 89 |
+
</AccordionContent>
|
| 90 |
+
</AccordionItem>
|
| 91 |
+
|
| 92 |
+
<AccordionItem value="quiz" className="border rounded-lg px-4">
|
| 93 |
+
<AccordionTrigger>Micro-Quiz</AccordionTrigger>
|
| 94 |
+
<AccordionContent className="text-sm text-muted-foreground">
|
| 95 |
+
<p>Test your understanding with quick quizzes:</p>
|
| 96 |
+
<ul className="list-disc list-inside space-y-1 ml-2">
|
| 97 |
+
<li>Generated based on your conversation</li>
|
| 98 |
+
<li>Multiple choice and short answer questions</li>
|
| 99 |
+
<li>Instant feedback and explanations</li>
|
| 100 |
+
<li>Track your quiz performance over time</li>
|
| 101 |
+
</ul>
|
| 102 |
+
</AccordionContent>
|
| 103 |
+
</AccordionItem>
|
| 104 |
+
|
| 105 |
+
<AccordionItem value="summarization" className="border rounded-lg px-4">
|
| 106 |
+
<AccordionTrigger>Summarization</AccordionTrigger>
|
| 107 |
+
<AccordionContent className="text-sm text-muted-foreground">
|
| 108 |
+
<p>Get concise summaries of your learning session:</p>
|
| 109 |
+
<ul className="list-disc list-inside space-y-1 ml-2">
|
| 110 |
+
<li>Key concepts discussed</li>
|
| 111 |
+
<li>Important takeaways</li>
|
| 112 |
+
<li>Topics to review</li>
|
| 113 |
+
<li>Next steps for learning</li>
|
| 114 |
+
</ul>
|
| 115 |
+
</AccordionContent>
|
| 116 |
+
</AccordionItem>
|
| 117 |
+
|
| 118 |
+
<AccordionItem value="export" className="border rounded-lg px-4">
|
| 119 |
+
<AccordionTrigger>Export Conversation</AccordionTrigger>
|
| 120 |
+
<AccordionContent className="text-sm text-muted-foreground">
|
| 121 |
+
<p>Save your conversation for later review:</p>
|
| 122 |
+
<ul className="list-disc list-inside space-y-1 ml-2">
|
| 123 |
+
<li>Export as PDF or text file</li>
|
| 124 |
+
<li>Includes all messages and references</li>
|
| 125 |
+
<li>Perfect for study notes</li>
|
| 126 |
+
<li>Share with study groups (optional)</li>
|
| 127 |
+
</ul>
|
| 128 |
+
</AccordionContent>
|
| 129 |
+
</AccordionItem>
|
| 130 |
+
|
| 131 |
+
<AccordionItem value="faq" className="border rounded-lg px-4">
|
| 132 |
+
<AccordionTrigger>FAQ</AccordionTrigger>
|
| 133 |
+
<AccordionContent className="text-sm text-muted-foreground space-y-3">
|
| 134 |
+
<div>
|
| 135 |
+
<p className="font-medium text-foreground">Is my data private?</p>
|
| 136 |
+
<p>Yes! Your conversations and files are encrypted and never shared.</p>
|
| 137 |
+
</div>
|
| 138 |
+
<div>
|
| 139 |
+
<p className="font-medium text-foreground">Can Clare do my homework?</p>
|
| 140 |
+
<p>Clare is designed to help you learn, not do work for you. It provides guidance and explanations to help you understand concepts.</p>
|
| 141 |
+
</div>
|
| 142 |
+
<div>
|
| 143 |
+
<p className="font-medium text-foreground">How accurate is Clare?</p>
|
| 144 |
+
<p>Clare is highly accurate but should be used as a learning aid. Always verify important information with course materials.</p>
|
| 145 |
+
</div>
|
| 146 |
+
</AccordionContent>
|
| 147 |
+
</AccordionItem>
|
| 148 |
+
</Accordion>
|
| 149 |
+
</div>
|
| 150 |
+
);
|
| 151 |
+
}
|
web/src/components/figma/ImageWithFallback.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react'
|
| 2 |
+
|
| 3 |
+
const ERROR_IMG_SRC =
|
| 4 |
+
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=='
|
| 5 |
+
|
| 6 |
+
export function ImageWithFallback(props: React.ImgHTMLAttributes<HTMLImageElement>) {
|
| 7 |
+
const [didError, setDidError] = useState(false)
|
| 8 |
+
|
| 9 |
+
const handleError = () => {
|
| 10 |
+
setDidError(true)
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const { src, alt, style, className, ...rest } = props
|
| 14 |
+
|
| 15 |
+
return didError ? (
|
| 16 |
+
<div
|
| 17 |
+
className={`inline-block bg-gray-100 text-center align-middle ${className ?? ''}`}
|
| 18 |
+
style={style}
|
| 19 |
+
>
|
| 20 |
+
<div className="flex items-center justify-center w-full h-full">
|
| 21 |
+
<img src={ERROR_IMG_SRC} alt="Error loading image" {...rest} data-original-url={src} />
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
) : (
|
| 25 |
+
<img src={src} alt={alt} className={className} style={style} {...rest} onError={handleError} />
|
| 26 |
+
)
|
| 27 |
+
}
|
web/src/components/ui/accordion.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as AccordionPrimitive from "@radix-ui/react-accordion@1.2.3";
|
| 5 |
+
import { ChevronDownIcon } from "lucide-react@0.487.0";
|
| 6 |
+
|
| 7 |
+
import { cn } from "./utils";
|
| 8 |
+
|
| 9 |
+
function Accordion({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
| 12 |
+
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function AccordionItem({
|
| 16 |
+
className,
|
| 17 |
+
...props
|
| 18 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
| 19 |
+
return (
|
| 20 |
+
<AccordionPrimitive.Item
|
| 21 |
+
data-slot="accordion-item"
|
| 22 |
+
className={cn("border-b last:border-b-0", className)}
|
| 23 |
+
{...props}
|
| 24 |
+
/>
|
| 25 |
+
);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function AccordionTrigger({
|
| 29 |
+
className,
|
| 30 |
+
children,
|
| 31 |
+
...props
|
| 32 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
| 33 |
+
return (
|
| 34 |
+
<AccordionPrimitive.Header className="flex">
|
| 35 |
+
<AccordionPrimitive.Trigger
|
| 36 |
+
data-slot="accordion-trigger"
|
| 37 |
+
className={cn(
|
| 38 |
+
"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",
|
| 39 |
+
className,
|
| 40 |
+
)}
|
| 41 |
+
{...props}
|
| 42 |
+
>
|
| 43 |
+
{children}
|
| 44 |
+
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
| 45 |
+
</AccordionPrimitive.Trigger>
|
| 46 |
+
</AccordionPrimitive.Header>
|
| 47 |
+
);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function AccordionContent({
|
| 51 |
+
className,
|
| 52 |
+
children,
|
| 53 |
+
...props
|
| 54 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
| 55 |
+
return (
|
| 56 |
+
<AccordionPrimitive.Content
|
| 57 |
+
data-slot="accordion-content"
|
| 58 |
+
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
| 59 |
+
{...props}
|
| 60 |
+
>
|
| 61 |
+
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
| 62 |
+
</AccordionPrimitive.Content>
|
| 63 |
+
);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
web/src/components/ui/alert-dialog.tsx
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog@1.1.6";
|
| 5 |
+
|
| 6 |
+
import { cn } from "./utils";
|
| 7 |
+
import { buttonVariants } from "./button";
|
| 8 |
+
|
| 9 |
+
function AlertDialog({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
| 12 |
+
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function AlertDialogTrigger({
|
| 16 |
+
...props
|
| 17 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
| 18 |
+
return (
|
| 19 |
+
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function AlertDialogPortal({
|
| 24 |
+
...props
|
| 25 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
| 26 |
+
return (
|
| 27 |
+
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function AlertDialogOverlay({
|
| 32 |
+
className,
|
| 33 |
+
...props
|
| 34 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
| 35 |
+
return (
|
| 36 |
+
<AlertDialogPrimitive.Overlay
|
| 37 |
+
data-slot="alert-dialog-overlay"
|
| 38 |
+
className={cn(
|
| 39 |
+
"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",
|
| 40 |
+
className,
|
| 41 |
+
)}
|
| 42 |
+
{...props}
|
| 43 |
+
/>
|
| 44 |
+
);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function AlertDialogContent({
|
| 48 |
+
className,
|
| 49 |
+
...props
|
| 50 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
| 51 |
+
return (
|
| 52 |
+
<AlertDialogPortal>
|
| 53 |
+
<AlertDialogOverlay />
|
| 54 |
+
<AlertDialogPrimitive.Content
|
| 55 |
+
data-slot="alert-dialog-content"
|
| 56 |
+
className={cn(
|
| 57 |
+
"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",
|
| 58 |
+
className,
|
| 59 |
+
)}
|
| 60 |
+
{...props}
|
| 61 |
+
/>
|
| 62 |
+
</AlertDialogPortal>
|
| 63 |
+
);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function AlertDialogHeader({
|
| 67 |
+
className,
|
| 68 |
+
...props
|
| 69 |
+
}: React.ComponentProps<"div">) {
|
| 70 |
+
return (
|
| 71 |
+
<div
|
| 72 |
+
data-slot="alert-dialog-header"
|
| 73 |
+
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
| 74 |
+
{...props}
|
| 75 |
+
/>
|
| 76 |
+
);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function AlertDialogFooter({
|
| 80 |
+
className,
|
| 81 |
+
...props
|
| 82 |
+
}: React.ComponentProps<"div">) {
|
| 83 |
+
return (
|
| 84 |
+
<div
|
| 85 |
+
data-slot="alert-dialog-footer"
|
| 86 |
+
className={cn(
|
| 87 |
+
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
| 88 |
+
className,
|
| 89 |
+
)}
|
| 90 |
+
{...props}
|
| 91 |
+
/>
|
| 92 |
+
);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function AlertDialogTitle({
|
| 96 |
+
className,
|
| 97 |
+
...props
|
| 98 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
| 99 |
+
return (
|
| 100 |
+
<AlertDialogPrimitive.Title
|
| 101 |
+
data-slot="alert-dialog-title"
|
| 102 |
+
className={cn("text-lg font-semibold", className)}
|
| 103 |
+
{...props}
|
| 104 |
+
/>
|
| 105 |
+
);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function AlertDialogDescription({
|
| 109 |
+
className,
|
| 110 |
+
...props
|
| 111 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
| 112 |
+
return (
|
| 113 |
+
<AlertDialogPrimitive.Description
|
| 114 |
+
data-slot="alert-dialog-description"
|
| 115 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 116 |
+
{...props}
|
| 117 |
+
/>
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function AlertDialogAction({
|
| 122 |
+
className,
|
| 123 |
+
...props
|
| 124 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
| 125 |
+
return (
|
| 126 |
+
<AlertDialogPrimitive.Action
|
| 127 |
+
className={cn(buttonVariants(), className)}
|
| 128 |
+
{...props}
|
| 129 |
+
/>
|
| 130 |
+
);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
function AlertDialogCancel({
|
| 134 |
+
className,
|
| 135 |
+
...props
|
| 136 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
| 137 |
+
return (
|
| 138 |
+
<AlertDialogPrimitive.Cancel
|
| 139 |
+
className={cn(buttonVariants({ variant: "outline" }), className)}
|
| 140 |
+
{...props}
|
| 141 |
+
/>
|
| 142 |
+
);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
export {
|
| 146 |
+
AlertDialog,
|
| 147 |
+
AlertDialogPortal,
|
| 148 |
+
AlertDialogOverlay,
|
| 149 |
+
AlertDialogTrigger,
|
| 150 |
+
AlertDialogContent,
|
| 151 |
+
AlertDialogHeader,
|
| 152 |
+
AlertDialogFooter,
|
| 153 |
+
AlertDialogTitle,
|
| 154 |
+
AlertDialogDescription,
|
| 155 |
+
AlertDialogAction,
|
| 156 |
+
AlertDialogCancel,
|
| 157 |
+
};
|
web/src/components/ui/alert.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
|
| 3 |
+
|
| 4 |
+
import { cn } from "./utils";
|
| 5 |
+
|
| 6 |
+
const alertVariants = cva(
|
| 7 |
+
"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",
|
| 8 |
+
{
|
| 9 |
+
variants: {
|
| 10 |
+
variant: {
|
| 11 |
+
default: "bg-card text-card-foreground",
|
| 12 |
+
destructive:
|
| 13 |
+
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
| 14 |
+
},
|
| 15 |
+
},
|
| 16 |
+
defaultVariants: {
|
| 17 |
+
variant: "default",
|
| 18 |
+
},
|
| 19 |
+
},
|
| 20 |
+
);
|
| 21 |
+
|
| 22 |
+
function Alert({
|
| 23 |
+
className,
|
| 24 |
+
variant,
|
| 25 |
+
...props
|
| 26 |
+
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
| 27 |
+
return (
|
| 28 |
+
<div
|
| 29 |
+
data-slot="alert"
|
| 30 |
+
role="alert"
|
| 31 |
+
className={cn(alertVariants({ variant }), className)}
|
| 32 |
+
{...props}
|
| 33 |
+
/>
|
| 34 |
+
);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
| 38 |
+
return (
|
| 39 |
+
<div
|
| 40 |
+
data-slot="alert-title"
|
| 41 |
+
className={cn(
|
| 42 |
+
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
| 43 |
+
className,
|
| 44 |
+
)}
|
| 45 |
+
{...props}
|
| 46 |
+
/>
|
| 47 |
+
);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function AlertDescription({
|
| 51 |
+
className,
|
| 52 |
+
...props
|
| 53 |
+
}: React.ComponentProps<"div">) {
|
| 54 |
+
return (
|
| 55 |
+
<div
|
| 56 |
+
data-slot="alert-description"
|
| 57 |
+
className={cn(
|
| 58 |
+
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
| 59 |
+
className,
|
| 60 |
+
)}
|
| 61 |
+
{...props}
|
| 62 |
+
/>
|
| 63 |
+
);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export { Alert, AlertTitle, AlertDescription };
|
web/src/components/ui/aspect-ratio.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio@1.1.2";
|
| 4 |
+
|
| 5 |
+
function AspectRatio({
|
| 6 |
+
...props
|
| 7 |
+
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
| 8 |
+
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export { AspectRatio };
|
web/src/components/ui/avatar.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as AvatarPrimitive from "@radix-ui/react-avatar@1.1.3";
|
| 5 |
+
|
| 6 |
+
import { cn } from "./utils";
|
| 7 |
+
|
| 8 |
+
function Avatar({
|
| 9 |
+
className,
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
| 12 |
+
return (
|
| 13 |
+
<AvatarPrimitive.Root
|
| 14 |
+
data-slot="avatar"
|
| 15 |
+
className={cn(
|
| 16 |
+
"relative flex size-10 shrink-0 overflow-hidden rounded-full",
|
| 17 |
+
className,
|
| 18 |
+
)}
|
| 19 |
+
{...props}
|
| 20 |
+
/>
|
| 21 |
+
);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function AvatarImage({
|
| 25 |
+
className,
|
| 26 |
+
...props
|
| 27 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
| 28 |
+
return (
|
| 29 |
+
<AvatarPrimitive.Image
|
| 30 |
+
data-slot="avatar-image"
|
| 31 |
+
className={cn("aspect-square size-full", className)}
|
| 32 |
+
{...props}
|
| 33 |
+
/>
|
| 34 |
+
);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function AvatarFallback({
|
| 38 |
+
className,
|
| 39 |
+
...props
|
| 40 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
| 41 |
+
return (
|
| 42 |
+
<AvatarPrimitive.Fallback
|
| 43 |
+
data-slot="avatar-fallback"
|
| 44 |
+
className={cn(
|
| 45 |
+
"bg-muted flex size-full items-center justify-center rounded-full",
|
| 46 |
+
className,
|
| 47 |
+
)}
|
| 48 |
+
{...props}
|
| 49 |
+
/>
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export { Avatar, AvatarImage, AvatarFallback };
|
web/src/components/ui/badge.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot@1.1.2";
|
| 3 |
+
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
|
| 4 |
+
|
| 5 |
+
import { cn } from "./utils";
|
| 6 |
+
|
| 7 |
+
const badgeVariants = cva(
|
| 8 |
+
"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",
|
| 9 |
+
{
|
| 10 |
+
variants: {
|
| 11 |
+
variant: {
|
| 12 |
+
default:
|
| 13 |
+
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
| 14 |
+
secondary:
|
| 15 |
+
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
| 16 |
+
destructive:
|
| 17 |
+
"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",
|
| 18 |
+
outline:
|
| 19 |
+
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
| 20 |
+
},
|
| 21 |
+
},
|
| 22 |
+
defaultVariants: {
|
| 23 |
+
variant: "default",
|
| 24 |
+
},
|
| 25 |
+
},
|
| 26 |
+
);
|
| 27 |
+
|
| 28 |
+
function Badge({
|
| 29 |
+
className,
|
| 30 |
+
variant,
|
| 31 |
+
asChild = false,
|
| 32 |
+
...props
|
| 33 |
+
}: React.ComponentProps<"span"> &
|
| 34 |
+
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
| 35 |
+
const Comp = asChild ? Slot : "span";
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<Comp
|
| 39 |
+
data-slot="badge"
|
| 40 |
+
className={cn(badgeVariants({ variant }), className)}
|
| 41 |
+
{...props}
|
| 42 |
+
/>
|
| 43 |
+
);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export { Badge, badgeVariants };
|
web/src/components/ui/breadcrumb.tsx
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot@1.1.2";
|
| 3 |
+
import { ChevronRight, MoreHorizontal } from "lucide-react@0.487.0";
|
| 4 |
+
|
| 5 |
+
import { cn } from "./utils";
|
| 6 |
+
|
| 7 |
+
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
| 8 |
+
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
| 12 |
+
return (
|
| 13 |
+
<ol
|
| 14 |
+
data-slot="breadcrumb-list"
|
| 15 |
+
className={cn(
|
| 16 |
+
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
| 17 |
+
className,
|
| 18 |
+
)}
|
| 19 |
+
{...props}
|
| 20 |
+
/>
|
| 21 |
+
);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
| 25 |
+
return (
|
| 26 |
+
<li
|
| 27 |
+
data-slot="breadcrumb-item"
|
| 28 |
+
className={cn("inline-flex items-center gap-1.5", className)}
|
| 29 |
+
{...props}
|
| 30 |
+
/>
|
| 31 |
+
);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function BreadcrumbLink({
|
| 35 |
+
asChild,
|
| 36 |
+
className,
|
| 37 |
+
...props
|
| 38 |
+
}: React.ComponentProps<"a"> & {
|
| 39 |
+
asChild?: boolean;
|
| 40 |
+
}) {
|
| 41 |
+
const Comp = asChild ? Slot : "a";
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<Comp
|
| 45 |
+
data-slot="breadcrumb-link"
|
| 46 |
+
className={cn("hover:text-foreground transition-colors", className)}
|
| 47 |
+
{...props}
|
| 48 |
+
/>
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
| 53 |
+
return (
|
| 54 |
+
<span
|
| 55 |
+
data-slot="breadcrumb-page"
|
| 56 |
+
role="link"
|
| 57 |
+
aria-disabled="true"
|
| 58 |
+
aria-current="page"
|
| 59 |
+
className={cn("text-foreground font-normal", className)}
|
| 60 |
+
{...props}
|
| 61 |
+
/>
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function BreadcrumbSeparator({
|
| 66 |
+
children,
|
| 67 |
+
className,
|
| 68 |
+
...props
|
| 69 |
+
}: React.ComponentProps<"li">) {
|
| 70 |
+
return (
|
| 71 |
+
<li
|
| 72 |
+
data-slot="breadcrumb-separator"
|
| 73 |
+
role="presentation"
|
| 74 |
+
aria-hidden="true"
|
| 75 |
+
className={cn("[&>svg]:size-3.5", className)}
|
| 76 |
+
{...props}
|
| 77 |
+
>
|
| 78 |
+
{children ?? <ChevronRight />}
|
| 79 |
+
</li>
|
| 80 |
+
);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function BreadcrumbEllipsis({
|
| 84 |
+
className,
|
| 85 |
+
...props
|
| 86 |
+
}: React.ComponentProps<"span">) {
|
| 87 |
+
return (
|
| 88 |
+
<span
|
| 89 |
+
data-slot="breadcrumb-ellipsis"
|
| 90 |
+
role="presentation"
|
| 91 |
+
aria-hidden="true"
|
| 92 |
+
className={cn("flex size-9 items-center justify-center", className)}
|
| 93 |
+
{...props}
|
| 94 |
+
>
|
| 95 |
+
<MoreHorizontal className="size-4" />
|
| 96 |
+
<span className="sr-only">More</span>
|
| 97 |
+
</span>
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
export {
|
| 102 |
+
Breadcrumb,
|
| 103 |
+
BreadcrumbList,
|
| 104 |
+
BreadcrumbItem,
|
| 105 |
+
BreadcrumbLink,
|
| 106 |
+
BreadcrumbPage,
|
| 107 |
+
BreadcrumbSeparator,
|
| 108 |
+
BreadcrumbEllipsis,
|
| 109 |
+
};
|
web/src/components/ui/button.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot@1.1.2";
|
| 3 |
+
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
|
| 4 |
+
|
| 5 |
+
import { cn } from "./utils";
|
| 6 |
+
|
| 7 |
+
const buttonVariants = cva(
|
| 8 |
+
"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",
|
| 9 |
+
{
|
| 10 |
+
variants: {
|
| 11 |
+
variant: {
|
| 12 |
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
| 13 |
+
destructive:
|
| 14 |
+
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
| 15 |
+
outline:
|
| 16 |
+
"border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
| 17 |
+
secondary:
|
| 18 |
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| 19 |
+
ghost:
|
| 20 |
+
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
| 21 |
+
link: "text-primary underline-offset-4 hover:underline",
|
| 22 |
+
},
|
| 23 |
+
size: {
|
| 24 |
+
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
| 25 |
+
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
| 26 |
+
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
| 27 |
+
icon: "size-9 rounded-md",
|
| 28 |
+
},
|
| 29 |
+
},
|
| 30 |
+
defaultVariants: {
|
| 31 |
+
variant: "default",
|
| 32 |
+
size: "default",
|
| 33 |
+
},
|
| 34 |
+
},
|
| 35 |
+
);
|
| 36 |
+
|
| 37 |
+
const Button = React.forwardRef<
|
| 38 |
+
HTMLButtonElement,
|
| 39 |
+
React.ComponentProps<"button"> &
|
| 40 |
+
VariantProps<typeof buttonVariants> & {
|
| 41 |
+
asChild?: boolean;
|
| 42 |
+
}
|
| 43 |
+
>(({ className, variant, size, asChild = false, ...props }, ref) => {
|
| 44 |
+
const Comp = asChild ? Slot : "button";
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<Comp
|
| 48 |
+
data-slot="button"
|
| 49 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
| 50 |
+
ref={ref}
|
| 51 |
+
{...props}
|
| 52 |
+
/>
|
| 53 |
+
);
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
Button.displayName = "Button";
|
| 57 |
+
|
| 58 |
+
export { Button, buttonVariants };
|
web/src/components/ui/calendar.tsx
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import { ChevronLeft, ChevronRight } from "lucide-react@0.487.0";
|
| 5 |
+
import { DayPicker } from "react-day-picker@8.10.1";
|
| 6 |
+
|
| 7 |
+
import { cn } from "./utils";
|
| 8 |
+
import { buttonVariants } from "./button";
|
| 9 |
+
|
| 10 |
+
function Calendar({
|
| 11 |
+
className,
|
| 12 |
+
classNames,
|
| 13 |
+
showOutsideDays = true,
|
| 14 |
+
...props
|
| 15 |
+
}: React.ComponentProps<typeof DayPicker>) {
|
| 16 |
+
return (
|
| 17 |
+
<DayPicker
|
| 18 |
+
showOutsideDays={showOutsideDays}
|
| 19 |
+
className={cn("p-3", className)}
|
| 20 |
+
classNames={{
|
| 21 |
+
months: "flex flex-col sm:flex-row gap-2",
|
| 22 |
+
month: "flex flex-col gap-4",
|
| 23 |
+
caption: "flex justify-center pt-1 relative items-center w-full",
|
| 24 |
+
caption_label: "text-sm font-medium",
|
| 25 |
+
nav: "flex items-center gap-1",
|
| 26 |
+
nav_button: cn(
|
| 27 |
+
buttonVariants({ variant: "outline" }),
|
| 28 |
+
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
| 29 |
+
),
|
| 30 |
+
nav_button_previous: "absolute left-1",
|
| 31 |
+
nav_button_next: "absolute right-1",
|
| 32 |
+
table: "w-full border-collapse space-x-1",
|
| 33 |
+
head_row: "flex",
|
| 34 |
+
head_cell:
|
| 35 |
+
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
| 36 |
+
row: "flex w-full mt-2",
|
| 37 |
+
cell: cn(
|
| 38 |
+
"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",
|
| 39 |
+
props.mode === "range"
|
| 40 |
+
? "[&: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"
|
| 41 |
+
: "[&:has([aria-selected])]:rounded-md",
|
| 42 |
+
),
|
| 43 |
+
day: cn(
|
| 44 |
+
buttonVariants({ variant: "ghost" }),
|
| 45 |
+
"size-8 p-0 font-normal aria-selected:opacity-100",
|
| 46 |
+
),
|
| 47 |
+
day_range_start:
|
| 48 |
+
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
|
| 49 |
+
day_range_end:
|
| 50 |
+
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
|
| 51 |
+
day_selected:
|
| 52 |
+
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
| 53 |
+
day_today: "bg-accent text-accent-foreground",
|
| 54 |
+
day_outside:
|
| 55 |
+
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
|
| 56 |
+
day_disabled: "text-muted-foreground opacity-50",
|
| 57 |
+
day_range_middle:
|
| 58 |
+
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
| 59 |
+
day_hidden: "invisible",
|
| 60 |
+
...classNames,
|
| 61 |
+
}}
|
| 62 |
+
components={{
|
| 63 |
+
IconLeft: ({ className, ...props }) => (
|
| 64 |
+
<ChevronLeft className={cn("size-4", className)} {...props} />
|
| 65 |
+
),
|
| 66 |
+
IconRight: ({ className, ...props }) => (
|
| 67 |
+
<ChevronRight className={cn("size-4", className)} {...props} />
|
| 68 |
+
),
|
| 69 |
+
}}
|
| 70 |
+
{...props}
|
| 71 |
+
/>
|
| 72 |
+
);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
export { Calendar };
|
web/src/components/ui/card.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
|
| 3 |
+
import { cn } from "./utils";
|
| 4 |
+
|
| 5 |
+
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
| 6 |
+
return (
|
| 7 |
+
<div
|
| 8 |
+
data-slot="card"
|
| 9 |
+
className={cn(
|
| 10 |
+
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border",
|
| 11 |
+
className,
|
| 12 |
+
)}
|
| 13 |
+
{...props}
|
| 14 |
+
/>
|
| 15 |
+
);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 19 |
+
return (
|
| 20 |
+
<div
|
| 21 |
+
data-slot="card-header"
|
| 22 |
+
className={cn(
|
| 23 |
+
"@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",
|
| 24 |
+
className,
|
| 25 |
+
)}
|
| 26 |
+
{...props}
|
| 27 |
+
/>
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
| 32 |
+
return (
|
| 33 |
+
<h4
|
| 34 |
+
data-slot="card-title"
|
| 35 |
+
className={cn("leading-none", className)}
|
| 36 |
+
{...props}
|
| 37 |
+
/>
|
| 38 |
+
);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
| 42 |
+
return (
|
| 43 |
+
<p
|
| 44 |
+
data-slot="card-description"
|
| 45 |
+
className={cn("text-muted-foreground", className)}
|
| 46 |
+
{...props}
|
| 47 |
+
/>
|
| 48 |
+
);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
| 52 |
+
return (
|
| 53 |
+
<div
|
| 54 |
+
data-slot="card-action"
|
| 55 |
+
className={cn(
|
| 56 |
+
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
| 57 |
+
className,
|
| 58 |
+
)}
|
| 59 |
+
{...props}
|
| 60 |
+
/>
|
| 61 |
+
);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
| 65 |
+
return (
|
| 66 |
+
<div
|
| 67 |
+
data-slot="card-content"
|
| 68 |
+
className={cn("px-6 [&:last-child]:pb-6", className)}
|
| 69 |
+
{...props}
|
| 70 |
+
/>
|
| 71 |
+
);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
| 75 |
+
return (
|
| 76 |
+
<div
|
| 77 |
+
data-slot="card-footer"
|
| 78 |
+
className={cn("flex items-center px-6 pb-6 [.border-t]:pt-6", className)}
|
| 79 |
+
{...props}
|
| 80 |
+
/>
|
| 81 |
+
);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export {
|
| 85 |
+
Card,
|
| 86 |
+
CardHeader,
|
| 87 |
+
CardFooter,
|
| 88 |
+
CardTitle,
|
| 89 |
+
CardAction,
|
| 90 |
+
CardDescription,
|
| 91 |
+
CardContent,
|
| 92 |
+
};
|
web/src/components/ui/carousel.tsx
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import useEmblaCarousel, {
|
| 5 |
+
type UseEmblaCarouselType,
|
| 6 |
+
} from "embla-carousel-react@8.6.0";
|
| 7 |
+
import { ArrowLeft, ArrowRight } from "lucide-react@0.487.0";
|
| 8 |
+
|
| 9 |
+
import { cn } from "./utils";
|
| 10 |
+
import { Button } from "./button";
|
| 11 |
+
|
| 12 |
+
type CarouselApi = UseEmblaCarouselType[1];
|
| 13 |
+
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
| 14 |
+
type CarouselOptions = UseCarouselParameters[0];
|
| 15 |
+
type CarouselPlugin = UseCarouselParameters[1];
|
| 16 |
+
|
| 17 |
+
type CarouselProps = {
|
| 18 |
+
opts?: CarouselOptions;
|
| 19 |
+
plugins?: CarouselPlugin;
|
| 20 |
+
orientation?: "horizontal" | "vertical";
|
| 21 |
+
setApi?: (api: CarouselApi) => void;
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
type CarouselContextProps = {
|
| 25 |
+
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
| 26 |
+
api: ReturnType<typeof useEmblaCarousel>[1];
|
| 27 |
+
scrollPrev: () => void;
|
| 28 |
+
scrollNext: () => void;
|
| 29 |
+
canScrollPrev: boolean;
|
| 30 |
+
canScrollNext: boolean;
|
| 31 |
+
} & CarouselProps;
|
| 32 |
+
|
| 33 |
+
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
| 34 |
+
|
| 35 |
+
function useCarousel() {
|
| 36 |
+
const context = React.useContext(CarouselContext);
|
| 37 |
+
|
| 38 |
+
if (!context) {
|
| 39 |
+
throw new Error("useCarousel must be used within a <Carousel />");
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
return context;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function Carousel({
|
| 46 |
+
orientation = "horizontal",
|
| 47 |
+
opts,
|
| 48 |
+
setApi,
|
| 49 |
+
plugins,
|
| 50 |
+
className,
|
| 51 |
+
children,
|
| 52 |
+
...props
|
| 53 |
+
}: React.ComponentProps<"div"> & CarouselProps) {
|
| 54 |
+
const [carouselRef, api] = useEmblaCarousel(
|
| 55 |
+
{
|
| 56 |
+
...opts,
|
| 57 |
+
axis: orientation === "horizontal" ? "x" : "y",
|
| 58 |
+
},
|
| 59 |
+
plugins,
|
| 60 |
+
);
|
| 61 |
+
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
| 62 |
+
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
| 63 |
+
|
| 64 |
+
const onSelect = React.useCallback((api: CarouselApi) => {
|
| 65 |
+
if (!api) return;
|
| 66 |
+
setCanScrollPrev(api.canScrollPrev());
|
| 67 |
+
setCanScrollNext(api.canScrollNext());
|
| 68 |
+
}, []);
|
| 69 |
+
|
| 70 |
+
const scrollPrev = React.useCallback(() => {
|
| 71 |
+
api?.scrollPrev();
|
| 72 |
+
}, [api]);
|
| 73 |
+
|
| 74 |
+
const scrollNext = React.useCallback(() => {
|
| 75 |
+
api?.scrollNext();
|
| 76 |
+
}, [api]);
|
| 77 |
+
|
| 78 |
+
const handleKeyDown = React.useCallback(
|
| 79 |
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
| 80 |
+
if (event.key === "ArrowLeft") {
|
| 81 |
+
event.preventDefault();
|
| 82 |
+
scrollPrev();
|
| 83 |
+
} else if (event.key === "ArrowRight") {
|
| 84 |
+
event.preventDefault();
|
| 85 |
+
scrollNext();
|
| 86 |
+
}
|
| 87 |
+
},
|
| 88 |
+
[scrollPrev, scrollNext],
|
| 89 |
+
);
|
| 90 |
+
|
| 91 |
+
React.useEffect(() => {
|
| 92 |
+
if (!api || !setApi) return;
|
| 93 |
+
setApi(api);
|
| 94 |
+
}, [api, setApi]);
|
| 95 |
+
|
| 96 |
+
React.useEffect(() => {
|
| 97 |
+
if (!api) return;
|
| 98 |
+
onSelect(api);
|
| 99 |
+
api.on("reInit", onSelect);
|
| 100 |
+
api.on("select", onSelect);
|
| 101 |
+
|
| 102 |
+
return () => {
|
| 103 |
+
api?.off("select", onSelect);
|
| 104 |
+
};
|
| 105 |
+
}, [api, onSelect]);
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
<CarouselContext.Provider
|
| 109 |
+
value={{
|
| 110 |
+
carouselRef,
|
| 111 |
+
api: api,
|
| 112 |
+
opts,
|
| 113 |
+
orientation:
|
| 114 |
+
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
| 115 |
+
scrollPrev,
|
| 116 |
+
scrollNext,
|
| 117 |
+
canScrollPrev,
|
| 118 |
+
canScrollNext,
|
| 119 |
+
}}
|
| 120 |
+
>
|
| 121 |
+
<div
|
| 122 |
+
onKeyDownCapture={handleKeyDown}
|
| 123 |
+
className={cn("relative", className)}
|
| 124 |
+
role="region"
|
| 125 |
+
aria-roledescription="carousel"
|
| 126 |
+
data-slot="carousel"
|
| 127 |
+
{...props}
|
| 128 |
+
>
|
| 129 |
+
{children}
|
| 130 |
+
</div>
|
| 131 |
+
</CarouselContext.Provider>
|
| 132 |
+
);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
| 136 |
+
const { carouselRef, orientation } = useCarousel();
|
| 137 |
+
|
| 138 |
+
return (
|
| 139 |
+
<div
|
| 140 |
+
ref={carouselRef}
|
| 141 |
+
className="overflow-hidden"
|
| 142 |
+
data-slot="carousel-content"
|
| 143 |
+
>
|
| 144 |
+
<div
|
| 145 |
+
className={cn(
|
| 146 |
+
"flex",
|
| 147 |
+
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
| 148 |
+
className,
|
| 149 |
+
)}
|
| 150 |
+
{...props}
|
| 151 |
+
/>
|
| 152 |
+
</div>
|
| 153 |
+
);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
| 157 |
+
const { orientation } = useCarousel();
|
| 158 |
+
|
| 159 |
+
return (
|
| 160 |
+
<div
|
| 161 |
+
role="group"
|
| 162 |
+
aria-roledescription="slide"
|
| 163 |
+
data-slot="carousel-item"
|
| 164 |
+
className={cn(
|
| 165 |
+
"min-w-0 shrink-0 grow-0 basis-full",
|
| 166 |
+
orientation === "horizontal" ? "pl-4" : "pt-4",
|
| 167 |
+
className,
|
| 168 |
+
)}
|
| 169 |
+
{...props}
|
| 170 |
+
/>
|
| 171 |
+
);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function CarouselPrevious({
|
| 175 |
+
className,
|
| 176 |
+
variant = "outline",
|
| 177 |
+
size = "icon",
|
| 178 |
+
...props
|
| 179 |
+
}: React.ComponentProps<typeof Button>) {
|
| 180 |
+
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
| 181 |
+
|
| 182 |
+
return (
|
| 183 |
+
<Button
|
| 184 |
+
data-slot="carousel-previous"
|
| 185 |
+
variant={variant}
|
| 186 |
+
size={size}
|
| 187 |
+
className={cn(
|
| 188 |
+
"absolute size-8 rounded-full",
|
| 189 |
+
orientation === "horizontal"
|
| 190 |
+
? "top-1/2 -left-12 -translate-y-1/2"
|
| 191 |
+
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
| 192 |
+
className,
|
| 193 |
+
)}
|
| 194 |
+
disabled={!canScrollPrev}
|
| 195 |
+
onClick={scrollPrev}
|
| 196 |
+
{...props}
|
| 197 |
+
>
|
| 198 |
+
<ArrowLeft />
|
| 199 |
+
<span className="sr-only">Previous slide</span>
|
| 200 |
+
</Button>
|
| 201 |
+
);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
function CarouselNext({
|
| 205 |
+
className,
|
| 206 |
+
variant = "outline",
|
| 207 |
+
size = "icon",
|
| 208 |
+
...props
|
| 209 |
+
}: React.ComponentProps<typeof Button>) {
|
| 210 |
+
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
| 211 |
+
|
| 212 |
+
return (
|
| 213 |
+
<Button
|
| 214 |
+
data-slot="carousel-next"
|
| 215 |
+
variant={variant}
|
| 216 |
+
size={size}
|
| 217 |
+
className={cn(
|
| 218 |
+
"absolute size-8 rounded-full",
|
| 219 |
+
orientation === "horizontal"
|
| 220 |
+
? "top-1/2 -right-12 -translate-y-1/2"
|
| 221 |
+
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
| 222 |
+
className,
|
| 223 |
+
)}
|
| 224 |
+
disabled={!canScrollNext}
|
| 225 |
+
onClick={scrollNext}
|
| 226 |
+
{...props}
|
| 227 |
+
>
|
| 228 |
+
<ArrowRight />
|
| 229 |
+
<span className="sr-only">Next slide</span>
|
| 230 |
+
</Button>
|
| 231 |
+
);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
export {
|
| 235 |
+
type CarouselApi,
|
| 236 |
+
Carousel,
|
| 237 |
+
CarouselContent,
|
| 238 |
+
CarouselItem,
|
| 239 |
+
CarouselPrevious,
|
| 240 |
+
CarouselNext,
|
| 241 |
+
};
|
web/src/components/ui/chart.tsx
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as RechartsPrimitive from "recharts@2.15.2";
|
| 5 |
+
|
| 6 |
+
import { cn } from "./utils";
|
| 7 |
+
|
| 8 |
+
// Format: { THEME_NAME: CSS_SELECTOR }
|
| 9 |
+
const THEMES = { light: "", dark: ".dark" } as const;
|
| 10 |
+
|
| 11 |
+
export type ChartConfig = {
|
| 12 |
+
[k in string]: {
|
| 13 |
+
label?: React.ReactNode;
|
| 14 |
+
icon?: React.ComponentType;
|
| 15 |
+
} & (
|
| 16 |
+
| { color?: string; theme?: never }
|
| 17 |
+
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
| 18 |
+
);
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
type ChartContextProps = {
|
| 22 |
+
config: ChartConfig;
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
| 26 |
+
|
| 27 |
+
function useChart() {
|
| 28 |
+
const context = React.useContext(ChartContext);
|
| 29 |
+
|
| 30 |
+
if (!context) {
|
| 31 |
+
throw new Error("useChart must be used within a <ChartContainer />");
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
return context;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function ChartContainer({
|
| 38 |
+
id,
|
| 39 |
+
className,
|
| 40 |
+
children,
|
| 41 |
+
config,
|
| 42 |
+
...props
|
| 43 |
+
}: React.ComponentProps<"div"> & {
|
| 44 |
+
config: ChartConfig;
|
| 45 |
+
children: React.ComponentProps<
|
| 46 |
+
typeof RechartsPrimitive.ResponsiveContainer
|
| 47 |
+
>["children"];
|
| 48 |
+
}) {
|
| 49 |
+
const uniqueId = React.useId();
|
| 50 |
+
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<ChartContext.Provider value={{ config }}>
|
| 54 |
+
<div
|
| 55 |
+
data-slot="chart"
|
| 56 |
+
data-chart={chartId}
|
| 57 |
+
className={cn(
|
| 58 |
+
"[&_.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",
|
| 59 |
+
className,
|
| 60 |
+
)}
|
| 61 |
+
{...props}
|
| 62 |
+
>
|
| 63 |
+
<ChartStyle id={chartId} config={config} />
|
| 64 |
+
<RechartsPrimitive.ResponsiveContainer>
|
| 65 |
+
{children}
|
| 66 |
+
</RechartsPrimitive.ResponsiveContainer>
|
| 67 |
+
</div>
|
| 68 |
+
</ChartContext.Provider>
|
| 69 |
+
);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
| 73 |
+
const colorConfig = Object.entries(config).filter(
|
| 74 |
+
([, config]) => config.theme || config.color,
|
| 75 |
+
);
|
| 76 |
+
|
| 77 |
+
if (!colorConfig.length) {
|
| 78 |
+
return null;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
return (
|
| 82 |
+
<style
|
| 83 |
+
dangerouslySetInnerHTML={{
|
| 84 |
+
__html: Object.entries(THEMES)
|
| 85 |
+
.map(
|
| 86 |
+
([theme, prefix]) => `
|
| 87 |
+
${prefix} [data-chart=${id}] {
|
| 88 |
+
${colorConfig
|
| 89 |
+
.map(([key, itemConfig]) => {
|
| 90 |
+
const color =
|
| 91 |
+
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
| 92 |
+
itemConfig.color;
|
| 93 |
+
return color ? ` --color-${key}: ${color};` : null;
|
| 94 |
+
})
|
| 95 |
+
.join("\n")}
|
| 96 |
+
}
|
| 97 |
+
`,
|
| 98 |
+
)
|
| 99 |
+
.join("\n"),
|
| 100 |
+
}}
|
| 101 |
+
/>
|
| 102 |
+
);
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
const ChartTooltip = RechartsPrimitive.Tooltip;
|
| 106 |
+
|
| 107 |
+
function ChartTooltipContent({
|
| 108 |
+
active,
|
| 109 |
+
payload,
|
| 110 |
+
className,
|
| 111 |
+
indicator = "dot",
|
| 112 |
+
hideLabel = false,
|
| 113 |
+
hideIndicator = false,
|
| 114 |
+
label,
|
| 115 |
+
labelFormatter,
|
| 116 |
+
labelClassName,
|
| 117 |
+
formatter,
|
| 118 |
+
color,
|
| 119 |
+
nameKey,
|
| 120 |
+
labelKey,
|
| 121 |
+
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
| 122 |
+
React.ComponentProps<"div"> & {
|
| 123 |
+
hideLabel?: boolean;
|
| 124 |
+
hideIndicator?: boolean;
|
| 125 |
+
indicator?: "line" | "dot" | "dashed";
|
| 126 |
+
nameKey?: string;
|
| 127 |
+
labelKey?: string;
|
| 128 |
+
}) {
|
| 129 |
+
const { config } = useChart();
|
| 130 |
+
|
| 131 |
+
const tooltipLabel = React.useMemo(() => {
|
| 132 |
+
if (hideLabel || !payload?.length) {
|
| 133 |
+
return null;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
const [item] = payload;
|
| 137 |
+
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
| 138 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
| 139 |
+
const value =
|
| 140 |
+
!labelKey && typeof label === "string"
|
| 141 |
+
? config[label as keyof typeof config]?.label || label
|
| 142 |
+
: itemConfig?.label;
|
| 143 |
+
|
| 144 |
+
if (labelFormatter) {
|
| 145 |
+
return (
|
| 146 |
+
<div className={cn("font-medium", labelClassName)}>
|
| 147 |
+
{labelFormatter(value, payload)}
|
| 148 |
+
</div>
|
| 149 |
+
);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
if (!value) {
|
| 153 |
+
return null;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
| 157 |
+
}, [
|
| 158 |
+
label,
|
| 159 |
+
labelFormatter,
|
| 160 |
+
payload,
|
| 161 |
+
hideLabel,
|
| 162 |
+
labelClassName,
|
| 163 |
+
config,
|
| 164 |
+
labelKey,
|
| 165 |
+
]);
|
| 166 |
+
|
| 167 |
+
if (!active || !payload?.length) {
|
| 168 |
+
return null;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
const nestLabel = payload.length === 1 && indicator !== "dot";
|
| 172 |
+
|
| 173 |
+
return (
|
| 174 |
+
<div
|
| 175 |
+
className={cn(
|
| 176 |
+
"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",
|
| 177 |
+
className,
|
| 178 |
+
)}
|
| 179 |
+
>
|
| 180 |
+
{!nestLabel ? tooltipLabel : null}
|
| 181 |
+
<div className="grid gap-1.5">
|
| 182 |
+
{payload.map((item, index) => {
|
| 183 |
+
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
| 184 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
| 185 |
+
const indicatorColor = color || item.payload.fill || item.color;
|
| 186 |
+
|
| 187 |
+
return (
|
| 188 |
+
<div
|
| 189 |
+
key={item.dataKey}
|
| 190 |
+
className={cn(
|
| 191 |
+
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
| 192 |
+
indicator === "dot" && "items-center",
|
| 193 |
+
)}
|
| 194 |
+
>
|
| 195 |
+
{formatter && item?.value !== undefined && item.name ? (
|
| 196 |
+
formatter(item.value, item.name, item, index, item.payload)
|
| 197 |
+
) : (
|
| 198 |
+
<>
|
| 199 |
+
{itemConfig?.icon ? (
|
| 200 |
+
<itemConfig.icon />
|
| 201 |
+
) : (
|
| 202 |
+
!hideIndicator && (
|
| 203 |
+
<div
|
| 204 |
+
className={cn(
|
| 205 |
+
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
| 206 |
+
{
|
| 207 |
+
"h-2.5 w-2.5": indicator === "dot",
|
| 208 |
+
"w-1": indicator === "line",
|
| 209 |
+
"w-0 border-[1.5px] border-dashed bg-transparent":
|
| 210 |
+
indicator === "dashed",
|
| 211 |
+
"my-0.5": nestLabel && indicator === "dashed",
|
| 212 |
+
},
|
| 213 |
+
)}
|
| 214 |
+
style={
|
| 215 |
+
{
|
| 216 |
+
"--color-bg": indicatorColor,
|
| 217 |
+
"--color-border": indicatorColor,
|
| 218 |
+
} as React.CSSProperties
|
| 219 |
+
}
|
| 220 |
+
/>
|
| 221 |
+
)
|
| 222 |
+
)}
|
| 223 |
+
<div
|
| 224 |
+
className={cn(
|
| 225 |
+
"flex flex-1 justify-between leading-none",
|
| 226 |
+
nestLabel ? "items-end" : "items-center",
|
| 227 |
+
)}
|
| 228 |
+
>
|
| 229 |
+
<div className="grid gap-1.5">
|
| 230 |
+
{nestLabel ? tooltipLabel : null}
|
| 231 |
+
<span className="text-muted-foreground">
|
| 232 |
+
{itemConfig?.label || item.name}
|
| 233 |
+
</span>
|
| 234 |
+
</div>
|
| 235 |
+
{item.value && (
|
| 236 |
+
<span className="text-foreground font-mono font-medium tabular-nums">
|
| 237 |
+
{item.value.toLocaleString()}
|
| 238 |
+
</span>
|
| 239 |
+
)}
|
| 240 |
+
</div>
|
| 241 |
+
</>
|
| 242 |
+
)}
|
| 243 |
+
</div>
|
| 244 |
+
);
|
| 245 |
+
})}
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
);
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
const ChartLegend = RechartsPrimitive.Legend;
|
| 252 |
+
|
| 253 |
+
function ChartLegendContent({
|
| 254 |
+
className,
|
| 255 |
+
hideIcon = false,
|
| 256 |
+
payload,
|
| 257 |
+
verticalAlign = "bottom",
|
| 258 |
+
nameKey,
|
| 259 |
+
}: React.ComponentProps<"div"> &
|
| 260 |
+
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
| 261 |
+
hideIcon?: boolean;
|
| 262 |
+
nameKey?: string;
|
| 263 |
+
}) {
|
| 264 |
+
const { config } = useChart();
|
| 265 |
+
|
| 266 |
+
if (!payload?.length) {
|
| 267 |
+
return null;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
return (
|
| 271 |
+
<div
|
| 272 |
+
className={cn(
|
| 273 |
+
"flex items-center justify-center gap-4",
|
| 274 |
+
verticalAlign === "top" ? "pb-3" : "pt-3",
|
| 275 |
+
className,
|
| 276 |
+
)}
|
| 277 |
+
>
|
| 278 |
+
{payload.map((item) => {
|
| 279 |
+
const key = `${nameKey || item.dataKey || "value"}`;
|
| 280 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
| 281 |
+
|
| 282 |
+
return (
|
| 283 |
+
<div
|
| 284 |
+
key={item.value}
|
| 285 |
+
className={cn(
|
| 286 |
+
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
|
| 287 |
+
)}
|
| 288 |
+
>
|
| 289 |
+
{itemConfig?.icon && !hideIcon ? (
|
| 290 |
+
<itemConfig.icon />
|
| 291 |
+
) : (
|
| 292 |
+
<div
|
| 293 |
+
className="h-2 w-2 shrink-0 rounded-[2px]"
|
| 294 |
+
style={{
|
| 295 |
+
backgroundColor: item.color,
|
| 296 |
+
}}
|
| 297 |
+
/>
|
| 298 |
+
)}
|
| 299 |
+
{itemConfig?.label}
|
| 300 |
+
</div>
|
| 301 |
+
);
|
| 302 |
+
})}
|
| 303 |
+
</div>
|
| 304 |
+
);
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
// Helper to extract item config from a payload.
|
| 308 |
+
function getPayloadConfigFromPayload(
|
| 309 |
+
config: ChartConfig,
|
| 310 |
+
payload: unknown,
|
| 311 |
+
key: string,
|
| 312 |
+
) {
|
| 313 |
+
if (typeof payload !== "object" || payload === null) {
|
| 314 |
+
return undefined;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
const payloadPayload =
|
| 318 |
+
"payload" in payload &&
|
| 319 |
+
typeof payload.payload === "object" &&
|
| 320 |
+
payload.payload !== null
|
| 321 |
+
? payload.payload
|
| 322 |
+
: undefined;
|
| 323 |
+
|
| 324 |
+
let configLabelKey: string = key;
|
| 325 |
+
|
| 326 |
+
if (
|
| 327 |
+
key in payload &&
|
| 328 |
+
typeof payload[key as keyof typeof payload] === "string"
|
| 329 |
+
) {
|
| 330 |
+
configLabelKey = payload[key as keyof typeof payload] as string;
|
| 331 |
+
} else if (
|
| 332 |
+
payloadPayload &&
|
| 333 |
+
key in payloadPayload &&
|
| 334 |
+
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
| 335 |
+
) {
|
| 336 |
+
configLabelKey = payloadPayload[
|
| 337 |
+
key as keyof typeof payloadPayload
|
| 338 |
+
] as string;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
return configLabelKey in config
|
| 342 |
+
? config[configLabelKey]
|
| 343 |
+
: config[key as keyof typeof config];
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
export {
|
| 347 |
+
ChartContainer,
|
| 348 |
+
ChartTooltip,
|
| 349 |
+
ChartTooltipContent,
|
| 350 |
+
ChartLegend,
|
| 351 |
+
ChartLegendContent,
|
| 352 |
+
ChartStyle,
|
| 353 |
+
};
|
web/src/components/ui/checkbox.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as CheckboxPrimitive from "@radix-ui/react-checkbox@1.1.4";
|
| 5 |
+
import { CheckIcon } from "lucide-react@0.487.0";
|
| 6 |
+
|
| 7 |
+
import { cn } from "./utils";
|
| 8 |
+
|
| 9 |
+
function Checkbox({
|
| 10 |
+
className,
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
| 13 |
+
return (
|
| 14 |
+
<CheckboxPrimitive.Root
|
| 15 |
+
data-slot="checkbox"
|
| 16 |
+
className={cn(
|
| 17 |
+
"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",
|
| 18 |
+
className,
|
| 19 |
+
)}
|
| 20 |
+
{...props}
|
| 21 |
+
>
|
| 22 |
+
<CheckboxPrimitive.Indicator
|
| 23 |
+
data-slot="checkbox-indicator"
|
| 24 |
+
className="flex items-center justify-center text-current transition-none"
|
| 25 |
+
>
|
| 26 |
+
<CheckIcon className="size-3.5" />
|
| 27 |
+
</CheckboxPrimitive.Indicator>
|
| 28 |
+
</CheckboxPrimitive.Root>
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export { Checkbox };
|
web/src/components/ui/collapsible.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible@1.1.3";
|
| 4 |
+
|
| 5 |
+
function Collapsible({
|
| 6 |
+
...props
|
| 7 |
+
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
| 8 |
+
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function CollapsibleTrigger({
|
| 12 |
+
...props
|
| 13 |
+
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
| 14 |
+
return (
|
| 15 |
+
<CollapsiblePrimitive.CollapsibleTrigger
|
| 16 |
+
data-slot="collapsible-trigger"
|
| 17 |
+
{...props}
|
| 18 |
+
/>
|
| 19 |
+
);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function CollapsibleContent({
|
| 23 |
+
...props
|
| 24 |
+
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
| 25 |
+
return (
|
| 26 |
+
<CollapsiblePrimitive.CollapsibleContent
|
| 27 |
+
data-slot="collapsible-content"
|
| 28 |
+
{...props}
|
| 29 |
+
/>
|
| 30 |
+
);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
web/src/components/ui/command.tsx
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import { Command as CommandPrimitive } from "cmdk@1.1.1";
|
| 5 |
+
import { SearchIcon } from "lucide-react@0.487.0";
|
| 6 |
+
|
| 7 |
+
import { cn } from "./utils";
|
| 8 |
+
import {
|
| 9 |
+
Dialog,
|
| 10 |
+
DialogContent,
|
| 11 |
+
DialogDescription,
|
| 12 |
+
DialogHeader,
|
| 13 |
+
DialogTitle,
|
| 14 |
+
} from "./dialog";
|
| 15 |
+
|
| 16 |
+
function Command({
|
| 17 |
+
className,
|
| 18 |
+
...props
|
| 19 |
+
}: React.ComponentProps<typeof CommandPrimitive>) {
|
| 20 |
+
return (
|
| 21 |
+
<CommandPrimitive
|
| 22 |
+
data-slot="command"
|
| 23 |
+
className={cn(
|
| 24 |
+
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
| 25 |
+
className,
|
| 26 |
+
)}
|
| 27 |
+
{...props}
|
| 28 |
+
/>
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function CommandDialog({
|
| 33 |
+
title = "Command Palette",
|
| 34 |
+
description = "Search for a command to run...",
|
| 35 |
+
children,
|
| 36 |
+
...props
|
| 37 |
+
}: React.ComponentProps<typeof Dialog> & {
|
| 38 |
+
title?: string;
|
| 39 |
+
description?: string;
|
| 40 |
+
}) {
|
| 41 |
+
return (
|
| 42 |
+
<Dialog {...props}>
|
| 43 |
+
<DialogHeader className="sr-only">
|
| 44 |
+
<DialogTitle>{title}</DialogTitle>
|
| 45 |
+
<DialogDescription>{description}</DialogDescription>
|
| 46 |
+
</DialogHeader>
|
| 47 |
+
<DialogContent className="overflow-hidden p-0">
|
| 48 |
+
<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">
|
| 49 |
+
{children}
|
| 50 |
+
</Command>
|
| 51 |
+
</DialogContent>
|
| 52 |
+
</Dialog>
|
| 53 |
+
);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function CommandInput({
|
| 57 |
+
className,
|
| 58 |
+
...props
|
| 59 |
+
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
| 60 |
+
return (
|
| 61 |
+
<div
|
| 62 |
+
data-slot="command-input-wrapper"
|
| 63 |
+
className="flex h-9 items-center gap-2 border-b px-3"
|
| 64 |
+
>
|
| 65 |
+
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
| 66 |
+
<CommandPrimitive.Input
|
| 67 |
+
data-slot="command-input"
|
| 68 |
+
className={cn(
|
| 69 |
+
"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",
|
| 70 |
+
className,
|
| 71 |
+
)}
|
| 72 |
+
{...props}
|
| 73 |
+
/>
|
| 74 |
+
</div>
|
| 75 |
+
);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function CommandList({
|
| 79 |
+
className,
|
| 80 |
+
...props
|
| 81 |
+
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
| 82 |
+
return (
|
| 83 |
+
<CommandPrimitive.List
|
| 84 |
+
data-slot="command-list"
|
| 85 |
+
className={cn(
|
| 86 |
+
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
| 87 |
+
className,
|
| 88 |
+
)}
|
| 89 |
+
{...props}
|
| 90 |
+
/>
|
| 91 |
+
);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function CommandEmpty({
|
| 95 |
+
...props
|
| 96 |
+
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
| 97 |
+
return (
|
| 98 |
+
<CommandPrimitive.Empty
|
| 99 |
+
data-slot="command-empty"
|
| 100 |
+
className="py-6 text-center text-sm"
|
| 101 |
+
{...props}
|
| 102 |
+
/>
|
| 103 |
+
);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function CommandGroup({
|
| 107 |
+
className,
|
| 108 |
+
...props
|
| 109 |
+
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
| 110 |
+
return (
|
| 111 |
+
<CommandPrimitive.Group
|
| 112 |
+
data-slot="command-group"
|
| 113 |
+
className={cn(
|
| 114 |
+
"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",
|
| 115 |
+
className,
|
| 116 |
+
)}
|
| 117 |
+
{...props}
|
| 118 |
+
/>
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function CommandSeparator({
|
| 123 |
+
className,
|
| 124 |
+
...props
|
| 125 |
+
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
| 126 |
+
return (
|
| 127 |
+
<CommandPrimitive.Separator
|
| 128 |
+
data-slot="command-separator"
|
| 129 |
+
className={cn("bg-border -mx-1 h-px", className)}
|
| 130 |
+
{...props}
|
| 131 |
+
/>
|
| 132 |
+
);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function CommandItem({
|
| 136 |
+
className,
|
| 137 |
+
...props
|
| 138 |
+
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
| 139 |
+
return (
|
| 140 |
+
<CommandPrimitive.Item
|
| 141 |
+
data-slot="command-item"
|
| 142 |
+
className={cn(
|
| 143 |
+
"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",
|
| 144 |
+
className,
|
| 145 |
+
)}
|
| 146 |
+
{...props}
|
| 147 |
+
/>
|
| 148 |
+
);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function CommandShortcut({
|
| 152 |
+
className,
|
| 153 |
+
...props
|
| 154 |
+
}: React.ComponentProps<"span">) {
|
| 155 |
+
return (
|
| 156 |
+
<span
|
| 157 |
+
data-slot="command-shortcut"
|
| 158 |
+
className={cn(
|
| 159 |
+
"text-muted-foreground ml-auto text-xs tracking-widest",
|
| 160 |
+
className,
|
| 161 |
+
)}
|
| 162 |
+
{...props}
|
| 163 |
+
/>
|
| 164 |
+
);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
export {
|
| 168 |
+
Command,
|
| 169 |
+
CommandDialog,
|
| 170 |
+
CommandInput,
|
| 171 |
+
CommandList,
|
| 172 |
+
CommandEmpty,
|
| 173 |
+
CommandGroup,
|
| 174 |
+
CommandItem,
|
| 175 |
+
CommandShortcut,
|
| 176 |
+
CommandSeparator,
|
| 177 |
+
};
|
web/src/components/ui/context-menu.tsx
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu@2.2.6";
|
| 5 |
+
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
|
| 6 |
+
|
| 7 |
+
import { cn } from "./utils";
|
| 8 |
+
|
| 9 |
+
function ContextMenu({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
| 12 |
+
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function ContextMenuTrigger({
|
| 16 |
+
...props
|
| 17 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
| 18 |
+
return (
|
| 19 |
+
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function ContextMenuGroup({
|
| 24 |
+
...props
|
| 25 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
| 26 |
+
return (
|
| 27 |
+
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function ContextMenuPortal({
|
| 32 |
+
...props
|
| 33 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
| 34 |
+
return (
|
| 35 |
+
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
| 36 |
+
);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function ContextMenuSub({
|
| 40 |
+
...props
|
| 41 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
| 42 |
+
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function ContextMenuRadioGroup({
|
| 46 |
+
...props
|
| 47 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
| 48 |
+
return (
|
| 49 |
+
<ContextMenuPrimitive.RadioGroup
|
| 50 |
+
data-slot="context-menu-radio-group"
|
| 51 |
+
{...props}
|
| 52 |
+
/>
|
| 53 |
+
);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function ContextMenuSubTrigger({
|
| 57 |
+
className,
|
| 58 |
+
inset,
|
| 59 |
+
children,
|
| 60 |
+
...props
|
| 61 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
| 62 |
+
inset?: boolean;
|
| 63 |
+
}) {
|
| 64 |
+
return (
|
| 65 |
+
<ContextMenuPrimitive.SubTrigger
|
| 66 |
+
data-slot="context-menu-sub-trigger"
|
| 67 |
+
data-inset={inset}
|
| 68 |
+
className={cn(
|
| 69 |
+
"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",
|
| 70 |
+
className,
|
| 71 |
+
)}
|
| 72 |
+
{...props}
|
| 73 |
+
>
|
| 74 |
+
{children}
|
| 75 |
+
<ChevronRightIcon className="ml-auto" />
|
| 76 |
+
</ContextMenuPrimitive.SubTrigger>
|
| 77 |
+
);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function ContextMenuSubContent({
|
| 81 |
+
className,
|
| 82 |
+
...props
|
| 83 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
| 84 |
+
return (
|
| 85 |
+
<ContextMenuPrimitive.SubContent
|
| 86 |
+
data-slot="context-menu-sub-content"
|
| 87 |
+
className={cn(
|
| 88 |
+
"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",
|
| 89 |
+
className,
|
| 90 |
+
)}
|
| 91 |
+
{...props}
|
| 92 |
+
/>
|
| 93 |
+
);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function ContextMenuContent({
|
| 97 |
+
className,
|
| 98 |
+
...props
|
| 99 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
| 100 |
+
return (
|
| 101 |
+
<ContextMenuPrimitive.Portal>
|
| 102 |
+
<ContextMenuPrimitive.Content
|
| 103 |
+
data-slot="context-menu-content"
|
| 104 |
+
className={cn(
|
| 105 |
+
"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",
|
| 106 |
+
className,
|
| 107 |
+
)}
|
| 108 |
+
{...props}
|
| 109 |
+
/>
|
| 110 |
+
</ContextMenuPrimitive.Portal>
|
| 111 |
+
);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
function ContextMenuItem({
|
| 115 |
+
className,
|
| 116 |
+
inset,
|
| 117 |
+
variant = "default",
|
| 118 |
+
...props
|
| 119 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
| 120 |
+
inset?: boolean;
|
| 121 |
+
variant?: "default" | "destructive";
|
| 122 |
+
}) {
|
| 123 |
+
return (
|
| 124 |
+
<ContextMenuPrimitive.Item
|
| 125 |
+
data-slot="context-menu-item"
|
| 126 |
+
data-inset={inset}
|
| 127 |
+
data-variant={variant}
|
| 128 |
+
className={cn(
|
| 129 |
+
"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",
|
| 130 |
+
className,
|
| 131 |
+
)}
|
| 132 |
+
{...props}
|
| 133 |
+
/>
|
| 134 |
+
);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function ContextMenuCheckboxItem({
|
| 138 |
+
className,
|
| 139 |
+
children,
|
| 140 |
+
checked,
|
| 141 |
+
...props
|
| 142 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
| 143 |
+
return (
|
| 144 |
+
<ContextMenuPrimitive.CheckboxItem
|
| 145 |
+
data-slot="context-menu-checkbox-item"
|
| 146 |
+
className={cn(
|
| 147 |
+
"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",
|
| 148 |
+
className,
|
| 149 |
+
)}
|
| 150 |
+
checked={checked}
|
| 151 |
+
{...props}
|
| 152 |
+
>
|
| 153 |
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
| 154 |
+
<ContextMenuPrimitive.ItemIndicator>
|
| 155 |
+
<CheckIcon className="size-4" />
|
| 156 |
+
</ContextMenuPrimitive.ItemIndicator>
|
| 157 |
+
</span>
|
| 158 |
+
{children}
|
| 159 |
+
</ContextMenuPrimitive.CheckboxItem>
|
| 160 |
+
);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
function ContextMenuRadioItem({
|
| 164 |
+
className,
|
| 165 |
+
children,
|
| 166 |
+
...props
|
| 167 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
| 168 |
+
return (
|
| 169 |
+
<ContextMenuPrimitive.RadioItem
|
| 170 |
+
data-slot="context-menu-radio-item"
|
| 171 |
+
className={cn(
|
| 172 |
+
"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",
|
| 173 |
+
className,
|
| 174 |
+
)}
|
| 175 |
+
{...props}
|
| 176 |
+
>
|
| 177 |
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
| 178 |
+
<ContextMenuPrimitive.ItemIndicator>
|
| 179 |
+
<CircleIcon className="size-2 fill-current" />
|
| 180 |
+
</ContextMenuPrimitive.ItemIndicator>
|
| 181 |
+
</span>
|
| 182 |
+
{children}
|
| 183 |
+
</ContextMenuPrimitive.RadioItem>
|
| 184 |
+
);
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
function ContextMenuLabel({
|
| 188 |
+
className,
|
| 189 |
+
inset,
|
| 190 |
+
...props
|
| 191 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
| 192 |
+
inset?: boolean;
|
| 193 |
+
}) {
|
| 194 |
+
return (
|
| 195 |
+
<ContextMenuPrimitive.Label
|
| 196 |
+
data-slot="context-menu-label"
|
| 197 |
+
data-inset={inset}
|
| 198 |
+
className={cn(
|
| 199 |
+
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
| 200 |
+
className,
|
| 201 |
+
)}
|
| 202 |
+
{...props}
|
| 203 |
+
/>
|
| 204 |
+
);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
function ContextMenuSeparator({
|
| 208 |
+
className,
|
| 209 |
+
...props
|
| 210 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
| 211 |
+
return (
|
| 212 |
+
<ContextMenuPrimitive.Separator
|
| 213 |
+
data-slot="context-menu-separator"
|
| 214 |
+
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
| 215 |
+
{...props}
|
| 216 |
+
/>
|
| 217 |
+
);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function ContextMenuShortcut({
|
| 221 |
+
className,
|
| 222 |
+
...props
|
| 223 |
+
}: React.ComponentProps<"span">) {
|
| 224 |
+
return (
|
| 225 |
+
<span
|
| 226 |
+
data-slot="context-menu-shortcut"
|
| 227 |
+
className={cn(
|
| 228 |
+
"text-muted-foreground ml-auto text-xs tracking-widest",
|
| 229 |
+
className,
|
| 230 |
+
)}
|
| 231 |
+
{...props}
|
| 232 |
+
/>
|
| 233 |
+
);
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
export {
|
| 237 |
+
ContextMenu,
|
| 238 |
+
ContextMenuTrigger,
|
| 239 |
+
ContextMenuContent,
|
| 240 |
+
ContextMenuItem,
|
| 241 |
+
ContextMenuCheckboxItem,
|
| 242 |
+
ContextMenuRadioItem,
|
| 243 |
+
ContextMenuLabel,
|
| 244 |
+
ContextMenuSeparator,
|
| 245 |
+
ContextMenuShortcut,
|
| 246 |
+
ContextMenuGroup,
|
| 247 |
+
ContextMenuPortal,
|
| 248 |
+
ContextMenuSub,
|
| 249 |
+
ContextMenuSubContent,
|
| 250 |
+
ContextMenuSubTrigger,
|
| 251 |
+
ContextMenuRadioGroup,
|
| 252 |
+
};
|
web/src/components/ui/dialog.tsx
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as DialogPrimitive from "@radix-ui/react-dialog@1.1.6";
|
| 5 |
+
import { XIcon } from "lucide-react@0.487.0";
|
| 6 |
+
|
| 7 |
+
import { cn } from "./utils";
|
| 8 |
+
|
| 9 |
+
function Dialog({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
| 12 |
+
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function DialogTrigger({
|
| 16 |
+
...props
|
| 17 |
+
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
| 18 |
+
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function DialogPortal({
|
| 22 |
+
...props
|
| 23 |
+
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
| 24 |
+
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function DialogClose({
|
| 28 |
+
...props
|
| 29 |
+
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
| 30 |
+
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const DialogOverlay = React.forwardRef<
|
| 34 |
+
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
| 35 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
| 36 |
+
>(({ className, ...props }, ref) => {
|
| 37 |
+
return (
|
| 38 |
+
<DialogPrimitive.Overlay
|
| 39 |
+
ref={ref}
|
| 40 |
+
data-slot="dialog-overlay"
|
| 41 |
+
className={cn(
|
| 42 |
+
"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",
|
| 43 |
+
className,
|
| 44 |
+
)}
|
| 45 |
+
{...props}
|
| 46 |
+
/>
|
| 47 |
+
);
|
| 48 |
+
});
|
| 49 |
+
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
| 50 |
+
|
| 51 |
+
function DialogContent({
|
| 52 |
+
className,
|
| 53 |
+
children,
|
| 54 |
+
...props
|
| 55 |
+
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
| 56 |
+
return (
|
| 57 |
+
<DialogPortal data-slot="dialog-portal">
|
| 58 |
+
<DialogOverlay />
|
| 59 |
+
<DialogPrimitive.Content
|
| 60 |
+
data-slot="dialog-content"
|
| 61 |
+
className={cn(
|
| 62 |
+
"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",
|
| 63 |
+
className,
|
| 64 |
+
)}
|
| 65 |
+
{...props}
|
| 66 |
+
>
|
| 67 |
+
{children}
|
| 68 |
+
<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">
|
| 69 |
+
<XIcon />
|
| 70 |
+
<span className="sr-only">Close</span>
|
| 71 |
+
</DialogPrimitive.Close>
|
| 72 |
+
</DialogPrimitive.Content>
|
| 73 |
+
</DialogPortal>
|
| 74 |
+
);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 78 |
+
return (
|
| 79 |
+
<div
|
| 80 |
+
data-slot="dialog-header"
|
| 81 |
+
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
| 82 |
+
{...props}
|
| 83 |
+
/>
|
| 84 |
+
);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
| 88 |
+
return (
|
| 89 |
+
<div
|
| 90 |
+
data-slot="dialog-footer"
|
| 91 |
+
className={cn(
|
| 92 |
+
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
| 93 |
+
className,
|
| 94 |
+
)}
|
| 95 |
+
{...props}
|
| 96 |
+
/>
|
| 97 |
+
);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
function DialogTitle({
|
| 101 |
+
className,
|
| 102 |
+
...props
|
| 103 |
+
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
| 104 |
+
return (
|
| 105 |
+
<DialogPrimitive.Title
|
| 106 |
+
data-slot="dialog-title"
|
| 107 |
+
className={cn("text-lg leading-none font-semibold", className)}
|
| 108 |
+
{...props}
|
| 109 |
+
/>
|
| 110 |
+
);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function DialogDescription({
|
| 114 |
+
className,
|
| 115 |
+
...props
|
| 116 |
+
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
| 117 |
+
return (
|
| 118 |
+
<DialogPrimitive.Description
|
| 119 |
+
data-slot="dialog-description"
|
| 120 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 121 |
+
{...props}
|
| 122 |
+
/>
|
| 123 |
+
);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
export {
|
| 127 |
+
Dialog,
|
| 128 |
+
DialogClose,
|
| 129 |
+
DialogContent,
|
| 130 |
+
DialogDescription,
|
| 131 |
+
DialogFooter,
|
| 132 |
+
DialogHeader,
|
| 133 |
+
DialogOverlay,
|
| 134 |
+
DialogPortal,
|
| 135 |
+
DialogTitle,
|
| 136 |
+
DialogTrigger,
|
| 137 |
+
};
|
web/src/components/ui/drawer.tsx
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import { Drawer as DrawerPrimitive } from "vaul@1.1.2";
|
| 5 |
+
|
| 6 |
+
import { cn } from "./utils";
|
| 7 |
+
|
| 8 |
+
function Drawer({
|
| 9 |
+
...props
|
| 10 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
| 11 |
+
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function DrawerTrigger({
|
| 15 |
+
...props
|
| 16 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
| 17 |
+
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function DrawerPortal({
|
| 21 |
+
...props
|
| 22 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
| 23 |
+
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function DrawerClose({
|
| 27 |
+
...props
|
| 28 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
| 29 |
+
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function DrawerOverlay({
|
| 33 |
+
className,
|
| 34 |
+
...props
|
| 35 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
| 36 |
+
return (
|
| 37 |
+
<DrawerPrimitive.Overlay
|
| 38 |
+
data-slot="drawer-overlay"
|
| 39 |
+
className={cn(
|
| 40 |
+
"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",
|
| 41 |
+
className,
|
| 42 |
+
)}
|
| 43 |
+
{...props}
|
| 44 |
+
/>
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function DrawerContent({
|
| 49 |
+
className,
|
| 50 |
+
children,
|
| 51 |
+
...props
|
| 52 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
| 53 |
+
return (
|
| 54 |
+
<DrawerPortal data-slot="drawer-portal">
|
| 55 |
+
<DrawerOverlay />
|
| 56 |
+
<DrawerPrimitive.Content
|
| 57 |
+
data-slot="drawer-content"
|
| 58 |
+
className={cn(
|
| 59 |
+
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
| 60 |
+
"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",
|
| 61 |
+
"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",
|
| 62 |
+
"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",
|
| 63 |
+
"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",
|
| 64 |
+
className,
|
| 65 |
+
)}
|
| 66 |
+
{...props}
|
| 67 |
+
>
|
| 68 |
+
<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" />
|
| 69 |
+
{children}
|
| 70 |
+
</DrawerPrimitive.Content>
|
| 71 |
+
</DrawerPortal>
|
| 72 |
+
);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 76 |
+
return (
|
| 77 |
+
<div
|
| 78 |
+
data-slot="drawer-header"
|
| 79 |
+
className={cn("flex flex-col gap-1.5 p-4", className)}
|
| 80 |
+
{...props}
|
| 81 |
+
/>
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
| 86 |
+
return (
|
| 87 |
+
<div
|
| 88 |
+
data-slot="drawer-footer"
|
| 89 |
+
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
| 90 |
+
{...props}
|
| 91 |
+
/>
|
| 92 |
+
);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function DrawerTitle({
|
| 96 |
+
className,
|
| 97 |
+
...props
|
| 98 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
| 99 |
+
return (
|
| 100 |
+
<DrawerPrimitive.Title
|
| 101 |
+
data-slot="drawer-title"
|
| 102 |
+
className={cn("text-foreground font-semibold", className)}
|
| 103 |
+
{...props}
|
| 104 |
+
/>
|
| 105 |
+
);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function DrawerDescription({
|
| 109 |
+
className,
|
| 110 |
+
...props
|
| 111 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
| 112 |
+
return (
|
| 113 |
+
<DrawerPrimitive.Description
|
| 114 |
+
data-slot="drawer-description"
|
| 115 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 116 |
+
{...props}
|
| 117 |
+
/>
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
export {
|
| 122 |
+
Drawer,
|
| 123 |
+
DrawerPortal,
|
| 124 |
+
DrawerOverlay,
|
| 125 |
+
DrawerTrigger,
|
| 126 |
+
DrawerClose,
|
| 127 |
+
DrawerContent,
|
| 128 |
+
DrawerHeader,
|
| 129 |
+
DrawerFooter,
|
| 130 |
+
DrawerTitle,
|
| 131 |
+
DrawerDescription,
|
| 132 |
+
};
|
web/src/components/ui/dropdown-menu.tsx
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu@2.1.6";
|
| 5 |
+
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
|
| 6 |
+
|
| 7 |
+
import { cn } from "./utils";
|
| 8 |
+
|
| 9 |
+
function DropdownMenu({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
| 12 |
+
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function DropdownMenuPortal({
|
| 16 |
+
...props
|
| 17 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
| 18 |
+
return (
|
| 19 |
+
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function DropdownMenuTrigger({
|
| 24 |
+
...props
|
| 25 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
| 26 |
+
return (
|
| 27 |
+
<DropdownMenuPrimitive.Trigger
|
| 28 |
+
data-slot="dropdown-menu-trigger"
|
| 29 |
+
{...props}
|
| 30 |
+
/>
|
| 31 |
+
);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function DropdownMenuContent({
|
| 35 |
+
className,
|
| 36 |
+
sideOffset = 4,
|
| 37 |
+
...props
|
| 38 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
| 39 |
+
return (
|
| 40 |
+
<DropdownMenuPrimitive.Portal>
|
| 41 |
+
<DropdownMenuPrimitive.Content
|
| 42 |
+
data-slot="dropdown-menu-content"
|
| 43 |
+
sideOffset={sideOffset}
|
| 44 |
+
className={cn(
|
| 45 |
+
"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",
|
| 46 |
+
className,
|
| 47 |
+
)}
|
| 48 |
+
{...props}
|
| 49 |
+
/>
|
| 50 |
+
</DropdownMenuPrimitive.Portal>
|
| 51 |
+
);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function DropdownMenuGroup({
|
| 55 |
+
...props
|
| 56 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
| 57 |
+
return (
|
| 58 |
+
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
| 59 |
+
);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function DropdownMenuItem({
|
| 63 |
+
className,
|
| 64 |
+
inset,
|
| 65 |
+
variant = "default",
|
| 66 |
+
...props
|
| 67 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
| 68 |
+
inset?: boolean;
|
| 69 |
+
variant?: "default" | "destructive";
|
| 70 |
+
}) {
|
| 71 |
+
return (
|
| 72 |
+
<DropdownMenuPrimitive.Item
|
| 73 |
+
data-slot="dropdown-menu-item"
|
| 74 |
+
data-inset={inset}
|
| 75 |
+
data-variant={variant}
|
| 76 |
+
className={cn(
|
| 77 |
+
"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",
|
| 78 |
+
className,
|
| 79 |
+
)}
|
| 80 |
+
{...props}
|
| 81 |
+
/>
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function DropdownMenuCheckboxItem({
|
| 86 |
+
className,
|
| 87 |
+
children,
|
| 88 |
+
checked,
|
| 89 |
+
...props
|
| 90 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
| 91 |
+
return (
|
| 92 |
+
<DropdownMenuPrimitive.CheckboxItem
|
| 93 |
+
data-slot="dropdown-menu-checkbox-item"
|
| 94 |
+
className={cn(
|
| 95 |
+
"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",
|
| 96 |
+
className,
|
| 97 |
+
)}
|
| 98 |
+
checked={checked}
|
| 99 |
+
{...props}
|
| 100 |
+
>
|
| 101 |
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
| 102 |
+
<DropdownMenuPrimitive.ItemIndicator>
|
| 103 |
+
<CheckIcon className="size-4" />
|
| 104 |
+
</DropdownMenuPrimitive.ItemIndicator>
|
| 105 |
+
</span>
|
| 106 |
+
{children}
|
| 107 |
+
</DropdownMenuPrimitive.CheckboxItem>
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function DropdownMenuRadioGroup({
|
| 112 |
+
...props
|
| 113 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
| 114 |
+
return (
|
| 115 |
+
<DropdownMenuPrimitive.RadioGroup
|
| 116 |
+
data-slot="dropdown-menu-radio-group"
|
| 117 |
+
{...props}
|
| 118 |
+
/>
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function DropdownMenuRadioItem({
|
| 123 |
+
className,
|
| 124 |
+
children,
|
| 125 |
+
...props
|
| 126 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
| 127 |
+
return (
|
| 128 |
+
<DropdownMenuPrimitive.RadioItem
|
| 129 |
+
data-slot="dropdown-menu-radio-item"
|
| 130 |
+
className={cn(
|
| 131 |
+
"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",
|
| 132 |
+
className,
|
| 133 |
+
)}
|
| 134 |
+
{...props}
|
| 135 |
+
>
|
| 136 |
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
| 137 |
+
<DropdownMenuPrimitive.ItemIndicator>
|
| 138 |
+
<CircleIcon className="size-2 fill-current" />
|
| 139 |
+
</DropdownMenuPrimitive.ItemIndicator>
|
| 140 |
+
</span>
|
| 141 |
+
{children}
|
| 142 |
+
</DropdownMenuPrimitive.RadioItem>
|
| 143 |
+
);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
function DropdownMenuLabel({
|
| 147 |
+
className,
|
| 148 |
+
inset,
|
| 149 |
+
...props
|
| 150 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
| 151 |
+
inset?: boolean;
|
| 152 |
+
}) {
|
| 153 |
+
return (
|
| 154 |
+
<DropdownMenuPrimitive.Label
|
| 155 |
+
data-slot="dropdown-menu-label"
|
| 156 |
+
data-inset={inset}
|
| 157 |
+
className={cn(
|
| 158 |
+
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
| 159 |
+
className,
|
| 160 |
+
)}
|
| 161 |
+
{...props}
|
| 162 |
+
/>
|
| 163 |
+
);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function DropdownMenuSeparator({
|
| 167 |
+
className,
|
| 168 |
+
...props
|
| 169 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
| 170 |
+
return (
|
| 171 |
+
<DropdownMenuPrimitive.Separator
|
| 172 |
+
data-slot="dropdown-menu-separator"
|
| 173 |
+
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
| 174 |
+
{...props}
|
| 175 |
+
/>
|
| 176 |
+
);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function DropdownMenuShortcut({
|
| 180 |
+
className,
|
| 181 |
+
...props
|
| 182 |
+
}: React.ComponentProps<"span">) {
|
| 183 |
+
return (
|
| 184 |
+
<span
|
| 185 |
+
data-slot="dropdown-menu-shortcut"
|
| 186 |
+
className={cn(
|
| 187 |
+
"text-muted-foreground ml-auto text-xs tracking-widest",
|
| 188 |
+
className,
|
| 189 |
+
)}
|
| 190 |
+
{...props}
|
| 191 |
+
/>
|
| 192 |
+
);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
function DropdownMenuSub({
|
| 196 |
+
...props
|
| 197 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
| 198 |
+
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function DropdownMenuSubTrigger({
|
| 202 |
+
className,
|
| 203 |
+
inset,
|
| 204 |
+
children,
|
| 205 |
+
...props
|
| 206 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
| 207 |
+
inset?: boolean;
|
| 208 |
+
}) {
|
| 209 |
+
return (
|
| 210 |
+
<DropdownMenuPrimitive.SubTrigger
|
| 211 |
+
data-slot="dropdown-menu-sub-trigger"
|
| 212 |
+
data-inset={inset}
|
| 213 |
+
className={cn(
|
| 214 |
+
"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",
|
| 215 |
+
className,
|
| 216 |
+
)}
|
| 217 |
+
{...props}
|
| 218 |
+
>
|
| 219 |
+
{children}
|
| 220 |
+
<ChevronRightIcon className="ml-auto size-4" />
|
| 221 |
+
</DropdownMenuPrimitive.SubTrigger>
|
| 222 |
+
);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
function DropdownMenuSubContent({
|
| 226 |
+
className,
|
| 227 |
+
...props
|
| 228 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
| 229 |
+
return (
|
| 230 |
+
<DropdownMenuPrimitive.SubContent
|
| 231 |
+
data-slot="dropdown-menu-sub-content"
|
| 232 |
+
className={cn(
|
| 233 |
+
"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",
|
| 234 |
+
className,
|
| 235 |
+
)}
|
| 236 |
+
{...props}
|
| 237 |
+
/>
|
| 238 |
+
);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
export {
|
| 242 |
+
DropdownMenu,
|
| 243 |
+
DropdownMenuPortal,
|
| 244 |
+
DropdownMenuTrigger,
|
| 245 |
+
DropdownMenuContent,
|
| 246 |
+
DropdownMenuGroup,
|
| 247 |
+
DropdownMenuLabel,
|
| 248 |
+
DropdownMenuItem,
|
| 249 |
+
DropdownMenuCheckboxItem,
|
| 250 |
+
DropdownMenuRadioGroup,
|
| 251 |
+
DropdownMenuRadioItem,
|
| 252 |
+
DropdownMenuSeparator,
|
| 253 |
+
DropdownMenuShortcut,
|
| 254 |
+
DropdownMenuSub,
|
| 255 |
+
DropdownMenuSubTrigger,
|
| 256 |
+
DropdownMenuSubContent,
|
| 257 |
+
};
|
web/src/components/ui/form.tsx
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as LabelPrimitive from "@radix-ui/react-label@2.1.2";
|
| 5 |
+
import { Slot } from "@radix-ui/react-slot@1.1.2";
|
| 6 |
+
import {
|
| 7 |
+
Controller,
|
| 8 |
+
FormProvider,
|
| 9 |
+
useFormContext,
|
| 10 |
+
useFormState,
|
| 11 |
+
type ControllerProps,
|
| 12 |
+
type FieldPath,
|
| 13 |
+
type FieldValues,
|
| 14 |
+
} from "react-hook-form@7.55.0";
|
| 15 |
+
|
| 16 |
+
import { cn } from "./utils";
|
| 17 |
+
import { Label } from "./label";
|
| 18 |
+
|
| 19 |
+
const Form = FormProvider;
|
| 20 |
+
|
| 21 |
+
type FormFieldContextValue<
|
| 22 |
+
TFieldValues extends FieldValues = FieldValues,
|
| 23 |
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
| 24 |
+
> = {
|
| 25 |
+
name: TName;
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
| 29 |
+
{} as FormFieldContextValue,
|
| 30 |
+
);
|
| 31 |
+
|
| 32 |
+
const FormField = <
|
| 33 |
+
TFieldValues extends FieldValues = FieldValues,
|
| 34 |
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
| 35 |
+
>({
|
| 36 |
+
...props
|
| 37 |
+
}: ControllerProps<TFieldValues, TName>) => {
|
| 38 |
+
return (
|
| 39 |
+
<FormFieldContext.Provider value={{ name: props.name }}>
|
| 40 |
+
<Controller {...props} />
|
| 41 |
+
</FormFieldContext.Provider>
|
| 42 |
+
);
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const useFormField = () => {
|
| 46 |
+
const fieldContext = React.useContext(FormFieldContext);
|
| 47 |
+
const itemContext = React.useContext(FormItemContext);
|
| 48 |
+
const { getFieldState } = useFormContext();
|
| 49 |
+
const formState = useFormState({ name: fieldContext.name });
|
| 50 |
+
const fieldState = getFieldState(fieldContext.name, formState);
|
| 51 |
+
|
| 52 |
+
if (!fieldContext) {
|
| 53 |
+
throw new Error("useFormField should be used within <FormField>");
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const { id } = itemContext;
|
| 57 |
+
|
| 58 |
+
return {
|
| 59 |
+
id,
|
| 60 |
+
name: fieldContext.name,
|
| 61 |
+
formItemId: `${id}-form-item`,
|
| 62 |
+
formDescriptionId: `${id}-form-item-description`,
|
| 63 |
+
formMessageId: `${id}-form-item-message`,
|
| 64 |
+
...fieldState,
|
| 65 |
+
};
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
type FormItemContextValue = {
|
| 69 |
+
id: string;
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const FormItemContext = React.createContext<FormItemContextValue>(
|
| 73 |
+
{} as FormItemContextValue,
|
| 74 |
+
);
|
| 75 |
+
|
| 76 |
+
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
| 77 |
+
const id = React.useId();
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<FormItemContext.Provider value={{ id }}>
|
| 81 |
+
<div
|
| 82 |
+
data-slot="form-item"
|
| 83 |
+
className={cn("grid gap-2", className)}
|
| 84 |
+
{...props}
|
| 85 |
+
/>
|
| 86 |
+
</FormItemContext.Provider>
|
| 87 |
+
);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function FormLabel({
|
| 91 |
+
className,
|
| 92 |
+
...props
|
| 93 |
+
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
| 94 |
+
const { error, formItemId } = useFormField();
|
| 95 |
+
|
| 96 |
+
return (
|
| 97 |
+
<Label
|
| 98 |
+
data-slot="form-label"
|
| 99 |
+
data-error={!!error}
|
| 100 |
+
className={cn("data-[error=true]:text-destructive", className)}
|
| 101 |
+
htmlFor={formItemId}
|
| 102 |
+
{...props}
|
| 103 |
+
/>
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
| 108 |
+
const { error, formItemId, formDescriptionId, formMessageId } =
|
| 109 |
+
useFormField();
|
| 110 |
+
|
| 111 |
+
return (
|
| 112 |
+
<Slot
|
| 113 |
+
data-slot="form-control"
|
| 114 |
+
id={formItemId}
|
| 115 |
+
aria-describedby={
|
| 116 |
+
!error
|
| 117 |
+
? `${formDescriptionId}`
|
| 118 |
+
: `${formDescriptionId} ${formMessageId}`
|
| 119 |
+
}
|
| 120 |
+
aria-invalid={!!error}
|
| 121 |
+
{...props}
|
| 122 |
+
/>
|
| 123 |
+
);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
| 127 |
+
const { formDescriptionId } = useFormField();
|
| 128 |
+
|
| 129 |
+
return (
|
| 130 |
+
<p
|
| 131 |
+
data-slot="form-description"
|
| 132 |
+
id={formDescriptionId}
|
| 133 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 134 |
+
{...props}
|
| 135 |
+
/>
|
| 136 |
+
);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
| 140 |
+
const { error, formMessageId } = useFormField();
|
| 141 |
+
const body = error ? String(error?.message ?? "") : props.children;
|
| 142 |
+
|
| 143 |
+
if (!body) {
|
| 144 |
+
return null;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
return (
|
| 148 |
+
<p
|
| 149 |
+
data-slot="form-message"
|
| 150 |
+
id={formMessageId}
|
| 151 |
+
className={cn("text-destructive text-sm", className)}
|
| 152 |
+
{...props}
|
| 153 |
+
>
|
| 154 |
+
{body}
|
| 155 |
+
</p>
|
| 156 |
+
);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
export {
|
| 160 |
+
useFormField,
|
| 161 |
+
Form,
|
| 162 |
+
FormItem,
|
| 163 |
+
FormLabel,
|
| 164 |
+
FormControl,
|
| 165 |
+
FormDescription,
|
| 166 |
+
FormMessage,
|
| 167 |
+
FormField,
|
| 168 |
+
};
|
web/src/components/ui/hover-card.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as HoverCardPrimitive from "@radix-ui/react-hover-card@1.1.6";
|
| 5 |
+
|
| 6 |
+
import { cn } from "./utils";
|
| 7 |
+
|
| 8 |
+
function HoverCard({
|
| 9 |
+
...props
|
| 10 |
+
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
| 11 |
+
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function HoverCardTrigger({
|
| 15 |
+
...props
|
| 16 |
+
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
| 17 |
+
return (
|
| 18 |
+
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
| 19 |
+
);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function HoverCardContent({
|
| 23 |
+
className,
|
| 24 |
+
align = "center",
|
| 25 |
+
sideOffset = 4,
|
| 26 |
+
...props
|
| 27 |
+
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
| 28 |
+
return (
|
| 29 |
+
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
| 30 |
+
<HoverCardPrimitive.Content
|
| 31 |
+
data-slot="hover-card-content"
|
| 32 |
+
align={align}
|
| 33 |
+
sideOffset={sideOffset}
|
| 34 |
+
className={cn(
|
| 35 |
+
"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",
|
| 36 |
+
className,
|
| 37 |
+
)}
|
| 38 |
+
{...props}
|
| 39 |
+
/>
|
| 40 |
+
</HoverCardPrimitive.Portal>
|
| 41 |
+
);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
web/src/components/ui/input-otp.tsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import { OTPInput, OTPInputContext } from "input-otp@1.4.2";
|
| 5 |
+
import { MinusIcon } from "lucide-react@0.487.0";
|
| 6 |
+
|
| 7 |
+
import { cn } from "./utils";
|
| 8 |
+
|
| 9 |
+
function InputOTP({
|
| 10 |
+
className,
|
| 11 |
+
containerClassName,
|
| 12 |
+
...props
|
| 13 |
+
}: React.ComponentProps<typeof OTPInput> & {
|
| 14 |
+
containerClassName?: string;
|
| 15 |
+
}) {
|
| 16 |
+
return (
|
| 17 |
+
<OTPInput
|
| 18 |
+
data-slot="input-otp"
|
| 19 |
+
containerClassName={cn(
|
| 20 |
+
"flex items-center gap-2 has-disabled:opacity-50",
|
| 21 |
+
containerClassName,
|
| 22 |
+
)}
|
| 23 |
+
className={cn("disabled:cursor-not-allowed", className)}
|
| 24 |
+
{...props}
|
| 25 |
+
/>
|
| 26 |
+
);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
| 30 |
+
return (
|
| 31 |
+
<div
|
| 32 |
+
data-slot="input-otp-group"
|
| 33 |
+
className={cn("flex items-center gap-1", className)}
|
| 34 |
+
{...props}
|
| 35 |
+
/>
|
| 36 |
+
);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function InputOTPSlot({
|
| 40 |
+
index,
|
| 41 |
+
className,
|
| 42 |
+
...props
|
| 43 |
+
}: React.ComponentProps<"div"> & {
|
| 44 |
+
index: number;
|
| 45 |
+
}) {
|
| 46 |
+
const inputOTPContext = React.useContext(OTPInputContext);
|
| 47 |
+
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<div
|
| 51 |
+
data-slot="input-otp-slot"
|
| 52 |
+
data-active={isActive}
|
| 53 |
+
className={cn(
|
| 54 |
+
"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]",
|
| 55 |
+
className,
|
| 56 |
+
)}
|
| 57 |
+
{...props}
|
| 58 |
+
>
|
| 59 |
+
{char}
|
| 60 |
+
{hasFakeCaret && (
|
| 61 |
+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
| 62 |
+
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
| 63 |
+
</div>
|
| 64 |
+
)}
|
| 65 |
+
</div>
|
| 66 |
+
);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
| 70 |
+
return (
|
| 71 |
+
<div data-slot="input-otp-separator" role="separator" {...props}>
|
| 72 |
+
<MinusIcon />
|
| 73 |
+
</div>
|
| 74 |
+
);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
web/src/components/ui/input.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
|
| 3 |
+
import { cn } from "./utils";
|
| 4 |
+
|
| 5 |
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
| 6 |
+
return (
|
| 7 |
+
<input
|
| 8 |
+
type={type}
|
| 9 |
+
data-slot="input"
|
| 10 |
+
className={cn(
|
| 11 |
+
"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",
|
| 12 |
+
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
| 13 |
+
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
| 14 |
+
className,
|
| 15 |
+
)}
|
| 16 |
+
{...props}
|
| 17 |
+
/>
|
| 18 |
+
);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export { Input };
|
web/src/components/ui/label.tsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as LabelPrimitive from "@radix-ui/react-label@2.1.2";
|
| 5 |
+
|
| 6 |
+
import { cn } from "./utils";
|
| 7 |
+
|
| 8 |
+
function Label({
|
| 9 |
+
className,
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
| 12 |
+
return (
|
| 13 |
+
<LabelPrimitive.Root
|
| 14 |
+
data-slot="label"
|
| 15 |
+
className={cn(
|
| 16 |
+
"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",
|
| 17 |
+
className,
|
| 18 |
+
)}
|
| 19 |
+
{...props}
|
| 20 |
+
/>
|
| 21 |
+
);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export { Label };
|
web/src/components/ui/menubar.tsx
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as MenubarPrimitive from "@radix-ui/react-menubar@1.1.6";
|
| 5 |
+
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
|
| 6 |
+
|
| 7 |
+
import { cn } from "./utils";
|
| 8 |
+
|
| 9 |
+
function Menubar({
|
| 10 |
+
className,
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
| 13 |
+
return (
|
| 14 |
+
<MenubarPrimitive.Root
|
| 15 |
+
data-slot="menubar"
|
| 16 |
+
className={cn(
|
| 17 |
+
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
|
| 18 |
+
className,
|
| 19 |
+
)}
|
| 20 |
+
{...props}
|
| 21 |
+
/>
|
| 22 |
+
);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function MenubarMenu({
|
| 26 |
+
...props
|
| 27 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
| 28 |
+
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function MenubarGroup({
|
| 32 |
+
...props
|
| 33 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
| 34 |
+
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function MenubarPortal({
|
| 38 |
+
...props
|
| 39 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
| 40 |
+
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function MenubarRadioGroup({
|
| 44 |
+
...props
|
| 45 |
+
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
| 46 |
+
return (
|
| 47 |
+
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
| 48 |
+
);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function MenubarTrigger({
|
| 52 |
+
className,
|
| 53 |
+
...props
|
| 54 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
|
| 55 |
+
return (
|
| 56 |
+
<MenubarPrimitive.Trigger
|
| 57 |
+
data-slot="menubar-trigger"
|
| 58 |
+
className={cn(
|
| 59 |
+
"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",
|
| 60 |
+
className,
|
| 61 |
+
)}
|
| 62 |
+
{...props}
|
| 63 |
+
/>
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function MenubarContent({
|
| 68 |
+
className,
|
| 69 |
+
align = "start",
|
| 70 |
+
alignOffset = -4,
|
| 71 |
+
sideOffset = 8,
|
| 72 |
+
...props
|
| 73 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
|
| 74 |
+
return (
|
| 75 |
+
<MenubarPortal>
|
| 76 |
+
<MenubarPrimitive.Content
|
| 77 |
+
data-slot="menubar-content"
|
| 78 |
+
align={align}
|
| 79 |
+
alignOffset={alignOffset}
|
| 80 |
+
sideOffset={sideOffset}
|
| 81 |
+
className={cn(
|
| 82 |
+
"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",
|
| 83 |
+
className,
|
| 84 |
+
)}
|
| 85 |
+
{...props}
|
| 86 |
+
/>
|
| 87 |
+
</MenubarPortal>
|
| 88 |
+
);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function MenubarItem({
|
| 92 |
+
className,
|
| 93 |
+
inset,
|
| 94 |
+
variant = "default",
|
| 95 |
+
...props
|
| 96 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
| 97 |
+
inset?: boolean;
|
| 98 |
+
variant?: "default" | "destructive";
|
| 99 |
+
}) {
|
| 100 |
+
return (
|
| 101 |
+
<MenubarPrimitive.Item
|
| 102 |
+
data-slot="menubar-item"
|
| 103 |
+
data-inset={inset}
|
| 104 |
+
data-variant={variant}
|
| 105 |
+
className={cn(
|
| 106 |
+
"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",
|
| 107 |
+
className,
|
| 108 |
+
)}
|
| 109 |
+
{...props}
|
| 110 |
+
/>
|
| 111 |
+
);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
function MenubarCheckboxItem({
|
| 115 |
+
className,
|
| 116 |
+
children,
|
| 117 |
+
checked,
|
| 118 |
+
...props
|
| 119 |
+
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
|
| 120 |
+
return (
|
| 121 |
+
<MenubarPrimitive.CheckboxItem
|
| 122 |
+
data-slot="menubar-checkbox-item"
|
| 123 |
+
className={cn(
|
| 124 |
+
"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",
|
| 125 |
+
className,
|
| 126 |
+
)}
|
| 127 |
+
checked={checked}
|
| 128 |
+
{...props}
|
| 129 |
+
>
|
| 130 |
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
| 131 |
+
<MenubarPrimitive.ItemIndicator>
|
| 132 |
+
<CheckIcon className="size-4" />
|
| 133 |
+
</MenubarPrimitive.ItemIndicator>
|
| 134 |
+
</span>
|
| 135 |
+
{children}
|
| 136 |
+
</MenubarPrimitive.CheckboxItem>
|
| 137 |
+
);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
function MenubarRadioItem({
|
| 141 |
+
className,
|
| 142 |
+
children,
|
| 143 |
+
...props
|
| 144 |
+
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
|
| 145 |
+
return (
|
| 146 |
+
<MenubarPrimitive.RadioItem
|
| 147 |
+
data-slot="menubar-radio-item"
|
| 148 |
+
className={cn(
|
| 149 |
+
"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",
|
| 150 |
+
className,
|
| 151 |
+
)}
|
| 152 |
+
{...props}
|
| 153 |
+
>
|
| 154 |
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
| 155 |
+
<MenubarPrimitive.ItemIndicator>
|
| 156 |
+
<CircleIcon className="size-2 fill-current" />
|
| 157 |
+
</MenubarPrimitive.ItemIndicator>
|
| 158 |
+
</span>
|
| 159 |
+
{children}
|
| 160 |
+
</MenubarPrimitive.RadioItem>
|
| 161 |
+
);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
function MenubarLabel({
|
| 165 |
+
className,
|
| 166 |
+
inset,
|
| 167 |
+
...props
|
| 168 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
| 169 |
+
inset?: boolean;
|
| 170 |
+
}) {
|
| 171 |
+
return (
|
| 172 |
+
<MenubarPrimitive.Label
|
| 173 |
+
data-slot="menubar-label"
|
| 174 |
+
data-inset={inset}
|
| 175 |
+
className={cn(
|
| 176 |
+
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
| 177 |
+
className,
|
| 178 |
+
)}
|
| 179 |
+
{...props}
|
| 180 |
+
/>
|
| 181 |
+
);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
function MenubarSeparator({
|
| 185 |
+
className,
|
| 186 |
+
...props
|
| 187 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
|
| 188 |
+
return (
|
| 189 |
+
<MenubarPrimitive.Separator
|
| 190 |
+
data-slot="menubar-separator"
|
| 191 |
+
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
| 192 |
+
{...props}
|
| 193 |
+
/>
|
| 194 |
+
);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function MenubarShortcut({
|
| 198 |
+
className,
|
| 199 |
+
...props
|
| 200 |
+
}: React.ComponentProps<"span">) {
|
| 201 |
+
return (
|
| 202 |
+
<span
|
| 203 |
+
data-slot="menubar-shortcut"
|
| 204 |
+
className={cn(
|
| 205 |
+
"text-muted-foreground ml-auto text-xs tracking-widest",
|
| 206 |
+
className,
|
| 207 |
+
)}
|
| 208 |
+
{...props}
|
| 209 |
+
/>
|
| 210 |
+
);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
function MenubarSub({
|
| 214 |
+
...props
|
| 215 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
| 216 |
+
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
function MenubarSubTrigger({
|
| 220 |
+
className,
|
| 221 |
+
inset,
|
| 222 |
+
children,
|
| 223 |
+
...props
|
| 224 |
+
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
| 225 |
+
inset?: boolean;
|
| 226 |
+
}) {
|
| 227 |
+
return (
|
| 228 |
+
<MenubarPrimitive.SubTrigger
|
| 229 |
+
data-slot="menubar-sub-trigger"
|
| 230 |
+
data-inset={inset}
|
| 231 |
+
className={cn(
|
| 232 |
+
"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",
|
| 233 |
+
className,
|
| 234 |
+
)}
|
| 235 |
+
{...props}
|
| 236 |
+
>
|
| 237 |
+
{children}
|
| 238 |
+
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
| 239 |
+
</MenubarPrimitive.SubTrigger>
|
| 240 |
+
);
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
function MenubarSubContent({
|
| 244 |
+
className,
|
| 245 |
+
...props
|
| 246 |
+
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
| 247 |
+
return (
|
| 248 |
+
<MenubarPrimitive.SubContent
|
| 249 |
+
data-slot="menubar-sub-content"
|
| 250 |
+
className={cn(
|
| 251 |
+
"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",
|
| 252 |
+
className,
|
| 253 |
+
)}
|
| 254 |
+
{...props}
|
| 255 |
+
/>
|
| 256 |
+
);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
export {
|
| 260 |
+
Menubar,
|
| 261 |
+
MenubarPortal,
|
| 262 |
+
MenubarMenu,
|
| 263 |
+
MenubarTrigger,
|
| 264 |
+
MenubarContent,
|
| 265 |
+
MenubarGroup,
|
| 266 |
+
MenubarSeparator,
|
| 267 |
+
MenubarLabel,
|
| 268 |
+
MenubarItem,
|
| 269 |
+
MenubarShortcut,
|
| 270 |
+
MenubarCheckboxItem,
|
| 271 |
+
MenubarRadioGroup,
|
| 272 |
+
MenubarRadioItem,
|
| 273 |
+
MenubarSub,
|
| 274 |
+
MenubarSubTrigger,
|
| 275 |
+
MenubarSubContent,
|
| 276 |
+
};
|
web/src/components/ui/navigation-menu.tsx
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu@1.2.5";
|
| 3 |
+
import { cva } from "class-variance-authority@0.7.1";
|
| 4 |
+
import { ChevronDownIcon } from "lucide-react@0.487.0";
|
| 5 |
+
|
| 6 |
+
import { cn } from "./utils";
|
| 7 |
+
|
| 8 |
+
function NavigationMenu({
|
| 9 |
+
className,
|
| 10 |
+
children,
|
| 11 |
+
viewport = true,
|
| 12 |
+
...props
|
| 13 |
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
| 14 |
+
viewport?: boolean;
|
| 15 |
+
}) {
|
| 16 |
+
return (
|
| 17 |
+
<NavigationMenuPrimitive.Root
|
| 18 |
+
data-slot="navigation-menu"
|
| 19 |
+
data-viewport={viewport}
|
| 20 |
+
className={cn(
|
| 21 |
+
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
| 22 |
+
className,
|
| 23 |
+
)}
|
| 24 |
+
{...props}
|
| 25 |
+
>
|
| 26 |
+
{children}
|
| 27 |
+
{viewport && <NavigationMenuViewport />}
|
| 28 |
+
</NavigationMenuPrimitive.Root>
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function NavigationMenuList({
|
| 33 |
+
className,
|
| 34 |
+
...props
|
| 35 |
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
| 36 |
+
return (
|
| 37 |
+
<NavigationMenuPrimitive.List
|
| 38 |
+
data-slot="navigation-menu-list"
|
| 39 |
+
className={cn(
|
| 40 |
+
"group flex flex-1 list-none items-center justify-center gap-1",
|
| 41 |
+
className,
|
| 42 |
+
)}
|
| 43 |
+
{...props}
|
| 44 |
+
/>
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function NavigationMenuItem({
|
| 49 |
+
className,
|
| 50 |
+
...props
|
| 51 |
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
| 52 |
+
return (
|
| 53 |
+
<NavigationMenuPrimitive.Item
|
| 54 |
+
data-slot="navigation-menu-item"
|
| 55 |
+
className={cn("relative", className)}
|
| 56 |
+
{...props}
|
| 57 |
+
/>
|
| 58 |
+
);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const navigationMenuTriggerStyle = cva(
|
| 62 |
+
"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",
|
| 63 |
+
);
|
| 64 |
+
|
| 65 |
+
function NavigationMenuTrigger({
|
| 66 |
+
className,
|
| 67 |
+
children,
|
| 68 |
+
...props
|
| 69 |
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
| 70 |
+
return (
|
| 71 |
+
<NavigationMenuPrimitive.Trigger
|
| 72 |
+
data-slot="navigation-menu-trigger"
|
| 73 |
+
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
| 74 |
+
{...props}
|
| 75 |
+
>
|
| 76 |
+
{children}{" "}
|
| 77 |
+
<ChevronDownIcon
|
| 78 |
+
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
| 79 |
+
aria-hidden="true"
|
| 80 |
+
/>
|
| 81 |
+
</NavigationMenuPrimitive.Trigger>
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function NavigationMenuContent({
|
| 86 |
+
className,
|
| 87 |
+
...props
|
| 88 |
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
| 89 |
+
return (
|
| 90 |
+
<NavigationMenuPrimitive.Content
|
| 91 |
+
data-slot="navigation-menu-content"
|
| 92 |
+
className={cn(
|
| 93 |
+
"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",
|
| 94 |
+
"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",
|
| 95 |
+
className,
|
| 96 |
+
)}
|
| 97 |
+
{...props}
|
| 98 |
+
/>
|
| 99 |
+
);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function NavigationMenuViewport({
|
| 103 |
+
className,
|
| 104 |
+
...props
|
| 105 |
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
| 106 |
+
return (
|
| 107 |
+
<div
|
| 108 |
+
className={cn(
|
| 109 |
+
"absolute top-full left-0 isolate z-50 flex justify-center",
|
| 110 |
+
)}
|
| 111 |
+
>
|
| 112 |
+
<NavigationMenuPrimitive.Viewport
|
| 113 |
+
data-slot="navigation-menu-viewport"
|
| 114 |
+
className={cn(
|
| 115 |
+
"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)]",
|
| 116 |
+
className,
|
| 117 |
+
)}
|
| 118 |
+
{...props}
|
| 119 |
+
/>
|
| 120 |
+
</div>
|
| 121 |
+
);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
function NavigationMenuLink({
|
| 125 |
+
className,
|
| 126 |
+
...props
|
| 127 |
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
| 128 |
+
return (
|
| 129 |
+
<NavigationMenuPrimitive.Link
|
| 130 |
+
data-slot="navigation-menu-link"
|
| 131 |
+
className={cn(
|
| 132 |
+
"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",
|
| 133 |
+
className,
|
| 134 |
+
)}
|
| 135 |
+
{...props}
|
| 136 |
+
/>
|
| 137 |
+
);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
function NavigationMenuIndicator({
|
| 141 |
+
className,
|
| 142 |
+
...props
|
| 143 |
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
| 144 |
+
return (
|
| 145 |
+
<NavigationMenuPrimitive.Indicator
|
| 146 |
+
data-slot="navigation-menu-indicator"
|
| 147 |
+
className={cn(
|
| 148 |
+
"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",
|
| 149 |
+
className,
|
| 150 |
+
)}
|
| 151 |
+
{...props}
|
| 152 |
+
>
|
| 153 |
+
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
| 154 |
+
</NavigationMenuPrimitive.Indicator>
|
| 155 |
+
);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
export {
|
| 159 |
+
NavigationMenu,
|
| 160 |
+
NavigationMenuList,
|
| 161 |
+
NavigationMenuItem,
|
| 162 |
+
NavigationMenuContent,
|
| 163 |
+
NavigationMenuTrigger,
|
| 164 |
+
NavigationMenuLink,
|
| 165 |
+
NavigationMenuIndicator,
|
| 166 |
+
NavigationMenuViewport,
|
| 167 |
+
navigationMenuTriggerStyle,
|
| 168 |
+
};
|
web/src/components/ui/pagination.tsx
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import {
|
| 3 |
+
ChevronLeftIcon,
|
| 4 |
+
ChevronRightIcon,
|
| 5 |
+
MoreHorizontalIcon,
|
| 6 |
+
} from "lucide-react@0.487.0";
|
| 7 |
+
|
| 8 |
+
import { cn } from "./utils";
|
| 9 |
+
import { Button, buttonVariants } from "./button";
|
| 10 |
+
|
| 11 |
+
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
| 12 |
+
return (
|
| 13 |
+
<nav
|
| 14 |
+
role="navigation"
|
| 15 |
+
aria-label="pagination"
|
| 16 |
+
data-slot="pagination"
|
| 17 |
+
className={cn("mx-auto flex w-full justify-center", className)}
|
| 18 |
+
{...props}
|
| 19 |
+
/>
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function PaginationContent({
|
| 24 |
+
className,
|
| 25 |
+
...props
|
| 26 |
+
}: React.ComponentProps<"ul">) {
|
| 27 |
+
return (
|
| 28 |
+
<ul
|
| 29 |
+
data-slot="pagination-content"
|
| 30 |
+
className={cn("flex flex-row items-center gap-1", className)}
|
| 31 |
+
{...props}
|
| 32 |
+
/>
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
| 37 |
+
return <li data-slot="pagination-item" {...props} />;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
type PaginationLinkProps = {
|
| 41 |
+
isActive?: boolean;
|
| 42 |
+
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
| 43 |
+
React.ComponentProps<"a">;
|
| 44 |
+
|
| 45 |
+
function PaginationLink({
|
| 46 |
+
className,
|
| 47 |
+
isActive,
|
| 48 |
+
size = "icon",
|
| 49 |
+
...props
|
| 50 |
+
}: PaginationLinkProps) {
|
| 51 |
+
return (
|
| 52 |
+
<a
|
| 53 |
+
aria-current={isActive ? "page" : undefined}
|
| 54 |
+
data-slot="pagination-link"
|
| 55 |
+
data-active={isActive}
|
| 56 |
+
className={cn(
|
| 57 |
+
buttonVariants({
|
| 58 |
+
variant: isActive ? "outline" : "ghost",
|
| 59 |
+
size,
|
| 60 |
+
}),
|
| 61 |
+
className,
|
| 62 |
+
)}
|
| 63 |
+
{...props}
|
| 64 |
+
/>
|
| 65 |
+
);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function PaginationPrevious({
|
| 69 |
+
className,
|
| 70 |
+
...props
|
| 71 |
+
}: React.ComponentProps<typeof PaginationLink>) {
|
| 72 |
+
return (
|
| 73 |
+
<PaginationLink
|
| 74 |
+
aria-label="Go to previous page"
|
| 75 |
+
size="default"
|
| 76 |
+
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
| 77 |
+
{...props}
|
| 78 |
+
>
|
| 79 |
+
<ChevronLeftIcon />
|
| 80 |
+
<span className="hidden sm:block">Previous</span>
|
| 81 |
+
</PaginationLink>
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function PaginationNext({
|
| 86 |
+
className,
|
| 87 |
+
...props
|
| 88 |
+
}: React.ComponentProps<typeof PaginationLink>) {
|
| 89 |
+
return (
|
| 90 |
+
<PaginationLink
|
| 91 |
+
aria-label="Go to next page"
|
| 92 |
+
size="default"
|
| 93 |
+
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
| 94 |
+
{...props}
|
| 95 |
+
>
|
| 96 |
+
<span className="hidden sm:block">Next</span>
|
| 97 |
+
<ChevronRightIcon />
|
| 98 |
+
</PaginationLink>
|
| 99 |
+
);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function PaginationEllipsis({
|
| 103 |
+
className,
|
| 104 |
+
...props
|
| 105 |
+
}: React.ComponentProps<"span">) {
|
| 106 |
+
return (
|
| 107 |
+
<span
|
| 108 |
+
aria-hidden
|
| 109 |
+
data-slot="pagination-ellipsis"
|
| 110 |
+
className={cn("flex size-9 items-center justify-center", className)}
|
| 111 |
+
{...props}
|
| 112 |
+
>
|
| 113 |
+
<MoreHorizontalIcon className="size-4" />
|
| 114 |
+
<span className="sr-only">More pages</span>
|
| 115 |
+
</span>
|
| 116 |
+
);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
export {
|
| 120 |
+
Pagination,
|
| 121 |
+
PaginationContent,
|
| 122 |
+
PaginationLink,
|
| 123 |
+
PaginationItem,
|
| 124 |
+
PaginationPrevious,
|
| 125 |
+
PaginationNext,
|
| 126 |
+
PaginationEllipsis,
|
| 127 |
+
};
|
web/src/components/ui/popover.tsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as PopoverPrimitive from "@radix-ui/react-popover@1.1.6";
|
| 5 |
+
|
| 6 |
+
import { cn } from "./utils";
|
| 7 |
+
|
| 8 |
+
function Popover({
|
| 9 |
+
...props
|
| 10 |
+
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
| 11 |
+
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function PopoverTrigger({
|
| 15 |
+
...props
|
| 16 |
+
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
| 17 |
+
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function PopoverContent({
|
| 21 |
+
className,
|
| 22 |
+
align = "center",
|
| 23 |
+
sideOffset = 4,
|
| 24 |
+
...props
|
| 25 |
+
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
| 26 |
+
return (
|
| 27 |
+
<PopoverPrimitive.Portal>
|
| 28 |
+
<PopoverPrimitive.Content
|
| 29 |
+
data-slot="popover-content"
|
| 30 |
+
align={align}
|
| 31 |
+
sideOffset={sideOffset}
|
| 32 |
+
className={cn(
|
| 33 |
+
"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",
|
| 34 |
+
className,
|
| 35 |
+
)}
|
| 36 |
+
{...props}
|
| 37 |
+
/>
|
| 38 |
+
</PopoverPrimitive.Portal>
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function PopoverAnchor({
|
| 43 |
+
...props
|
| 44 |
+
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
| 45 |
+
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
web/src/components/ui/progress.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as ProgressPrimitive from "@radix-ui/react-progress@1.1.2";
|
| 5 |
+
|
| 6 |
+
import { cn } from "./utils";
|
| 7 |
+
|
| 8 |
+
function Progress({
|
| 9 |
+
className,
|
| 10 |
+
value,
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
| 13 |
+
return (
|
| 14 |
+
<ProgressPrimitive.Root
|
| 15 |
+
data-slot="progress"
|
| 16 |
+
className={cn(
|
| 17 |
+
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
| 18 |
+
className,
|
| 19 |
+
)}
|
| 20 |
+
{...props}
|
| 21 |
+
>
|
| 22 |
+
<ProgressPrimitive.Indicator
|
| 23 |
+
data-slot="progress-indicator"
|
| 24 |
+
className="bg-primary h-full w-full flex-1 transition-all"
|
| 25 |
+
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
| 26 |
+
/>
|
| 27 |
+
</ProgressPrimitive.Root>
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export { Progress };
|
web/src/components/ui/radio-group.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group@1.2.3";
|
| 5 |
+
import { CircleIcon } from "lucide-react@0.487.0";
|
| 6 |
+
|
| 7 |
+
import { cn } from "./utils";
|
| 8 |
+
|
| 9 |
+
function RadioGroup({
|
| 10 |
+
className,
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
| 13 |
+
return (
|
| 14 |
+
<RadioGroupPrimitive.Root
|
| 15 |
+
data-slot="radio-group"
|
| 16 |
+
className={cn("grid gap-3", className)}
|
| 17 |
+
{...props}
|
| 18 |
+
/>
|
| 19 |
+
);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function RadioGroupItem({
|
| 23 |
+
className,
|
| 24 |
+
...props
|
| 25 |
+
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
| 26 |
+
return (
|
| 27 |
+
<RadioGroupPrimitive.Item
|
| 28 |
+
data-slot="radio-group-item"
|
| 29 |
+
className={cn(
|
| 30 |
+
"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",
|
| 31 |
+
className,
|
| 32 |
+
)}
|
| 33 |
+
{...props}
|
| 34 |
+
>
|
| 35 |
+
<RadioGroupPrimitive.Indicator
|
| 36 |
+
data-slot="radio-group-indicator"
|
| 37 |
+
className="relative flex items-center justify-center"
|
| 38 |
+
>
|
| 39 |
+
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
| 40 |
+
</RadioGroupPrimitive.Indicator>
|
| 41 |
+
</RadioGroupPrimitive.Item>
|
| 42 |
+
);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export { RadioGroup, RadioGroupItem };
|
web/src/components/ui/resizable.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import { GripVerticalIcon } from "lucide-react@0.487.0";
|
| 5 |
+
import * as ResizablePrimitive from "react-resizable-panels@2.1.7";
|
| 6 |
+
|
| 7 |
+
import { cn } from "./utils";
|
| 8 |
+
|
| 9 |
+
function ResizablePanelGroup({
|
| 10 |
+
className,
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
| 13 |
+
return (
|
| 14 |
+
<ResizablePrimitive.PanelGroup
|
| 15 |
+
data-slot="resizable-panel-group"
|
| 16 |
+
className={cn(
|
| 17 |
+
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
| 18 |
+
className,
|
| 19 |
+
)}
|
| 20 |
+
{...props}
|
| 21 |
+
/>
|
| 22 |
+
);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function ResizablePanel({
|
| 26 |
+
...props
|
| 27 |
+
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
| 28 |
+
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function ResizableHandle({
|
| 32 |
+
withHandle,
|
| 33 |
+
className,
|
| 34 |
+
...props
|
| 35 |
+
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
| 36 |
+
withHandle?: boolean;
|
| 37 |
+
}) {
|
| 38 |
+
return (
|
| 39 |
+
<ResizablePrimitive.PanelResizeHandle
|
| 40 |
+
data-slot="resizable-handle"
|
| 41 |
+
className={cn(
|
| 42 |
+
"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",
|
| 43 |
+
className,
|
| 44 |
+
)}
|
| 45 |
+
{...props}
|
| 46 |
+
>
|
| 47 |
+
{withHandle && (
|
| 48 |
+
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
| 49 |
+
<GripVerticalIcon className="size-2.5" />
|
| 50 |
+
</div>
|
| 51 |
+
)}
|
| 52 |
+
</ResizablePrimitive.PanelResizeHandle>
|
| 53 |
+
);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|