Spaces:
Sleeping
Sleeping
Upload 73 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- web/src/App.tsx +1159 -0
- web/src/Attributions.md +3 -0
- web/src/assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png +0 -0
- web/src/components/ChatArea.tsx +1511 -0
- web/src/components/FileUploadArea.tsx +273 -0
- web/src/components/FloatingActionButtons.tsx +102 -0
- web/src/components/GroupMembers.tsx +119 -0
- web/src/components/Header.tsx +500 -0
- web/src/components/LearningModeSelector.tsx +101 -0
- web/src/components/LeftSidebar.tsx +834 -0
- web/src/components/LoginScreen.tsx +91 -0
- web/src/components/Message.tsx +347 -0
- web/src/components/Onboarding.tsx +350 -0
- web/src/components/ProfileEditor.tsx +263 -0
- web/src/components/ReviewBanner.tsx +49 -0
- web/src/components/RightPanel.tsx +308 -0
- web/src/components/SmartReview.tsx +304 -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 +144 -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/src/App.tsx
ADDED
|
@@ -0,0 +1,1159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import { Header } from './components/Header';
|
| 3 |
+
import { LeftSidebar } from './components/LeftSidebar';
|
| 4 |
+
import { ChatArea } from './components/ChatArea';
|
| 5 |
+
import { LoginScreen } from './components/LoginScreen';
|
| 6 |
+
import { ProfileEditor } from './components/ProfileEditor';
|
| 7 |
+
import { ReviewBanner } from './components/ReviewBanner';
|
| 8 |
+
import { Onboarding } from './components/Onboarding';
|
| 9 |
+
import { Menu, X, User, ChevronLeft, ChevronRight } from 'lucide-react';
|
| 10 |
+
import { Button } from './components/ui/button';
|
| 11 |
+
import { Toaster } from './components/ui/sonner';
|
| 12 |
+
import { toast } from 'sonner';
|
| 13 |
+
|
| 14 |
+
export interface Message {
|
| 15 |
+
id: string;
|
| 16 |
+
role: 'user' | 'assistant';
|
| 17 |
+
content: string;
|
| 18 |
+
timestamp: Date;
|
| 19 |
+
references?: string[];
|
| 20 |
+
sender?: GroupMember; // For group chat
|
| 21 |
+
showNextButton?: boolean; // For quiz mode
|
| 22 |
+
questionData?: {
|
| 23 |
+
type: 'multiple-choice' | 'fill-in-blank' | 'open-ended';
|
| 24 |
+
question: string;
|
| 25 |
+
options?: string[];
|
| 26 |
+
correctAnswer: string;
|
| 27 |
+
explanation: string;
|
| 28 |
+
sampleAnswer?: string;
|
| 29 |
+
};
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export interface User {
|
| 33 |
+
name: string;
|
| 34 |
+
email: string;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export interface GroupMember {
|
| 38 |
+
id: string;
|
| 39 |
+
name: string;
|
| 40 |
+
email: string;
|
| 41 |
+
avatar?: string;
|
| 42 |
+
isAI?: boolean;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export type SpaceType = 'individual' | 'group';
|
| 46 |
+
|
| 47 |
+
export interface CourseInfo {
|
| 48 |
+
id: string;
|
| 49 |
+
name: string;
|
| 50 |
+
instructor: {
|
| 51 |
+
name: string;
|
| 52 |
+
email: string;
|
| 53 |
+
};
|
| 54 |
+
teachingAssistant: {
|
| 55 |
+
name: string;
|
| 56 |
+
email: string;
|
| 57 |
+
};
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export interface Workspace {
|
| 61 |
+
id: string;
|
| 62 |
+
name: string;
|
| 63 |
+
type: SpaceType;
|
| 64 |
+
avatar: string;
|
| 65 |
+
members?: GroupMember[];
|
| 66 |
+
category?: 'course' | 'personal';
|
| 67 |
+
courseName?: string;
|
| 68 |
+
courseInfo?: CourseInfo;
|
| 69 |
+
isEditable?: boolean; // For personal interest workspaces
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export type FileType = 'syllabus' | 'lecture-slides' | 'literature-review' | 'other';
|
| 73 |
+
|
| 74 |
+
export interface UploadedFile {
|
| 75 |
+
file: File;
|
| 76 |
+
type: FileType;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
export type LearningMode = 'general' | 'concept' | 'socratic' | 'exam' | 'assignment' | 'summary';
|
| 80 |
+
export type Language = 'auto' | 'en' | 'zh';
|
| 81 |
+
export type ChatMode = 'ask' | 'review' | 'quiz';
|
| 82 |
+
|
| 83 |
+
export interface SavedItem {
|
| 84 |
+
id: string;
|
| 85 |
+
title: string;
|
| 86 |
+
content: string;
|
| 87 |
+
type: 'export' | 'quiz' | 'summary';
|
| 88 |
+
timestamp: Date;
|
| 89 |
+
isSaved: boolean;
|
| 90 |
+
format?: 'pdf' | 'text';
|
| 91 |
+
workspaceId: string;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
export interface SavedChat {
|
| 95 |
+
id: string;
|
| 96 |
+
title: string;
|
| 97 |
+
messages: Message[];
|
| 98 |
+
chatMode: ChatMode;
|
| 99 |
+
timestamp: Date;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function App() {
|
| 103 |
+
const [isDarkMode, setIsDarkMode] = useState(() => {
|
| 104 |
+
const saved = localStorage.getItem('theme');
|
| 105 |
+
return saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
| 106 |
+
});
|
| 107 |
+
const [user, setUser] = useState<User | null>(null);
|
| 108 |
+
// Global current course selection
|
| 109 |
+
const [currentCourseId, setCurrentCourseId] = useState<string>(() => localStorage.getItem('myspace_selected_course') || 'course1');
|
| 110 |
+
|
| 111 |
+
// Available courses with instructor/TA info
|
| 112 |
+
const availableCourses: CourseInfo[] = [
|
| 113 |
+
{
|
| 114 |
+
id: 'course1',
|
| 115 |
+
name: 'Introduction to AI',
|
| 116 |
+
instructor: { name: 'Dr. Sarah Johnson', email: 'sarah.johnson@university.edu' },
|
| 117 |
+
teachingAssistant: { name: 'Michael Chen', email: 'michael.chen@university.edu' }
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
id: 'course2',
|
| 121 |
+
name: 'Machine Learning',
|
| 122 |
+
instructor: { name: 'Prof. David Lee', email: 'david.lee@university.edu' },
|
| 123 |
+
teachingAssistant: { name: 'Emily Zhang', email: 'emily.zhang@university.edu' }
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
id: 'course3',
|
| 127 |
+
name: 'Data Structures',
|
| 128 |
+
instructor: { name: 'Dr. Robert Smith', email: 'robert.smith@university.edu' },
|
| 129 |
+
teachingAssistant: { name: 'Lisa Wang', email: 'lisa.wang@university.edu' }
|
| 130 |
+
},
|
| 131 |
+
{
|
| 132 |
+
id: 'course4',
|
| 133 |
+
name: 'Web Development',
|
| 134 |
+
instructor: { name: 'Prof. Maria Garcia', email: 'maria.garcia@university.edu' },
|
| 135 |
+
teachingAssistant: { name: 'James Brown', email: 'james.brown@university.edu' }
|
| 136 |
+
},
|
| 137 |
+
];
|
| 138 |
+
|
| 139 |
+
// Separate messages for each chat mode
|
| 140 |
+
const [askMessages, setAskMessages] = useState<Message[]>([
|
| 141 |
+
{
|
| 142 |
+
id: '1',
|
| 143 |
+
role: 'assistant',
|
| 144 |
+
content: "👋 Hi! I'm Clare, your AI teaching assistant. 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!",
|
| 145 |
+
timestamp: new Date(),
|
| 146 |
+
}
|
| 147 |
+
]);
|
| 148 |
+
const [reviewMessages, setReviewMessages] = useState<Message[]>([
|
| 149 |
+
{
|
| 150 |
+
id: 'review-1',
|
| 151 |
+
role: 'assistant',
|
| 152 |
+
content: "📚 Welcome to Review mode! I'll help you review and consolidate your learning. Let's go through what you've learned!",
|
| 153 |
+
timestamp: new Date(),
|
| 154 |
+
}
|
| 155 |
+
]);
|
| 156 |
+
const [quizMessages, setQuizMessages] = useState<Message[]>([
|
| 157 |
+
{
|
| 158 |
+
id: 'quiz-1',
|
| 159 |
+
role: 'assistant',
|
| 160 |
+
content: "🎯 Welcome to Quiz mode! I'll test your understanding with personalized questions based on your learning history. Ready to start?",
|
| 161 |
+
timestamp: new Date(),
|
| 162 |
+
}
|
| 163 |
+
]);
|
| 164 |
+
|
| 165 |
+
const [learningMode, setLearningMode] = useState<LearningMode>('concept');
|
| 166 |
+
const [language, setLanguage] = useState<Language>('auto');
|
| 167 |
+
const [chatMode, setChatMode] = useState<ChatMode>('ask');
|
| 168 |
+
|
| 169 |
+
// Get current messages based on chat mode
|
| 170 |
+
const messages = chatMode === 'ask' ? askMessages : chatMode === 'review' ? reviewMessages : quizMessages;
|
| 171 |
+
|
| 172 |
+
// Track previous chat mode to detect mode changes
|
| 173 |
+
const prevChatModeRef = useRef<ChatMode>(chatMode);
|
| 174 |
+
|
| 175 |
+
// Ensure welcome message exists when switching modes or when messages are empty
|
| 176 |
+
useEffect(() => {
|
| 177 |
+
// Check the actual state arrays, not the computed messages
|
| 178 |
+
let currentMessages: Message[];
|
| 179 |
+
let setCurrentMessages: (messages: Message[]) => void;
|
| 180 |
+
|
| 181 |
+
if (chatMode === 'ask') {
|
| 182 |
+
currentMessages = askMessages;
|
| 183 |
+
setCurrentMessages = setAskMessages;
|
| 184 |
+
} else if (chatMode === 'review') {
|
| 185 |
+
currentMessages = reviewMessages;
|
| 186 |
+
setCurrentMessages = setReviewMessages;
|
| 187 |
+
} else {
|
| 188 |
+
currentMessages = quizMessages;
|
| 189 |
+
setCurrentMessages = setQuizMessages;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
const hasUserMessages = currentMessages.some(msg => msg.role === 'user');
|
| 193 |
+
const expectedWelcomeId = chatMode === 'ask' ? '1' : chatMode === 'review' ? 'review-1' : 'quiz-1';
|
| 194 |
+
const hasWelcomeMessage = currentMessages.some(msg => msg.id === expectedWelcomeId && msg.role === 'assistant');
|
| 195 |
+
const modeChanged = prevChatModeRef.current !== chatMode;
|
| 196 |
+
|
| 197 |
+
// If mode changed or messages are empty or missing welcome message, restore welcome message
|
| 198 |
+
if ((modeChanged || currentMessages.length === 0 || !hasWelcomeMessage) && !hasUserMessages) {
|
| 199 |
+
const initialMessages: Record<ChatMode, Message[]> = {
|
| 200 |
+
ask: [{
|
| 201 |
+
id: '1',
|
| 202 |
+
role: 'assistant',
|
| 203 |
+
content: "👋 Hi! I'm Clare, your AI teaching assistant. 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!",
|
| 204 |
+
timestamp: new Date(),
|
| 205 |
+
}],
|
| 206 |
+
review: [{
|
| 207 |
+
id: 'review-1',
|
| 208 |
+
role: 'assistant',
|
| 209 |
+
content: "📚 Welcome to Review mode! I'll help you review and consolidate your learning. Let's go through what you've learned!",
|
| 210 |
+
timestamp: new Date(),
|
| 211 |
+
}],
|
| 212 |
+
quiz: [{
|
| 213 |
+
id: 'quiz-1',
|
| 214 |
+
role: 'assistant',
|
| 215 |
+
content: "🎯 Welcome to Quiz mode! I'll test your understanding with personalized questions based on your learning history. Ready to start?",
|
| 216 |
+
timestamp: new Date(),
|
| 217 |
+
}],
|
| 218 |
+
};
|
| 219 |
+
|
| 220 |
+
setCurrentMessages(initialMessages[chatMode]);
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
prevChatModeRef.current = chatMode;
|
| 224 |
+
}, [chatMode, askMessages.length, reviewMessages.length, quizMessages.length]); // Only depend on lengths to avoid infinite loops
|
| 225 |
+
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
| 226 |
+
const [memoryProgress, setMemoryProgress] = useState(36);
|
| 227 |
+
const [quizState, setQuizState] = useState<{
|
| 228 |
+
currentQuestion: number;
|
| 229 |
+
waitingForAnswer: boolean;
|
| 230 |
+
showNextButton: boolean;
|
| 231 |
+
}>({
|
| 232 |
+
currentQuestion: 0,
|
| 233 |
+
waitingForAnswer: false,
|
| 234 |
+
showNextButton: false,
|
| 235 |
+
});
|
| 236 |
+
const [isTyping, setIsTyping] = useState(false);
|
| 237 |
+
const [leftSidebarOpen, setLeftSidebarOpen] = useState(false);
|
| 238 |
+
const [leftPanelVisible, setLeftPanelVisible] = useState(true);
|
| 239 |
+
const [showProfileEditor, setShowProfileEditor] = useState(false);
|
| 240 |
+
const [showOnboarding, setShowOnboarding] = useState(false);
|
| 241 |
+
const [exportResult, setExportResult] = useState('');
|
| 242 |
+
// Review banner state
|
| 243 |
+
const [showReviewBanner, setShowReviewBanner] = useState(() => {
|
| 244 |
+
// Temporarily force show for testing - remove this after confirming it works
|
| 245 |
+
// const dismissed = localStorage.getItem('reviewBannerDismissed');
|
| 246 |
+
// return !dismissed || dismissed === 'false';
|
| 247 |
+
return true; // Force show for testing
|
| 248 |
+
});
|
| 249 |
+
const [resultType, setResultType] = useState<'export' | 'quiz' | 'summary' | null>(null);
|
| 250 |
+
const [showClearDialog, setShowClearDialog] = useState(false);
|
| 251 |
+
|
| 252 |
+
// Saved conversations/summaries
|
| 253 |
+
const [savedItems, setSavedItems] = useState<SavedItem[]>([]);
|
| 254 |
+
const [recentlySavedId, setRecentlySavedId] = useState<string | null>(null);
|
| 255 |
+
|
| 256 |
+
// Saved chats
|
| 257 |
+
const [savedChats, setSavedChats] = useState<SavedChat[]>([]);
|
| 258 |
+
|
| 259 |
+
// Mock group members
|
| 260 |
+
const [groupMembers] = useState<GroupMember[]>([
|
| 261 |
+
{ id: 'clare', name: 'Clare AI', email: 'clare@ai.assistant', isAI: true },
|
| 262 |
+
{ id: '1', name: 'Sarah Johnson', email: 'sarah.j@university.edu' },
|
| 263 |
+
{ id: '2', name: 'Michael Chen', email: 'michael.c@university.edu' },
|
| 264 |
+
{ id: '3', name: 'Emma Williams', email: 'emma.w@university.edu' },
|
| 265 |
+
]);
|
| 266 |
+
|
| 267 |
+
// Workspaces - individual workspace uses user's avatar, group workspaces use group avatars
|
| 268 |
+
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
| 269 |
+
const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>('individual');
|
| 270 |
+
|
| 271 |
+
// Initialize workspaces when user logs in
|
| 272 |
+
useEffect(() => {
|
| 273 |
+
if (user) {
|
| 274 |
+
const userAvatar = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`;
|
| 275 |
+
const course1Info = availableCourses.find(c => c.id === 'course1');
|
| 276 |
+
const course2Info = availableCourses.find(c => c.name === 'AI Ethics');
|
| 277 |
+
|
| 278 |
+
setWorkspaces([
|
| 279 |
+
{
|
| 280 |
+
id: 'individual',
|
| 281 |
+
name: 'My Space',
|
| 282 |
+
type: 'individual',
|
| 283 |
+
avatar: userAvatar,
|
| 284 |
+
},
|
| 285 |
+
{
|
| 286 |
+
id: 'group-1',
|
| 287 |
+
name: 'CS 101 Study Group',
|
| 288 |
+
type: 'group',
|
| 289 |
+
avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=cs101group',
|
| 290 |
+
members: groupMembers,
|
| 291 |
+
category: 'course',
|
| 292 |
+
courseName: course1Info?.name || 'CS 101',
|
| 293 |
+
courseInfo: course1Info,
|
| 294 |
+
},
|
| 295 |
+
{
|
| 296 |
+
id: 'group-2',
|
| 297 |
+
name: 'AI Ethics Team',
|
| 298 |
+
type: 'group',
|
| 299 |
+
avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=aiethicsteam',
|
| 300 |
+
members: groupMembers,
|
| 301 |
+
category: 'course',
|
| 302 |
+
courseName: course2Info?.name || 'AI Ethics',
|
| 303 |
+
courseInfo: course2Info,
|
| 304 |
+
},
|
| 305 |
+
]);
|
| 306 |
+
}
|
| 307 |
+
}, [user, groupMembers, availableCourses]);
|
| 308 |
+
|
| 309 |
+
// Get current workspace
|
| 310 |
+
const currentWorkspace = workspaces.find(w => w.id === currentWorkspaceId) || workspaces[0];
|
| 311 |
+
const spaceType: SpaceType = currentWorkspace?.type || 'individual';
|
| 312 |
+
|
| 313 |
+
// Keep current course in sync with workspace type
|
| 314 |
+
useEffect(() => {
|
| 315 |
+
if (!currentWorkspace) return;
|
| 316 |
+
if (currentWorkspace.type === 'group' && currentWorkspace.category === 'course' && currentWorkspace.courseName) {
|
| 317 |
+
setCurrentCourseId(currentWorkspace.courseName);
|
| 318 |
+
} else if (currentWorkspace.type === 'individual') {
|
| 319 |
+
const saved = localStorage.getItem('myspace_selected_course');
|
| 320 |
+
if (saved) setCurrentCourseId(saved);
|
| 321 |
+
}
|
| 322 |
+
}, [currentWorkspaceId, currentWorkspace]);
|
| 323 |
+
|
| 324 |
+
// Persist selection for My Space
|
| 325 |
+
useEffect(() => {
|
| 326 |
+
if (currentWorkspace?.type === 'individual') {
|
| 327 |
+
localStorage.setItem('myspace_selected_course', currentCourseId);
|
| 328 |
+
}
|
| 329 |
+
}, [currentCourseId, currentWorkspace]);
|
| 330 |
+
|
| 331 |
+
useEffect(() => {
|
| 332 |
+
document.documentElement.classList.toggle('dark', isDarkMode);
|
| 333 |
+
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
|
| 334 |
+
}, [isDarkMode]);
|
| 335 |
+
|
| 336 |
+
const generateQuizQuestion = () => {
|
| 337 |
+
const questions: Array<{
|
| 338 |
+
type: 'multiple-choice' | 'fill-in-blank' | 'open-ended';
|
| 339 |
+
question: string;
|
| 340 |
+
options?: string[];
|
| 341 |
+
correctAnswer: string;
|
| 342 |
+
explanation: string;
|
| 343 |
+
sampleAnswer?: string;
|
| 344 |
+
}> = [
|
| 345 |
+
{
|
| 346 |
+
type: 'multiple-choice',
|
| 347 |
+
question: "Which of the following is NOT a principle of Responsible AI?",
|
| 348 |
+
options: ["A) Fairness", "B) Transparency", "C) Profit Maximization", "D) Accountability"],
|
| 349 |
+
correctAnswer: "C",
|
| 350 |
+
explanation: "Profit Maximization is not a principle of Responsible AI. The key principles are Fairness, Transparency, and Accountability."
|
| 351 |
+
},
|
| 352 |
+
{
|
| 353 |
+
type: 'fill-in-blank',
|
| 354 |
+
question: "Algorithmic fairness ensures that AI systems do not discriminate against individuals based on their _____.",
|
| 355 |
+
correctAnswer: "protected characteristics",
|
| 356 |
+
explanation: "Protected characteristics include attributes like race, gender, age, religion, etc. AI systems should not discriminate based on these."
|
| 357 |
+
},
|
| 358 |
+
{
|
| 359 |
+
type: 'open-ended',
|
| 360 |
+
question: "Explain why transparency is important in AI systems.",
|
| 361 |
+
correctAnswer: "Transparency allows users to understand how AI systems make decisions, which builds trust and enables accountability.",
|
| 362 |
+
sampleAnswer: "Transparency allows users to understand how AI systems make decisions, which builds trust and enables accountability.",
|
| 363 |
+
explanation: "Transparency is crucial because it helps users understand AI decision-making processes, enables debugging, and ensures accountability."
|
| 364 |
+
}
|
| 365 |
+
];
|
| 366 |
+
|
| 367 |
+
const randomIndex = Math.floor(Math.random() * questions.length);
|
| 368 |
+
return questions[randomIndex];
|
| 369 |
+
};
|
| 370 |
+
|
| 371 |
+
const handleSendMessage = (content: string) => {
|
| 372 |
+
if (!content.trim() || !user) return;
|
| 373 |
+
|
| 374 |
+
// Attach sender info for all user messages
|
| 375 |
+
const sender: GroupMember = {
|
| 376 |
+
id: user.email,
|
| 377 |
+
name: user.name,
|
| 378 |
+
email: user.email,
|
| 379 |
+
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`,
|
| 380 |
+
};
|
| 381 |
+
|
| 382 |
+
const userMessage: Message = {
|
| 383 |
+
id: Date.now().toString(),
|
| 384 |
+
role: 'user',
|
| 385 |
+
content,
|
| 386 |
+
timestamp: new Date(),
|
| 387 |
+
sender,
|
| 388 |
+
};
|
| 389 |
+
|
| 390 |
+
// Add user message to the appropriate mode's message list
|
| 391 |
+
if (chatMode === 'ask') {
|
| 392 |
+
setAskMessages(prev => [...prev, userMessage]);
|
| 393 |
+
} else if (chatMode === 'review') {
|
| 394 |
+
setReviewMessages(prev => [...prev, userMessage]);
|
| 395 |
+
} else {
|
| 396 |
+
setQuizMessages(prev => [...prev, userMessage]);
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
// Handle Quiz mode differently
|
| 400 |
+
if (chatMode === 'quiz') {
|
| 401 |
+
if (quizState.waitingForAnswer) {
|
| 402 |
+
// User is answering a question
|
| 403 |
+
const isCorrect = Math.random() > 0.3; // Simulate answer checking
|
| 404 |
+
|
| 405 |
+
setIsTyping(true);
|
| 406 |
+
setTimeout(() => {
|
| 407 |
+
const feedback = isCorrect
|
| 408 |
+
? "✅ Correct! Great job!"
|
| 409 |
+
: "❌ Not quite right, but good effort!";
|
| 410 |
+
|
| 411 |
+
const explanation = "Here's the explanation: The correct answer demonstrates understanding of the key concepts. Let me break it down for you...";
|
| 412 |
+
|
| 413 |
+
const assistantMessage: Message = {
|
| 414 |
+
id: (Date.now() + 1).toString(),
|
| 415 |
+
role: 'assistant',
|
| 416 |
+
content: `${feedback}\n\n${explanation}`,
|
| 417 |
+
timestamp: new Date(),
|
| 418 |
+
sender: spaceType === 'group' ? groupMembers.find(m => m.isAI) : undefined,
|
| 419 |
+
showNextButton: true,
|
| 420 |
+
};
|
| 421 |
+
|
| 422 |
+
// Close typing indicator first
|
| 423 |
+
setIsTyping(false);
|
| 424 |
+
|
| 425 |
+
// Wait a bit to ensure typing indicator disappears before adding message
|
| 426 |
+
setTimeout(() => {
|
| 427 |
+
setQuizMessages(prev => [...prev, assistantMessage]);
|
| 428 |
+
setQuizState(prev => ({ ...prev, waitingForAnswer: false, showNextButton: true }));
|
| 429 |
+
}, 50);
|
| 430 |
+
}, 2000);
|
| 431 |
+
}
|
| 432 |
+
return;
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
// Handle Ask and Review modes
|
| 436 |
+
// Respond in all workspaces to keep conversations continuous
|
| 437 |
+
const shouldAIRespond = true;
|
| 438 |
+
|
| 439 |
+
if (shouldAIRespond) {
|
| 440 |
+
setIsTyping(true);
|
| 441 |
+
setTimeout(() => {
|
| 442 |
+
let response = '';
|
| 443 |
+
|
| 444 |
+
if (chatMode === 'ask') {
|
| 445 |
+
const responses: Record<LearningMode, string> = {
|
| 446 |
+
general: "I'd be happy to help! To provide you with the most accurate and relevant answer, could you please provide some context about what you're asking? For example, what subject or topic is this related to?",
|
| 447 |
+
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?",
|
| 448 |
+
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.",
|
| 449 |
+
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",
|
| 450 |
+
assignment: "I can help you with that assignment! Let's break it down into manageable steps. First, what specific aspect are you working on?",
|
| 451 |
+
summary: "Here's a quick summary: Responsible AI focuses on developing and deploying AI systems that are ethical, fair, transparent, and accountable to society.",
|
| 452 |
+
};
|
| 453 |
+
response = responses[learningMode];
|
| 454 |
+
} else if (chatMode === 'review') {
|
| 455 |
+
// Check if this is a review request by checking window storage
|
| 456 |
+
const reviewData = (window as any).__lastReviewData;
|
| 457 |
+
if (reviewData) {
|
| 458 |
+
if (reviewData.startsWith('REVIEW_TOPIC:')) {
|
| 459 |
+
// Parse review topic data
|
| 460 |
+
const data = reviewData.replace('REVIEW_TOPIC:', '').split('|');
|
| 461 |
+
const [title, previousQuestion, memoryRetention, schedule, status, weight, lastReviewed] = data;
|
| 462 |
+
|
| 463 |
+
response = `Let's review **${title}** together!\n\n**Your Previous Question:**\n"${previousQuestion}"\n\n**Review Details:**\n- **Memory Retention:** ${memoryRetention}%\n- **Schedule:** ${schedule}\n- **Status:** ${status.toUpperCase()}\n- **Weight:** ${weight}%\n- **Last Reviewed:** ${lastReviewed}\n\nLet's go through this topic step by step. What would you like to focus on first?`;
|
| 464 |
+
// Clear the stored data
|
| 465 |
+
delete (window as any).__lastReviewData;
|
| 466 |
+
} else if (reviewData === 'REVIEW_ALL') {
|
| 467 |
+
response = `I'll help you review all the topics that need your attention. Based on your learning history, here are the topics we should focus on:\n\n1. **Main Concept of Lab 3** (Urgent - Memory Retention: 25%)\n2. **Effective Prompt Engineering** (Review - Memory Retention: 60%)\n3. **Objective LLM Evaluation** (Stable - Memory Retention: 90%)\n\nLet's start with the most urgent ones first. Which topic would you like to begin with?`;
|
| 468 |
+
// Clear the stored data
|
| 469 |
+
delete (window as any).__lastReviewData;
|
| 470 |
+
} else {
|
| 471 |
+
response = "Let's review what you've learned! Based on your previous conversations, here are the key concepts we covered: [Review content would go here]";
|
| 472 |
+
}
|
| 473 |
+
} else {
|
| 474 |
+
response = "Let's review what you've learned! Based on your previous conversations, here are the key concepts we covered: [Review content would go here]";
|
| 475 |
+
}
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
const assistantMessage: Message = {
|
| 479 |
+
id: (Date.now() + 1).toString(),
|
| 480 |
+
role: 'assistant',
|
| 481 |
+
content: response,
|
| 482 |
+
timestamp: new Date(),
|
| 483 |
+
references: chatMode === 'ask' ? ['Module 10, Section 2.3', 'Lecture Notes - Week 5'] : undefined,
|
| 484 |
+
sender: spaceType === 'group' ? groupMembers.find(m => m.isAI) : undefined,
|
| 485 |
+
};
|
| 486 |
+
|
| 487 |
+
// Close typing indicator first
|
| 488 |
+
setIsTyping(false);
|
| 489 |
+
|
| 490 |
+
// Wait a bit to ensure typing indicator disappears before adding message
|
| 491 |
+
setTimeout(() => {
|
| 492 |
+
if (chatMode === 'ask') {
|
| 493 |
+
setAskMessages(prev => [...prev, assistantMessage]);
|
| 494 |
+
} else if (chatMode === 'review') {
|
| 495 |
+
setReviewMessages(prev => [...prev, assistantMessage]);
|
| 496 |
+
}
|
| 497 |
+
}, 50);
|
| 498 |
+
}, 2000);
|
| 499 |
+
}
|
| 500 |
+
};
|
| 501 |
+
|
| 502 |
+
const handleNextQuestion = () => {
|
| 503 |
+
setIsTyping(true);
|
| 504 |
+
const question = generateQuizQuestion();
|
| 505 |
+
let questionText = question.question;
|
| 506 |
+
|
| 507 |
+
if (question.type === 'multiple-choice') {
|
| 508 |
+
questionText += '\n\n' + question.options.join('\n');
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
setTimeout(() => {
|
| 512 |
+
const assistantMessage: Message = {
|
| 513 |
+
id: Date.now().toString(),
|
| 514 |
+
role: 'assistant',
|
| 515 |
+
content: questionText,
|
| 516 |
+
timestamp: new Date(),
|
| 517 |
+
sender: spaceType === 'group' ? groupMembers.find(m => m.isAI) : undefined,
|
| 518 |
+
questionData: question,
|
| 519 |
+
};
|
| 520 |
+
|
| 521 |
+
// Close typing indicator first
|
| 522 |
+
setIsTyping(false);
|
| 523 |
+
|
| 524 |
+
// Wait a bit to ensure typing indicator disappears before adding message
|
| 525 |
+
setTimeout(() => {
|
| 526 |
+
setQuizMessages(prev => [...prev, assistantMessage]);
|
| 527 |
+
setQuizState(prev => ({
|
| 528 |
+
...prev,
|
| 529 |
+
currentQuestion: prev.currentQuestion + 1,
|
| 530 |
+
waitingForAnswer: true,
|
| 531 |
+
showNextButton: false
|
| 532 |
+
}));
|
| 533 |
+
}, 50);
|
| 534 |
+
}, 2000);
|
| 535 |
+
};
|
| 536 |
+
|
| 537 |
+
const handleStartQuiz = () => {
|
| 538 |
+
handleNextQuestion();
|
| 539 |
+
};
|
| 540 |
+
|
| 541 |
+
const handleFileUpload = (files: File[]) => {
|
| 542 |
+
const newFiles: UploadedFile[] = files.map(file => ({
|
| 543 |
+
file,
|
| 544 |
+
type: 'other' as FileType, // Default type
|
| 545 |
+
}));
|
| 546 |
+
setUploadedFiles(prev => [...prev, ...newFiles]);
|
| 547 |
+
};
|
| 548 |
+
|
| 549 |
+
const handleRemoveFile = (index: number) => {
|
| 550 |
+
setUploadedFiles(prev => prev.filter((_, i) => i !== index));
|
| 551 |
+
};
|
| 552 |
+
|
| 553 |
+
const handleFileTypeChange = (index: number, type: FileType) => {
|
| 554 |
+
setUploadedFiles(prev => prev.map((file, i) =>
|
| 555 |
+
i === index ? { ...file, type } : file
|
| 556 |
+
));
|
| 557 |
+
};
|
| 558 |
+
|
| 559 |
+
// Helper function to check if current chat is already saved
|
| 560 |
+
const isCurrentChatSaved = (): SavedChat | null => {
|
| 561 |
+
if (messages.length <= 1) return null;
|
| 562 |
+
|
| 563 |
+
// Find a saved chat that matches the current messages and chatMode
|
| 564 |
+
return savedChats.find(chat => {
|
| 565 |
+
if (chat.chatMode !== chatMode) return false;
|
| 566 |
+
if (chat.messages.length !== messages.length) return false;
|
| 567 |
+
|
| 568 |
+
// Check if all messages match
|
| 569 |
+
return chat.messages.every((savedMsg, index) => {
|
| 570 |
+
const currentMsg = messages[index];
|
| 571 |
+
return (
|
| 572 |
+
savedMsg.id === currentMsg.id &&
|
| 573 |
+
savedMsg.role === currentMsg.role &&
|
| 574 |
+
savedMsg.content === currentMsg.content
|
| 575 |
+
);
|
| 576 |
+
});
|
| 577 |
+
}) || null;
|
| 578 |
+
};
|
| 579 |
+
|
| 580 |
+
const handleSaveChat = () => {
|
| 581 |
+
if (messages.length <= 1) {
|
| 582 |
+
toast.info('No conversation to save');
|
| 583 |
+
return;
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
// Check if already saved
|
| 587 |
+
const existingChat = isCurrentChatSaved();
|
| 588 |
+
if (existingChat) {
|
| 589 |
+
// Unsave: remove from saved chats
|
| 590 |
+
handleDeleteSavedChat(existingChat.id);
|
| 591 |
+
toast.success('Chat unsaved');
|
| 592 |
+
return;
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
// Save: add new chat
|
| 596 |
+
const title = `Chat - ${chatMode === 'ask' ? 'Ask' : chatMode === 'review' ? 'Review' : 'Quiz'} - ${new Date().toLocaleDateString()}`;
|
| 597 |
+
const newChat: SavedChat = {
|
| 598 |
+
id: Date.now().toString(),
|
| 599 |
+
title,
|
| 600 |
+
messages: [...messages],
|
| 601 |
+
chatMode,
|
| 602 |
+
timestamp: new Date(),
|
| 603 |
+
};
|
| 604 |
+
|
| 605 |
+
setSavedChats(prev => [newChat, ...prev]);
|
| 606 |
+
setLeftPanelVisible(true);
|
| 607 |
+
toast.success('Chat saved!');
|
| 608 |
+
};
|
| 609 |
+
|
| 610 |
+
const handleLoadChat = (savedChat: SavedChat) => {
|
| 611 |
+
// Set the chat mode first
|
| 612 |
+
setChatMode(savedChat.chatMode);
|
| 613 |
+
|
| 614 |
+
// Then set the messages for that mode
|
| 615 |
+
if (savedChat.chatMode === 'ask') {
|
| 616 |
+
setAskMessages(savedChat.messages);
|
| 617 |
+
} else if (savedChat.chatMode === 'review') {
|
| 618 |
+
setReviewMessages(savedChat.messages);
|
| 619 |
+
} else {
|
| 620 |
+
setQuizMessages(savedChat.messages);
|
| 621 |
+
// Reset quiz state
|
| 622 |
+
setQuizState({
|
| 623 |
+
currentQuestion: 0,
|
| 624 |
+
waitingForAnswer: false,
|
| 625 |
+
showNextButton: false,
|
| 626 |
+
});
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
toast.success('Chat loaded!');
|
| 630 |
+
};
|
| 631 |
+
|
| 632 |
+
const handleDeleteSavedChat = (id: string) => {
|
| 633 |
+
setSavedChats(prev => prev.filter(chat => chat.id !== id));
|
| 634 |
+
toast.success('Chat deleted');
|
| 635 |
+
};
|
| 636 |
+
|
| 637 |
+
const handleRenameSavedChat = (id: string, newTitle: string) => {
|
| 638 |
+
setSavedChats(prev => prev.map(chat =>
|
| 639 |
+
chat.id === id ? { ...chat, title: newTitle } : chat
|
| 640 |
+
));
|
| 641 |
+
toast.success('Chat renamed');
|
| 642 |
+
};
|
| 643 |
+
|
| 644 |
+
const handleClearConversation = (shouldSave: boolean = false) => {
|
| 645 |
+
if (shouldSave) {
|
| 646 |
+
handleSaveChat();
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
const initialMessages: Record<ChatMode, Message[]> = {
|
| 650 |
+
ask: [{
|
| 651 |
+
id: '1',
|
| 652 |
+
role: 'assistant',
|
| 653 |
+
content: "👋 Hi! I'm Clare, your AI teaching assistant. 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!",
|
| 654 |
+
timestamp: new Date(),
|
| 655 |
+
}],
|
| 656 |
+
review: [{
|
| 657 |
+
id: 'review-1',
|
| 658 |
+
role: 'assistant',
|
| 659 |
+
content: "📚 Welcome to Review mode! I'll help you review and consolidate your learning. Let's go through what you've learned!",
|
| 660 |
+
timestamp: new Date(),
|
| 661 |
+
}],
|
| 662 |
+
quiz: [{
|
| 663 |
+
id: 'quiz-1',
|
| 664 |
+
role: 'assistant',
|
| 665 |
+
content: "🎯 Welcome to Quiz mode! I'll test your understanding with personalized questions based on your learning history. Ready to start?",
|
| 666 |
+
timestamp: new Date(),
|
| 667 |
+
}],
|
| 668 |
+
};
|
| 669 |
+
|
| 670 |
+
// Clear only the current mode's conversation
|
| 671 |
+
if (chatMode === 'ask') {
|
| 672 |
+
setAskMessages(initialMessages.ask);
|
| 673 |
+
} else if (chatMode === 'review') {
|
| 674 |
+
setReviewMessages(initialMessages.review);
|
| 675 |
+
} else {
|
| 676 |
+
setQuizMessages(initialMessages.quiz);
|
| 677 |
+
setQuizState({
|
| 678 |
+
currentQuestion: 0,
|
| 679 |
+
waitingForAnswer: false,
|
| 680 |
+
showNextButton: false,
|
| 681 |
+
});
|
| 682 |
+
}
|
| 683 |
+
};
|
| 684 |
+
|
| 685 |
+
const handleExport = () => {
|
| 686 |
+
const result = `# Conversation Export
|
| 687 |
+
Date: ${new Date().toLocaleDateString()}
|
| 688 |
+
Student: ${user?.name}
|
| 689 |
+
|
| 690 |
+
## Summary
|
| 691 |
+
This conversation covered key concepts in Module 10 – Responsible AI, including ethical considerations, fairness, transparency, and accountability in AI systems.
|
| 692 |
+
|
| 693 |
+
## Key Takeaways
|
| 694 |
+
1. Understanding the principles of Responsible AI
|
| 695 |
+
2. Real-world applications and implications
|
| 696 |
+
3. Best practices for ethical AI development
|
| 697 |
+
|
| 698 |
+
Exported successfully! ✓`;
|
| 699 |
+
|
| 700 |
+
setExportResult(result);
|
| 701 |
+
setResultType('export');
|
| 702 |
+
toast.success('Conversation exported!');
|
| 703 |
+
};
|
| 704 |
+
|
| 705 |
+
const handleQuiz = () => {
|
| 706 |
+
const quiz = `# Micro-Quiz: Responsible AI
|
| 707 |
+
|
| 708 |
+
**Question 1:** Which of the following is a key principle of Responsible AI?
|
| 709 |
+
a) Profit maximization
|
| 710 |
+
b) Transparency
|
| 711 |
+
c) Rapid deployment
|
| 712 |
+
d) Cost reduction
|
| 713 |
+
|
| 714 |
+
**Question 2:** What is algorithmic fairness?
|
| 715 |
+
(Short answer expected)
|
| 716 |
+
|
| 717 |
+
**Question 3:** True or False: AI systems should always prioritize accuracy over fairness.
|
| 718 |
+
|
| 719 |
+
Generate quiz based on your conversation!`;
|
| 720 |
+
|
| 721 |
+
setExportResult(quiz);
|
| 722 |
+
setResultType('quiz');
|
| 723 |
+
toast.success('Quiz generated!');
|
| 724 |
+
};
|
| 725 |
+
|
| 726 |
+
const handleSummary = () => {
|
| 727 |
+
const summary = `# Learning Summary
|
| 728 |
+
|
| 729 |
+
## Today's Session
|
| 730 |
+
**Duration:** 25 minutes
|
| 731 |
+
**Topics Covered:** 3
|
| 732 |
+
**Messages Exchanged:** 12
|
| 733 |
+
|
| 734 |
+
## Key Concepts Discussed
|
| 735 |
+
• Principles of Responsible AI
|
| 736 |
+
• Ethical considerations in AI development
|
| 737 |
+
• Fairness and transparency in algorithms
|
| 738 |
+
|
| 739 |
+
## Recommended Next Steps
|
| 740 |
+
1. Review Module 10, Section 2.3
|
| 741 |
+
2. Complete practice quiz on algorithmic fairness
|
| 742 |
+
3. Read additional resources on AI ethics
|
| 743 |
+
|
| 744 |
+
## Progress Update
|
| 745 |
+
You've covered 65% of Module 10 content. Keep up the great work! 🎉`;
|
| 746 |
+
|
| 747 |
+
setExportResult(summary);
|
| 748 |
+
setResultType('summary');
|
| 749 |
+
toast.success('Summary generated!');
|
| 750 |
+
};
|
| 751 |
+
|
| 752 |
+
const handleSave = (
|
| 753 |
+
content: string,
|
| 754 |
+
type: 'export' | 'quiz' | 'summary',
|
| 755 |
+
saveAsChat: boolean = false,
|
| 756 |
+
format: 'pdf' | 'text' = 'text',
|
| 757 |
+
workspaceId?: string
|
| 758 |
+
) => {
|
| 759 |
+
if (!content.trim()) return;
|
| 760 |
+
|
| 761 |
+
// Summary should always be saved as file, not chat
|
| 762 |
+
// If saving as chat (from RightPanel export/quiz only, not summary)
|
| 763 |
+
if (saveAsChat && type !== 'summary') {
|
| 764 |
+
// Convert the export result to a chat format
|
| 765 |
+
// Create messages from the export content
|
| 766 |
+
const chatMessages: Message[] = [
|
| 767 |
+
{
|
| 768 |
+
id: '1',
|
| 769 |
+
role: 'assistant',
|
| 770 |
+
content: "👋 Hi! I'm Clare, your AI teaching assistant. 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!",
|
| 771 |
+
timestamp: new Date(),
|
| 772 |
+
},
|
| 773 |
+
{
|
| 774 |
+
id: Date.now().toString(),
|
| 775 |
+
role: 'assistant',
|
| 776 |
+
content,
|
| 777 |
+
timestamp: new Date(),
|
| 778 |
+
}
|
| 779 |
+
];
|
| 780 |
+
|
| 781 |
+
const title = type === 'export' ? 'Exported Conversation' : 'Micro-Quiz';
|
| 782 |
+
const newChat: SavedChat = {
|
| 783 |
+
id: Date.now().toString(),
|
| 784 |
+
title: `${title} - ${new Date().toLocaleDateString()}`,
|
| 785 |
+
messages: chatMessages,
|
| 786 |
+
chatMode: 'ask',
|
| 787 |
+
timestamp: new Date(),
|
| 788 |
+
};
|
| 789 |
+
|
| 790 |
+
setSavedChats(prev => [newChat, ...prev]);
|
| 791 |
+
setLeftPanelVisible(true);
|
| 792 |
+
toast.success('Chat saved!');
|
| 793 |
+
return;
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
// Otherwise, save as file (existing behavior)
|
| 797 |
+
// Check if already saved
|
| 798 |
+
const existingItem = savedItems.find(item => item.content === content && item.type === type);
|
| 799 |
+
if (existingItem) {
|
| 800 |
+
// Unsave: remove from saved items
|
| 801 |
+
handleUnsave(existingItem.id);
|
| 802 |
+
return;
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
// Save: add new item
|
| 806 |
+
const title = type === 'export' ? 'Exported Conversation' : type === 'quiz' ? 'Micro-Quiz' : 'Summarization';
|
| 807 |
+
const newItem: SavedItem = {
|
| 808 |
+
id: Date.now().toString(),
|
| 809 |
+
title: `${title} - ${new Date().toLocaleDateString()}`,
|
| 810 |
+
content,
|
| 811 |
+
type,
|
| 812 |
+
timestamp: new Date(),
|
| 813 |
+
isSaved: true,
|
| 814 |
+
format,
|
| 815 |
+
workspaceId: workspaceId || currentWorkspaceId,
|
| 816 |
+
};
|
| 817 |
+
|
| 818 |
+
setSavedItems(prev => [newItem, ...prev]);
|
| 819 |
+
setRecentlySavedId(newItem.id);
|
| 820 |
+
setLeftPanelVisible(true); // Open left panel
|
| 821 |
+
|
| 822 |
+
// Clear the highlight after animation
|
| 823 |
+
setTimeout(() => {
|
| 824 |
+
setRecentlySavedId(null);
|
| 825 |
+
}, 2000);
|
| 826 |
+
|
| 827 |
+
toast.success('Saved for later!');
|
| 828 |
+
};
|
| 829 |
+
|
| 830 |
+
const handleUnsave = (id: string) => {
|
| 831 |
+
setSavedItems(prev => prev.filter(item => item.id !== id));
|
| 832 |
+
toast.success('Removed from saved items');
|
| 833 |
+
};
|
| 834 |
+
|
| 835 |
+
// Create a new group workspace
|
| 836 |
+
const handleCreateWorkspace = (payload: {
|
| 837 |
+
name: string;
|
| 838 |
+
category: 'course' | 'personal';
|
| 839 |
+
courseId?: string;
|
| 840 |
+
invites: string[];
|
| 841 |
+
}) => {
|
| 842 |
+
const id = `group-${Date.now()}`;
|
| 843 |
+
const avatar = `https://api.dicebear.com/7.x/shapes/svg?seed=${encodeURIComponent(payload.name)}`;
|
| 844 |
+
|
| 845 |
+
// Add creator as first member
|
| 846 |
+
const creatorMember: GroupMember = user ? {
|
| 847 |
+
id: user.email,
|
| 848 |
+
name: user.name,
|
| 849 |
+
email: user.email,
|
| 850 |
+
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`,
|
| 851 |
+
} : { id: 'unknown', name: 'Unknown', email: 'unknown@email.com' };
|
| 852 |
+
|
| 853 |
+
const members: GroupMember[] = [
|
| 854 |
+
creatorMember,
|
| 855 |
+
...payload.invites.map(email => ({
|
| 856 |
+
id: email,
|
| 857 |
+
name: email.split('@')[0] || email,
|
| 858 |
+
email,
|
| 859 |
+
}))
|
| 860 |
+
];
|
| 861 |
+
|
| 862 |
+
let newWorkspace: Workspace;
|
| 863 |
+
|
| 864 |
+
if (payload.category === 'course') {
|
| 865 |
+
const courseInfo = availableCourses.find(c => c.id === payload.courseId);
|
| 866 |
+
newWorkspace = {
|
| 867 |
+
id,
|
| 868 |
+
name: payload.name,
|
| 869 |
+
type: 'group',
|
| 870 |
+
avatar,
|
| 871 |
+
members,
|
| 872 |
+
category: 'course',
|
| 873 |
+
courseName: courseInfo?.name || 'Untitled Course',
|
| 874 |
+
courseInfo,
|
| 875 |
+
};
|
| 876 |
+
} else {
|
| 877 |
+
// Personal interest workspace
|
| 878 |
+
newWorkspace = {
|
| 879 |
+
id,
|
| 880 |
+
name: payload.name,
|
| 881 |
+
type: 'group',
|
| 882 |
+
avatar,
|
| 883 |
+
members,
|
| 884 |
+
category: 'personal',
|
| 885 |
+
isEditable: true,
|
| 886 |
+
};
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
setWorkspaces(prev => [...prev, newWorkspace]);
|
| 890 |
+
setCurrentWorkspaceId(id);
|
| 891 |
+
|
| 892 |
+
// Set current course if it's a course workspace
|
| 893 |
+
if (payload.category === 'course' && payload.courseId) {
|
| 894 |
+
setCurrentCourseId(payload.courseId);
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
toast.success('New group workspace created');
|
| 898 |
+
};
|
| 899 |
+
|
| 900 |
+
// Handle review click - switch to review mode
|
| 901 |
+
const handleReviewClick = () => {
|
| 902 |
+
setChatMode('review');
|
| 903 |
+
setShowReviewBanner(false);
|
| 904 |
+
localStorage.setItem('reviewBannerDismissed', 'true');
|
| 905 |
+
};
|
| 906 |
+
|
| 907 |
+
// Handle dismiss review banner
|
| 908 |
+
const handleDismissReviewBanner = () => {
|
| 909 |
+
setShowReviewBanner(false);
|
| 910 |
+
localStorage.setItem('reviewBannerDismissed', 'true');
|
| 911 |
+
};
|
| 912 |
+
|
| 913 |
+
// Handle login - check if user needs onboarding
|
| 914 |
+
const handleLogin = (newUser: User) => {
|
| 915 |
+
setUser(newUser);
|
| 916 |
+
// For testing: always show onboarding (comment out localStorage check)
|
| 917 |
+
// const onboardingCompleted = localStorage.getItem(`onboarding_completed_${newUser.email}`);
|
| 918 |
+
// if (!onboardingCompleted) {
|
| 919 |
+
setShowOnboarding(true);
|
| 920 |
+
// }
|
| 921 |
+
};
|
| 922 |
+
|
| 923 |
+
// Handle onboarding completion
|
| 924 |
+
const handleOnboardingComplete = (updatedUser: User) => {
|
| 925 |
+
setUser(updatedUser);
|
| 926 |
+
setShowOnboarding(false);
|
| 927 |
+
// For testing: don't save to localStorage (comment out)
|
| 928 |
+
// localStorage.setItem(`onboarding_completed_${updatedUser.email}`, 'true');
|
| 929 |
+
};
|
| 930 |
+
|
| 931 |
+
// Handle onboarding skip
|
| 932 |
+
const handleOnboardingSkip = () => {
|
| 933 |
+
// For testing: don't save to localStorage (comment out)
|
| 934 |
+
// if (user) {
|
| 935 |
+
// localStorage.setItem(`onboarding_completed_${user.email}`, 'true');
|
| 936 |
+
// }
|
| 937 |
+
setShowOnboarding(false);
|
| 938 |
+
};
|
| 939 |
+
|
| 940 |
+
// Show login screen if user is not logged in
|
| 941 |
+
if (!user) {
|
| 942 |
+
return <LoginScreen onLogin={handleLogin} />;
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
+
// Show onboarding if user just logged in and hasn't completed it
|
| 946 |
+
if (showOnboarding && user) {
|
| 947 |
+
return (
|
| 948 |
+
<>
|
| 949 |
+
<Onboarding
|
| 950 |
+
user={user}
|
| 951 |
+
onComplete={handleOnboardingComplete}
|
| 952 |
+
onSkip={handleOnboardingSkip}
|
| 953 |
+
/>
|
| 954 |
+
</>
|
| 955 |
+
);
|
| 956 |
+
}
|
| 957 |
+
|
| 958 |
+
return (
|
| 959 |
+
<div className="min-h-screen bg-background flex flex-col">
|
| 960 |
+
<Toaster />
|
| 961 |
+
<Header
|
| 962 |
+
user={user}
|
| 963 |
+
onMenuClick={() => setLeftSidebarOpen(!leftSidebarOpen)}
|
| 964 |
+
onUserClick={() => {}}
|
| 965 |
+
isDarkMode={isDarkMode}
|
| 966 |
+
onToggleDarkMode={() => setIsDarkMode(!isDarkMode)}
|
| 967 |
+
language={language}
|
| 968 |
+
onLanguageChange={setLanguage}
|
| 969 |
+
workspaces={workspaces}
|
| 970 |
+
currentWorkspace={currentWorkspace}
|
| 971 |
+
onWorkspaceChange={setCurrentWorkspaceId}
|
| 972 |
+
onCreateWorkspace={handleCreateWorkspace}
|
| 973 |
+
onLogout={() => setUser(null)}
|
| 974 |
+
availableCourses={availableCourses}
|
| 975 |
+
onUserUpdate={setUser}
|
| 976 |
+
/>
|
| 977 |
+
{showProfileEditor && user && (
|
| 978 |
+
<ProfileEditor
|
| 979 |
+
user={user}
|
| 980 |
+
onSave={setUser}
|
| 981 |
+
onClose={() => setShowProfileEditor(false)}
|
| 982 |
+
/>
|
| 983 |
+
)}
|
| 984 |
+
|
| 985 |
+
{/* Review Banner - Below Header */}
|
| 986 |
+
{showReviewBanner && (
|
| 987 |
+
<div className="w-full bg-background border-b border-border flex-shrink-0 relative z-50">
|
| 988 |
+
<ReviewBanner
|
| 989 |
+
onReview={handleReviewClick}
|
| 990 |
+
onDismiss={handleDismissReviewBanner}
|
| 991 |
+
/>
|
| 992 |
+
</div>
|
| 993 |
+
)}
|
| 994 |
+
|
| 995 |
+
<div
|
| 996 |
+
className="flex-1 flex overflow-hidden h-[calc(100vh-4rem)] relative"
|
| 997 |
+
style={{ overscrollBehavior: 'none' }}
|
| 998 |
+
>
|
| 999 |
+
{/* Toggle Button - When panel is closed, at left edge, center axis aligned to left edge */}
|
| 1000 |
+
{!leftPanelVisible && (
|
| 1001 |
+
<Button
|
| 1002 |
+
variant="secondary"
|
| 1003 |
+
size="icon"
|
| 1004 |
+
onClick={() => setLeftPanelVisible(true)}
|
| 1005 |
+
className="hidden lg:flex absolute z-[100] h-8 w-5 shadow-lg rounded-full bg-card border border-border transition-all duration-200 ease-in-out hover:translate-x-[10px]"
|
| 1006 |
+
style={{ left: '-5px', top: '1rem' }}
|
| 1007 |
+
title="Open panel"
|
| 1008 |
+
>
|
| 1009 |
+
<ChevronRight className="h-3 w-3" />
|
| 1010 |
+
</Button>
|
| 1011 |
+
)}
|
| 1012 |
+
{/* Mobile Sidebar Toggle - Left */}
|
| 1013 |
+
{leftSidebarOpen && (
|
| 1014 |
+
<div
|
| 1015 |
+
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
| 1016 |
+
onClick={() => setLeftSidebarOpen(false)}
|
| 1017 |
+
/>
|
| 1018 |
+
)}
|
| 1019 |
+
|
| 1020 |
+
{/* Left Sidebar */}
|
| 1021 |
+
{leftPanelVisible ? (
|
| 1022 |
+
<aside
|
| 1023 |
+
className="hidden lg:flex w-80 bg-card border-r border-border flex-col h-full min-h-0 relative"
|
| 1024 |
+
style={{ borderRight: '1px solid var(--border)', height: 'calc(100vh - 4rem)' }}
|
| 1025 |
+
>
|
| 1026 |
+
{/* Toggle Button - Inside panel, right edge aligned to panel right edge */}
|
| 1027 |
+
<Button
|
| 1028 |
+
variant="secondary"
|
| 1029 |
+
size="icon"
|
| 1030 |
+
onClick={() => setLeftPanelVisible(false)}
|
| 1031 |
+
className="absolute z-[70] h-8 w-5 shadow-lg rounded-full bg-card border border-border"
|
| 1032 |
+
style={{ right: '-10px', top: '1rem' }}
|
| 1033 |
+
title="Close panel"
|
| 1034 |
+
>
|
| 1035 |
+
<ChevronLeft className="h-3 w-3" />
|
| 1036 |
+
</Button>
|
| 1037 |
+
<LeftSidebar
|
| 1038 |
+
learningMode={learningMode}
|
| 1039 |
+
language={language}
|
| 1040 |
+
onLearningModeChange={setLearningMode}
|
| 1041 |
+
onLanguageChange={setLanguage}
|
| 1042 |
+
spaceType={spaceType}
|
| 1043 |
+
groupMembers={groupMembers}
|
| 1044 |
+
user={user}
|
| 1045 |
+
onLogin={setUser}
|
| 1046 |
+
onLogout={() => setUser(null)}
|
| 1047 |
+
isLoggedIn={!!user}
|
| 1048 |
+
onEditProfile={() => setShowProfileEditor(true)}
|
| 1049 |
+
savedItems={savedItems}
|
| 1050 |
+
recentlySavedId={recentlySavedId}
|
| 1051 |
+
onUnsave={handleUnsave}
|
| 1052 |
+
onSave={handleSave}
|
| 1053 |
+
savedChats={savedChats}
|
| 1054 |
+
onLoadChat={handleLoadChat}
|
| 1055 |
+
onDeleteSavedChat={handleDeleteSavedChat}
|
| 1056 |
+
onRenameSavedChat={handleRenameSavedChat}
|
| 1057 |
+
currentWorkspaceId={currentWorkspaceId}
|
| 1058 |
+
workspaces={workspaces}
|
| 1059 |
+
selectedCourse={currentCourseId}
|
| 1060 |
+
availableCourses={availableCourses}
|
| 1061 |
+
/>
|
| 1062 |
+
</aside>
|
| 1063 |
+
) : null}
|
| 1064 |
+
|
| 1065 |
+
{/* Left Sidebar - Mobile */}
|
| 1066 |
+
<aside
|
| 1067 |
+
className={`
|
| 1068 |
+
fixed lg:hidden inset-y-0 left-0 z-50
|
| 1069 |
+
w-80 bg-card border-r border-border
|
| 1070 |
+
transform transition-transform duration-300 ease-in-out
|
| 1071 |
+
${leftSidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
| 1072 |
+
flex flex-col
|
| 1073 |
+
mt-16
|
| 1074 |
+
h-[calc(100vh-4rem)]
|
| 1075 |
+
min-h-0
|
| 1076 |
+
`}
|
| 1077 |
+
>
|
| 1078 |
+
<div className="p-4 border-b border-border flex justify-between items-center">
|
| 1079 |
+
<h3>Settings & Guide</h3>
|
| 1080 |
+
<Button
|
| 1081 |
+
variant="ghost"
|
| 1082 |
+
size="icon"
|
| 1083 |
+
onClick={() => setLeftSidebarOpen(false)}
|
| 1084 |
+
>
|
| 1085 |
+
<X className="h-5 w-5" />
|
| 1086 |
+
</Button>
|
| 1087 |
+
</div>
|
| 1088 |
+
<LeftSidebar
|
| 1089 |
+
learningMode={learningMode}
|
| 1090 |
+
language={language}
|
| 1091 |
+
onLearningModeChange={setLearningMode}
|
| 1092 |
+
onLanguageChange={setLanguage}
|
| 1093 |
+
spaceType={spaceType}
|
| 1094 |
+
groupMembers={groupMembers}
|
| 1095 |
+
user={user}
|
| 1096 |
+
onLogin={setUser}
|
| 1097 |
+
onLogout={() => setUser(null)}
|
| 1098 |
+
isLoggedIn={!!user}
|
| 1099 |
+
onEditProfile={() => setShowProfileEditor(true)}
|
| 1100 |
+
savedItems={savedItems}
|
| 1101 |
+
recentlySavedId={recentlySavedId}
|
| 1102 |
+
onUnsave={handleUnsave}
|
| 1103 |
+
onSave={handleSave}
|
| 1104 |
+
savedChats={savedChats}
|
| 1105 |
+
onLoadChat={handleLoadChat}
|
| 1106 |
+
onDeleteSavedChat={handleDeleteSavedChat}
|
| 1107 |
+
currentWorkspaceId={currentWorkspaceId}
|
| 1108 |
+
workspaces={workspaces}
|
| 1109 |
+
selectedCourse={currentCourseId}
|
| 1110 |
+
availableCourses={availableCourses}
|
| 1111 |
+
/>
|
| 1112 |
+
</aside>
|
| 1113 |
+
|
| 1114 |
+
{/* Main Chat Area */}
|
| 1115 |
+
<main className="flex-1 flex flex-col min-w-0 min-h-0 h-full">
|
| 1116 |
+
<ChatArea
|
| 1117 |
+
messages={messages}
|
| 1118 |
+
onSendMessage={handleSendMessage}
|
| 1119 |
+
uploadedFiles={uploadedFiles}
|
| 1120 |
+
onFileUpload={handleFileUpload}
|
| 1121 |
+
onRemoveFile={handleRemoveFile}
|
| 1122 |
+
onFileTypeChange={handleFileTypeChange}
|
| 1123 |
+
memoryProgress={memoryProgress}
|
| 1124 |
+
isLoggedIn={!!user}
|
| 1125 |
+
learningMode={learningMode}
|
| 1126 |
+
onClearConversation={() => setShowClearDialog(true)}
|
| 1127 |
+
onSaveChat={handleSaveChat}
|
| 1128 |
+
onLearningModeChange={setLearningMode}
|
| 1129 |
+
spaceType={spaceType}
|
| 1130 |
+
chatMode={chatMode}
|
| 1131 |
+
onChatModeChange={setChatMode}
|
| 1132 |
+
onNextQuestion={handleNextQuestion}
|
| 1133 |
+
onStartQuiz={handleStartQuiz}
|
| 1134 |
+
quizState={quizState}
|
| 1135 |
+
isTyping={isTyping}
|
| 1136 |
+
showClearDialog={showClearDialog}
|
| 1137 |
+
onConfirmClear={(shouldSave) => {
|
| 1138 |
+
handleClearConversation(shouldSave);
|
| 1139 |
+
setShowClearDialog(false);
|
| 1140 |
+
}}
|
| 1141 |
+
onCancelClear={() => setShowClearDialog(false)}
|
| 1142 |
+
savedChats={savedChats}
|
| 1143 |
+
workspaces={workspaces}
|
| 1144 |
+
currentWorkspaceId={currentWorkspaceId}
|
| 1145 |
+
onSaveFile={(content, type, _format, targetWorkspaceId) => handleSave(content, type, false, _format ?? 'text', targetWorkspaceId)}
|
| 1146 |
+
leftPanelVisible={leftPanelVisible}
|
| 1147 |
+
currentCourseId={currentCourseId}
|
| 1148 |
+
onCourseChange={setCurrentCourseId}
|
| 1149 |
+
availableCourses={availableCourses}
|
| 1150 |
+
showReviewBanner={showReviewBanner}
|
| 1151 |
+
/>
|
| 1152 |
+
</main>
|
| 1153 |
+
|
| 1154 |
+
</div>
|
| 1155 |
+
</div>
|
| 1156 |
+
);
|
| 1157 |
+
}
|
| 1158 |
+
|
| 1159 |
+
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/assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png
ADDED
|
web/src/components/ChatArea.tsx
ADDED
|
@@ -0,0 +1,1511 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { Button } from './ui/button';
|
| 3 |
+
import { Textarea } from './ui/textarea';
|
| 4 |
+
import { Input } from './ui/input';
|
| 5 |
+
import { Label } from './ui/label';
|
| 6 |
+
import { Send, ArrowDown, AlertCircle, Trash2, Share2, Upload, X, File, FileText, Presentation, Image as ImageIcon, Bookmark, Plus, Download, Copy } from 'lucide-react';
|
| 7 |
+
import { Message } from './Message';
|
| 8 |
+
import { Alert, AlertDescription } from './ui/alert';
|
| 9 |
+
import { Badge } from './ui/badge';
|
| 10 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
| 11 |
+
import type { Message as MessageType, LearningMode, UploadedFile, FileType, SpaceType, ChatMode, SavedChat, Workspace } from '../App';
|
| 12 |
+
import { toast } from 'sonner';
|
| 13 |
+
import { jsPDF } from 'jspdf';
|
| 14 |
+
import {
|
| 15 |
+
DropdownMenu,
|
| 16 |
+
DropdownMenuContent,
|
| 17 |
+
DropdownMenuItem,
|
| 18 |
+
DropdownMenuTrigger,
|
| 19 |
+
} from './ui/dropdown-menu';
|
| 20 |
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogOverlay } from './ui/dialog';
|
| 21 |
+
import { Checkbox } from './ui/checkbox';
|
| 22 |
+
import {
|
| 23 |
+
AlertDialog,
|
| 24 |
+
AlertDialogAction,
|
| 25 |
+
AlertDialogCancel,
|
| 26 |
+
AlertDialogContent,
|
| 27 |
+
AlertDialogDescription,
|
| 28 |
+
AlertDialogFooter,
|
| 29 |
+
AlertDialogHeader,
|
| 30 |
+
AlertDialogTitle,
|
| 31 |
+
} from './ui/alert-dialog';
|
| 32 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
| 33 |
+
import { SmartReview } from './SmartReview';
|
| 34 |
+
import clareAvatar from '../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png';
|
| 35 |
+
|
| 36 |
+
interface ChatAreaProps {
|
| 37 |
+
messages: MessageType[];
|
| 38 |
+
onSendMessage: (content: string) => void;
|
| 39 |
+
uploadedFiles: UploadedFile[];
|
| 40 |
+
onFileUpload: (files: File[]) => void;
|
| 41 |
+
onRemoveFile: (index: number) => void;
|
| 42 |
+
onFileTypeChange: (index: number, type: FileType) => void;
|
| 43 |
+
memoryProgress: number;
|
| 44 |
+
isLoggedIn: boolean;
|
| 45 |
+
learningMode: LearningMode;
|
| 46 |
+
onClearConversation: () => void;
|
| 47 |
+
onSaveChat: () => void;
|
| 48 |
+
onLearningModeChange: (mode: LearningMode) => void;
|
| 49 |
+
spaceType: SpaceType;
|
| 50 |
+
chatMode: ChatMode;
|
| 51 |
+
onChatModeChange: (mode: ChatMode) => void;
|
| 52 |
+
onNextQuestion: () => void;
|
| 53 |
+
onStartQuiz: () => void;
|
| 54 |
+
quizState: {
|
| 55 |
+
currentQuestion: number;
|
| 56 |
+
waitingForAnswer: boolean;
|
| 57 |
+
showNextButton: boolean;
|
| 58 |
+
};
|
| 59 |
+
isTyping: boolean;
|
| 60 |
+
showClearDialog: boolean;
|
| 61 |
+
onConfirmClear: (shouldSave: boolean) => void;
|
| 62 |
+
onCancelClear: () => void;
|
| 63 |
+
savedChats: SavedChat[];
|
| 64 |
+
workspaces: Workspace[];
|
| 65 |
+
currentWorkspaceId: string;
|
| 66 |
+
onSaveFile?: (content: string, type: 'export' | 'summary', format?: 'pdf' | 'text', workspaceId?: string) => void;
|
| 67 |
+
leftPanelVisible?: boolean;
|
| 68 |
+
currentCourseId?: string;
|
| 69 |
+
onCourseChange?: (courseId: string) => void;
|
| 70 |
+
availableCourses?: Array<{ id: string; name: string }>;
|
| 71 |
+
showReviewBanner?: boolean;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
interface PendingFile {
|
| 75 |
+
file: File;
|
| 76 |
+
type: FileType;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
export function ChatArea({
|
| 80 |
+
messages,
|
| 81 |
+
onSendMessage,
|
| 82 |
+
uploadedFiles,
|
| 83 |
+
onFileUpload,
|
| 84 |
+
onRemoveFile,
|
| 85 |
+
onFileTypeChange,
|
| 86 |
+
memoryProgress,
|
| 87 |
+
isLoggedIn,
|
| 88 |
+
learningMode,
|
| 89 |
+
onClearConversation,
|
| 90 |
+
onSaveChat,
|
| 91 |
+
onLearningModeChange,
|
| 92 |
+
spaceType,
|
| 93 |
+
chatMode,
|
| 94 |
+
onChatModeChange,
|
| 95 |
+
onNextQuestion,
|
| 96 |
+
onStartQuiz,
|
| 97 |
+
quizState,
|
| 98 |
+
isTyping: isAppTyping,
|
| 99 |
+
showClearDialog,
|
| 100 |
+
onConfirmClear,
|
| 101 |
+
onCancelClear,
|
| 102 |
+
savedChats,
|
| 103 |
+
workspaces,
|
| 104 |
+
currentWorkspaceId,
|
| 105 |
+
onSaveFile,
|
| 106 |
+
leftPanelVisible = false,
|
| 107 |
+
currentCourseId,
|
| 108 |
+
onCourseChange,
|
| 109 |
+
availableCourses = [],
|
| 110 |
+
showReviewBanner = false,
|
| 111 |
+
}: ChatAreaProps) {
|
| 112 |
+
const [input, setInput] = useState('');
|
| 113 |
+
const [isTyping, setIsTyping] = useState(false);
|
| 114 |
+
const [showScrollButton, setShowScrollButton] = useState(false);
|
| 115 |
+
const [showTopBorder, setShowTopBorder] = useState(false);
|
| 116 |
+
const [isDragging, setIsDragging] = useState(false);
|
| 117 |
+
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
| 118 |
+
const [showTypeDialog, setShowTypeDialog] = useState(false);
|
| 119 |
+
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
| 120 |
+
const [fileToDelete, setFileToDelete] = useState<number | null>(null);
|
| 121 |
+
const [selectedFile, setSelectedFile] = useState<{file: File; index: number} | null>(null);
|
| 122 |
+
const [showFileViewer, setShowFileViewer] = useState(false);
|
| 123 |
+
const [showDownloadDialog, setShowDownloadDialog] = useState(false);
|
| 124 |
+
const [downloadPreview, setDownloadPreview] = useState('');
|
| 125 |
+
const [downloadTab, setDownloadTab] = useState<'chat' | 'summary'>('chat');
|
| 126 |
+
const [downloadOptions, setDownloadOptions] = useState({ chat: true, summary: false });
|
| 127 |
+
const [showShareDialog, setShowShareDialog] = useState(false);
|
| 128 |
+
const [shareLink, setShareLink] = useState('');
|
| 129 |
+
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>('');
|
| 130 |
+
|
| 131 |
+
// Use availableCourses if provided, otherwise fallback to default
|
| 132 |
+
const courses = availableCourses.length > 0 ? availableCourses : [
|
| 133 |
+
{ id: 'course1', name: 'Introduction to AI' },
|
| 134 |
+
{ id: 'course2', name: 'Machine Learning' },
|
| 135 |
+
{ id: 'course3', name: 'Data Structures' },
|
| 136 |
+
{ id: 'course4', name: 'Web Development' },
|
| 137 |
+
];
|
| 138 |
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 139 |
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
| 140 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 141 |
+
const isInitialMount = useRef(true);
|
| 142 |
+
const previousMessagesLength = useRef(messages.length);
|
| 143 |
+
|
| 144 |
+
const scrollToBottom = () => {
|
| 145 |
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
// Only auto-scroll when new messages are added (not on initial load)
|
| 149 |
+
useEffect(() => {
|
| 150 |
+
// Skip auto-scroll on initial mount
|
| 151 |
+
if (isInitialMount.current) {
|
| 152 |
+
isInitialMount.current = false;
|
| 153 |
+
previousMessagesLength.current = messages.length;
|
| 154 |
+
// Ensure we stay at top on initial load
|
| 155 |
+
if (scrollContainerRef.current) {
|
| 156 |
+
scrollContainerRef.current.scrollTop = 0;
|
| 157 |
+
}
|
| 158 |
+
return;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// Only scroll if new messages were added (length increased)
|
| 162 |
+
if (messages.length > previousMessagesLength.current) {
|
| 163 |
+
scrollToBottom();
|
| 164 |
+
}
|
| 165 |
+
previousMessagesLength.current = messages.length;
|
| 166 |
+
}, [messages]);
|
| 167 |
+
|
| 168 |
+
useEffect(() => {
|
| 169 |
+
const handleScroll = () => {
|
| 170 |
+
if (scrollContainerRef.current) {
|
| 171 |
+
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
| 172 |
+
const isAtBottom = scrollHeight - scrollTop - clientHeight < 100;
|
| 173 |
+
setShowScrollButton(!isAtBottom);
|
| 174 |
+
// Show border when content is scrolled below the fixed bar
|
| 175 |
+
setShowTopBorder(scrollTop > 0);
|
| 176 |
+
}
|
| 177 |
+
};
|
| 178 |
+
|
| 179 |
+
const container = scrollContainerRef.current;
|
| 180 |
+
if (container) {
|
| 181 |
+
// Check initial state
|
| 182 |
+
handleScroll();
|
| 183 |
+
container.addEventListener('scroll', handleScroll);
|
| 184 |
+
return () => container.removeEventListener('scroll', handleScroll);
|
| 185 |
+
}
|
| 186 |
+
}, [messages]);
|
| 187 |
+
|
| 188 |
+
// Use native event listeners to prevent scroll propagation to left panel
|
| 189 |
+
useEffect(() => {
|
| 190 |
+
const container = scrollContainerRef.current;
|
| 191 |
+
if (!container) return;
|
| 192 |
+
|
| 193 |
+
const handleWheel = (e: WheelEvent) => {
|
| 194 |
+
// Always stop propagation to prevent scrolling left panel
|
| 195 |
+
e.stopPropagation();
|
| 196 |
+
e.stopImmediatePropagation();
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
+
container.addEventListener('wheel', handleWheel, { passive: false, capture: true });
|
| 200 |
+
|
| 201 |
+
return () => {
|
| 202 |
+
container.removeEventListener('wheel', handleWheel, { capture: true });
|
| 203 |
+
};
|
| 204 |
+
}, []);
|
| 205 |
+
|
| 206 |
+
const handleSubmit = (e: React.FormEvent) => {
|
| 207 |
+
e.preventDefault();
|
| 208 |
+
if (!input.trim() || !isLoggedIn) return;
|
| 209 |
+
|
| 210 |
+
onSendMessage(input);
|
| 211 |
+
setInput('');
|
| 212 |
+
// All modes now use isAppTyping from App.tsx, so we don't set local isTyping
|
| 213 |
+
};
|
| 214 |
+
|
| 215 |
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
| 216 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 217 |
+
e.preventDefault();
|
| 218 |
+
handleSubmit(e);
|
| 219 |
+
}
|
| 220 |
+
};
|
| 221 |
+
|
| 222 |
+
const modeLabels: Record<LearningMode, string> = {
|
| 223 |
+
general: 'General',
|
| 224 |
+
concept: 'Concept Explainer',
|
| 225 |
+
socratic: 'Socratic Tutor',
|
| 226 |
+
exam: 'Exam Prep',
|
| 227 |
+
assignment: 'Assignment Helper',
|
| 228 |
+
summary: 'Quick Summary',
|
| 229 |
+
};
|
| 230 |
+
|
| 231 |
+
// Handle review topic button click
|
| 232 |
+
const handleReviewTopic = (item: { title: string; previousQuestion: string; memoryRetention: number; schedule: string; status: string; weight: number; lastReviewed: string }) => {
|
| 233 |
+
// Send a user message with review request and data
|
| 234 |
+
// Format: User-friendly message + hidden data for parsing
|
| 235 |
+
const userMessage = `Please help me review: ${item.title}`;
|
| 236 |
+
const reviewData = `REVIEW_TOPIC:${item.title}|${item.previousQuestion}|${item.memoryRetention}|${item.schedule}|${item.status}|${item.weight}|${item.lastReviewed}`;
|
| 237 |
+
// Store review data temporarily to pass to App.tsx
|
| 238 |
+
(window as any).__lastReviewData = reviewData;
|
| 239 |
+
onSendMessage(userMessage);
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
// Handle review all button click
|
| 243 |
+
const handleReviewAll = () => {
|
| 244 |
+
// Send a user message requesting review all
|
| 245 |
+
(window as any).__lastReviewData = 'REVIEW_ALL';
|
| 246 |
+
onSendMessage('Please help me review all topics that need attention.');
|
| 247 |
+
};
|
| 248 |
+
|
| 249 |
+
const handleClearClick = () => {
|
| 250 |
+
// Check if current chat is a saved/previewed chat
|
| 251 |
+
const isSavedChat = isCurrentChatSaved();
|
| 252 |
+
|
| 253 |
+
// If viewing a saved chat, clear directly without dialog
|
| 254 |
+
if (isSavedChat) {
|
| 255 |
+
// Directly clear without showing dialog
|
| 256 |
+
onConfirmClear(false);
|
| 257 |
+
return;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
// Check if there are user messages (not just welcome message)
|
| 261 |
+
const hasUserMessages = messages.some(msg => msg.role === 'user');
|
| 262 |
+
if (!hasUserMessages) {
|
| 263 |
+
// No user messages, just clear without showing dialog
|
| 264 |
+
onClearConversation();
|
| 265 |
+
return;
|
| 266 |
+
}
|
| 267 |
+
// User messages exist, show dialog to save or discard
|
| 268 |
+
onClearConversation();
|
| 269 |
+
};
|
| 270 |
+
|
| 271 |
+
const buildPreviewContent = () => {
|
| 272 |
+
if (!messages.length) return '';
|
| 273 |
+
return messages
|
| 274 |
+
.map((msg) => `${msg.role === 'user' ? 'You' : 'Clare'}: ${msg.content}`)
|
| 275 |
+
.join('\n\n');
|
| 276 |
+
};
|
| 277 |
+
|
| 278 |
+
const buildSummaryContent = () => {
|
| 279 |
+
if (!messages.length) return 'No messages to summarize.';
|
| 280 |
+
|
| 281 |
+
// Simple summary: count messages and list main topics
|
| 282 |
+
const userMessages = messages.filter(msg => msg.role === 'user');
|
| 283 |
+
const assistantMessages = messages.filter(msg => msg.role === 'assistant');
|
| 284 |
+
|
| 285 |
+
let summary = `Chat Summary\n================\n\n`;
|
| 286 |
+
summary += `Total Messages: ${messages.length}\n`;
|
| 287 |
+
summary += `- User Messages: ${userMessages.length}\n`;
|
| 288 |
+
summary += `- Assistant Responses: ${assistantMessages.length}\n\n`;
|
| 289 |
+
|
| 290 |
+
summary += `Key Points:\n`;
|
| 291 |
+
// Extract first 3 user messages as key points
|
| 292 |
+
userMessages.slice(0, 3).forEach((msg, idx) => {
|
| 293 |
+
const preview = msg.content.substring(0, 80);
|
| 294 |
+
summary += `${idx + 1}. ${preview}${msg.content.length > 80 ? '...' : ''}\n`;
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
return summary;
|
| 298 |
+
};
|
| 299 |
+
|
| 300 |
+
const handleOpenDownloadDialog = () => {
|
| 301 |
+
setDownloadTab('chat');
|
| 302 |
+
setDownloadOptions({ chat: true, summary: false });
|
| 303 |
+
setDownloadPreview(buildPreviewContent());
|
| 304 |
+
setShowDownloadDialog(true);
|
| 305 |
+
};
|
| 306 |
+
|
| 307 |
+
const handleCopyPreview = async () => {
|
| 308 |
+
try {
|
| 309 |
+
await navigator.clipboard.writeText(downloadPreview);
|
| 310 |
+
toast.success('Copied preview');
|
| 311 |
+
} catch (e) {
|
| 312 |
+
toast.error('Copy failed');
|
| 313 |
+
}
|
| 314 |
+
};
|
| 315 |
+
|
| 316 |
+
const handleDownloadFile = async () => {
|
| 317 |
+
try {
|
| 318 |
+
let contentToPdf = '';
|
| 319 |
+
|
| 320 |
+
// Build content based on selected options
|
| 321 |
+
if (downloadOptions.chat) {
|
| 322 |
+
contentToPdf += buildPreviewContent();
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
if (downloadOptions.summary) {
|
| 326 |
+
if (downloadOptions.chat) {
|
| 327 |
+
contentToPdf += '\n\n================\n\n';
|
| 328 |
+
}
|
| 329 |
+
contentToPdf += buildSummaryContent();
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
if (!contentToPdf.trim()) {
|
| 333 |
+
toast.error('Please select at least one option');
|
| 334 |
+
return;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
// Create PDF
|
| 338 |
+
const pdf = new jsPDF({
|
| 339 |
+
orientation: 'portrait',
|
| 340 |
+
unit: 'mm',
|
| 341 |
+
format: 'a4'
|
| 342 |
+
});
|
| 343 |
+
|
| 344 |
+
// Set font
|
| 345 |
+
pdf.setFontSize(11);
|
| 346 |
+
|
| 347 |
+
// Add title
|
| 348 |
+
pdf.setFontSize(14);
|
| 349 |
+
pdf.text('Chat Export', 10, 10);
|
| 350 |
+
pdf.setFontSize(11);
|
| 351 |
+
|
| 352 |
+
// Split content into lines and add to PDF
|
| 353 |
+
const pageHeight = pdf.internal.pageSize.getHeight();
|
| 354 |
+
const margin = 10;
|
| 355 |
+
const maxWidth = 190; // A4 width minus margins
|
| 356 |
+
const lineHeight = 5;
|
| 357 |
+
let yPosition = 20;
|
| 358 |
+
|
| 359 |
+
// Split text into lines that fit in the page
|
| 360 |
+
const lines = pdf.splitTextToSize(contentToPdf, maxWidth);
|
| 361 |
+
|
| 362 |
+
lines.forEach((line: string) => {
|
| 363 |
+
if (yPosition > pageHeight - margin) {
|
| 364 |
+
pdf.addPage();
|
| 365 |
+
yPosition = margin;
|
| 366 |
+
}
|
| 367 |
+
pdf.text(line, margin, yPosition);
|
| 368 |
+
yPosition += lineHeight;
|
| 369 |
+
});
|
| 370 |
+
|
| 371 |
+
// Save PDF
|
| 372 |
+
pdf.save('chat-export.pdf');
|
| 373 |
+
setShowDownloadDialog(false);
|
| 374 |
+
toast.success('PDF downloaded successfully');
|
| 375 |
+
} catch (error) {
|
| 376 |
+
console.error('PDF generation error:', error);
|
| 377 |
+
toast.error('Failed to generate PDF');
|
| 378 |
+
}
|
| 379 |
+
};
|
| 380 |
+
|
| 381 |
+
// Check if current chat is already saved
|
| 382 |
+
const isCurrentChatSaved = (): boolean => {
|
| 383 |
+
if (messages.length <= 1) return false;
|
| 384 |
+
|
| 385 |
+
// Find a saved chat that matches the current messages and chatMode
|
| 386 |
+
return savedChats.some(chat => {
|
| 387 |
+
if (chat.chatMode !== chatMode) return false;
|
| 388 |
+
if (chat.messages.length !== messages.length) return false;
|
| 389 |
+
|
| 390 |
+
// Check if all messages match
|
| 391 |
+
return chat.messages.every((savedMsg, index) => {
|
| 392 |
+
const currentMsg = messages[index];
|
| 393 |
+
return (
|
| 394 |
+
savedMsg.id === currentMsg.id &&
|
| 395 |
+
savedMsg.role === currentMsg.role &&
|
| 396 |
+
savedMsg.content === currentMsg.content
|
| 397 |
+
);
|
| 398 |
+
});
|
| 399 |
+
});
|
| 400 |
+
};
|
| 401 |
+
|
| 402 |
+
const handleSaveClick = () => {
|
| 403 |
+
if (messages.length <= 1) {
|
| 404 |
+
toast.info('No conversation to save');
|
| 405 |
+
return;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
onSaveChat();
|
| 409 |
+
};
|
| 410 |
+
|
| 411 |
+
const handleShareClick = () => {
|
| 412 |
+
if (messages.length <= 1) {
|
| 413 |
+
toast.info('No conversation to share');
|
| 414 |
+
return;
|
| 415 |
+
}
|
| 416 |
+
// Create a temporary share link for this session
|
| 417 |
+
const conversationText = buildPreviewContent();
|
| 418 |
+
const blob = new Blob([conversationText], { type: 'text/plain' });
|
| 419 |
+
const url = URL.createObjectURL(blob);
|
| 420 |
+
setShareLink(url);
|
| 421 |
+
// Default to current workspace
|
| 422 |
+
setTargetWorkspaceId(currentWorkspaceId);
|
| 423 |
+
setShowShareDialog(true);
|
| 424 |
+
};
|
| 425 |
+
|
| 426 |
+
const handleCopyShareLink = async () => {
|
| 427 |
+
try {
|
| 428 |
+
await navigator.clipboard.writeText(shareLink);
|
| 429 |
+
toast.success('Link copied');
|
| 430 |
+
} catch {
|
| 431 |
+
toast.error('Failed to copy link');
|
| 432 |
+
}
|
| 433 |
+
};
|
| 434 |
+
|
| 435 |
+
const handleShareSendToWorkspace = () => {
|
| 436 |
+
const content = buildPreviewContent();
|
| 437 |
+
onSaveFile?.(content, 'export', 'text', targetWorkspaceId);
|
| 438 |
+
setShowShareDialog(false);
|
| 439 |
+
toast.success('Sent to workspace Saved Files');
|
| 440 |
+
};
|
| 441 |
+
|
| 442 |
+
const handleDragOver = (e: React.DragEvent) => {
|
| 443 |
+
e.preventDefault();
|
| 444 |
+
e.stopPropagation();
|
| 445 |
+
if (!isLoggedIn) return;
|
| 446 |
+
setIsDragging(true);
|
| 447 |
+
};
|
| 448 |
+
|
| 449 |
+
const handleDragLeave = (e: React.DragEvent) => {
|
| 450 |
+
e.preventDefault();
|
| 451 |
+
e.stopPropagation();
|
| 452 |
+
setIsDragging(false);
|
| 453 |
+
};
|
| 454 |
+
|
| 455 |
+
const handleDrop = (e: React.DragEvent) => {
|
| 456 |
+
e.preventDefault();
|
| 457 |
+
e.stopPropagation();
|
| 458 |
+
setIsDragging(false);
|
| 459 |
+
if (!isLoggedIn) return;
|
| 460 |
+
|
| 461 |
+
const fileList = e.dataTransfer.files;
|
| 462 |
+
const files: File[] = [];
|
| 463 |
+
for (let i = 0; i < fileList.length; i++) {
|
| 464 |
+
const file = fileList.item(i);
|
| 465 |
+
if (file) {
|
| 466 |
+
files.push(file);
|
| 467 |
+
}
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
const validFiles = files.filter((file) => {
|
| 471 |
+
const ext = file.name.toLowerCase();
|
| 472 |
+
return ['.pdf', '.docx', '.pptx', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.doc', '.ppt'].some((allowedExt) =>
|
| 473 |
+
ext.endsWith(allowedExt)
|
| 474 |
+
);
|
| 475 |
+
});
|
| 476 |
+
|
| 477 |
+
if (validFiles.length > 0) {
|
| 478 |
+
setPendingFiles(validFiles.map(file => ({ file, type: 'other' as FileType })));
|
| 479 |
+
setShowTypeDialog(true);
|
| 480 |
+
} else {
|
| 481 |
+
toast.error('Please upload .pdf, .docx, .pptx, or image files');
|
| 482 |
+
}
|
| 483 |
+
};
|
| 484 |
+
|
| 485 |
+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 486 |
+
const files = Array.from(e.target.files || []) as File[];
|
| 487 |
+
if (files.length > 0) {
|
| 488 |
+
const validFiles = files.filter((file) => {
|
| 489 |
+
const ext = file.name.toLowerCase();
|
| 490 |
+
return ['.pdf', '.docx', '.pptx', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.doc', '.ppt'].some((allowedExt) =>
|
| 491 |
+
ext.endsWith(allowedExt)
|
| 492 |
+
);
|
| 493 |
+
});
|
| 494 |
+
if (validFiles.length > 0) {
|
| 495 |
+
setPendingFiles(validFiles.map(file => ({ file, type: 'other' as FileType })));
|
| 496 |
+
setShowTypeDialog(true);
|
| 497 |
+
} else {
|
| 498 |
+
toast.error('Please upload .pdf, .docx, .pptx, or image files');
|
| 499 |
+
}
|
| 500 |
+
}
|
| 501 |
+
e.target.value = '';
|
| 502 |
+
};
|
| 503 |
+
|
| 504 |
+
const handleConfirmUpload = () => {
|
| 505 |
+
onFileUpload(pendingFiles.map(pf => pf.file));
|
| 506 |
+
// Update the parent's file types
|
| 507 |
+
const startIndex = uploadedFiles.length;
|
| 508 |
+
pendingFiles.forEach((pf, idx) => {
|
| 509 |
+
setTimeout(() => {
|
| 510 |
+
onFileTypeChange(startIndex + idx, pf.type);
|
| 511 |
+
}, 0);
|
| 512 |
+
});
|
| 513 |
+
setPendingFiles([]);
|
| 514 |
+
setShowTypeDialog(false);
|
| 515 |
+
toast.success(`${pendingFiles.length} file(s) uploaded successfully`);
|
| 516 |
+
};
|
| 517 |
+
|
| 518 |
+
const handleCancelUpload = () => {
|
| 519 |
+
setPendingFiles([]);
|
| 520 |
+
setShowTypeDialog(false);
|
| 521 |
+
};
|
| 522 |
+
|
| 523 |
+
const handlePendingFileTypeChange = (index: number, type: FileType) => {
|
| 524 |
+
setPendingFiles(prev => prev.map((pf, i) =>
|
| 525 |
+
i === index ? { ...pf, type } : pf
|
| 526 |
+
));
|
| 527 |
+
};
|
| 528 |
+
|
| 529 |
+
const getFileIcon = (filename: string) => {
|
| 530 |
+
const ext = filename.toLowerCase();
|
| 531 |
+
if (ext.endsWith('.pdf')) return FileText;
|
| 532 |
+
if (ext.endsWith('.docx') || ext.endsWith('.doc')) return File;
|
| 533 |
+
if (ext.endsWith('.pptx') || ext.endsWith('.ppt')) return Presentation;
|
| 534 |
+
if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].some(e => ext.endsWith(e))) return ImageIcon;
|
| 535 |
+
return File;
|
| 536 |
+
};
|
| 537 |
+
|
| 538 |
+
const getFileTypeInfo = (filename: string) => {
|
| 539 |
+
const ext = filename.toLowerCase();
|
| 540 |
+
if (ext.endsWith('.pdf')) {
|
| 541 |
+
return { bgColor: 'bg-red-500', type: 'PDF' };
|
| 542 |
+
}
|
| 543 |
+
if (ext.endsWith('.docx') || ext.endsWith('.doc')) {
|
| 544 |
+
return { bgColor: 'bg-blue-500', type: 'Document' };
|
| 545 |
+
}
|
| 546 |
+
if (ext.endsWith('.pptx') || ext.endsWith('.ppt')) {
|
| 547 |
+
return { bgColor: 'bg-orange-500', type: 'Presentation' };
|
| 548 |
+
}
|
| 549 |
+
if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].some(e => ext.endsWith(e))) {
|
| 550 |
+
return { bgColor: 'bg-green-500', type: 'Image' };
|
| 551 |
+
}
|
| 552 |
+
return { bgColor: 'bg-gray-500', type: 'File' };
|
| 553 |
+
};
|
| 554 |
+
|
| 555 |
+
const formatFileSize = (bytes: number) => {
|
| 556 |
+
if (bytes < 1024) return bytes + ' B';
|
| 557 |
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
| 558 |
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
| 559 |
+
};
|
| 560 |
+
|
| 561 |
+
// File Thumbnail Component - ChatGPT style
|
| 562 |
+
const FileThumbnail = ({
|
| 563 |
+
file,
|
| 564 |
+
Icon,
|
| 565 |
+
fileInfo,
|
| 566 |
+
isImage,
|
| 567 |
+
onPreview,
|
| 568 |
+
onRemove
|
| 569 |
+
}: {
|
| 570 |
+
file: File;
|
| 571 |
+
Icon: React.ComponentType<{ className?: string }>;
|
| 572 |
+
fileInfo: { bgColor: string; type: string };
|
| 573 |
+
isImage: boolean;
|
| 574 |
+
onPreview: () => void;
|
| 575 |
+
onRemove: (e: React.MouseEvent) => void;
|
| 576 |
+
}) => {
|
| 577 |
+
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
| 578 |
+
const [imageLoading, setImageLoading] = useState(true);
|
| 579 |
+
|
| 580 |
+
useEffect(() => {
|
| 581 |
+
if (isImage) {
|
| 582 |
+
setImageLoading(true);
|
| 583 |
+
const reader = new FileReader();
|
| 584 |
+
reader.onload = (e) => {
|
| 585 |
+
setImagePreview(e.target?.result as string);
|
| 586 |
+
setImageLoading(false);
|
| 587 |
+
};
|
| 588 |
+
reader.onerror = () => {
|
| 589 |
+
setImageLoading(false);
|
| 590 |
+
};
|
| 591 |
+
reader.readAsDataURL(file);
|
| 592 |
+
} else {
|
| 593 |
+
setImagePreview(null);
|
| 594 |
+
setImageLoading(false);
|
| 595 |
+
}
|
| 596 |
+
}, [file, isImage]);
|
| 597 |
+
|
| 598 |
+
// All files have consistent height: h-16 (64px)
|
| 599 |
+
// Image files: square card (w-16 h-16), no filename
|
| 600 |
+
if (isImage) {
|
| 601 |
+
return (
|
| 602 |
+
<div
|
| 603 |
+
className="relative cursor-pointer w-16 h-16 flex-shrink-0"
|
| 604 |
+
onClick={onPreview}
|
| 605 |
+
style={{ width: '64px', height: '64px', flexShrink: 0 }}
|
| 606 |
+
>
|
| 607 |
+
{/* Square image thumbnail card - ChatGPT style, same height as other files */}
|
| 608 |
+
<div className="w-full h-full relative bg-card border border-border rounded-lg hover:border-primary/50 transition-colors">
|
| 609 |
+
<div className="w-full h-full overflow-hidden rounded-lg absolute inset-0">
|
| 610 |
+
{imageLoading ? (
|
| 611 |
+
<div className="w-full h-full flex items-center justify-center bg-muted">
|
| 612 |
+
<Icon className="h-5 w-5 text-muted-foreground animate-pulse" />
|
| 613 |
+
</div>
|
| 614 |
+
) : imagePreview ? (
|
| 615 |
+
<img
|
| 616 |
+
src={imagePreview}
|
| 617 |
+
alt={file.name}
|
| 618 |
+
className="w-full h-full object-cover"
|
| 619 |
+
onError={(e) => {
|
| 620 |
+
e.currentTarget.style.display = 'none';
|
| 621 |
+
setImageLoading(false);
|
| 622 |
+
}}
|
| 623 |
+
/>
|
| 624 |
+
) : (
|
| 625 |
+
<div className="w-full h-full flex items-center justify-center bg-muted">
|
| 626 |
+
<Icon className="h-5 w-5 text-muted-foreground" />
|
| 627 |
+
</div>
|
| 628 |
+
)}
|
| 629 |
+
</div>
|
| 630 |
+
{/* Remove button - top right corner inside card, always visible - ChatGPT style */}
|
| 631 |
+
<button
|
| 632 |
+
type="button"
|
| 633 |
+
className="absolute top-1 right-1 h-4 w-4 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-sm flex items-center justify-center cursor-pointer"
|
| 634 |
+
onClick={(e) => {
|
| 635 |
+
e.stopPropagation();
|
| 636 |
+
onRemove(e);
|
| 637 |
+
}}
|
| 638 |
+
style={{ zIndex: 100, position: 'absolute', top: '4px', right: '4px' }}
|
| 639 |
+
>
|
| 640 |
+
<X
|
| 641 |
+
className="h-2.5 w-2.5"
|
| 642 |
+
style={{
|
| 643 |
+
color: 'rgb(0, 0, 0)',
|
| 644 |
+
strokeWidth: 2
|
| 645 |
+
}}
|
| 646 |
+
/>
|
| 647 |
+
</button>
|
| 648 |
+
</div>
|
| 649 |
+
</div>
|
| 650 |
+
);
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
// Other files: horizontal rectangle with icon, filename and type (same height as images, fixed width)
|
| 654 |
+
return (
|
| 655 |
+
<div
|
| 656 |
+
className="relative cursor-pointer"
|
| 657 |
+
onClick={onPreview}
|
| 658 |
+
style={{ width: '240px', flexShrink: 0 }}
|
| 659 |
+
>
|
| 660 |
+
{/* Horizontal file card - ChatGPT style, same height as images, fixed width */}
|
| 661 |
+
<div className="h-16 w-full relative flex items-center px-3 bg-card border border-border rounded-lg hover:border-primary/50 transition-colors">
|
| 662 |
+
{/* File icon with colored background */}
|
| 663 |
+
<div className={`${fileInfo.bgColor} flex items-center justify-center w-10 h-10 rounded shrink-0`}>
|
| 664 |
+
<Icon className="h-5 w-5 text-white" />
|
| 665 |
+
</div>
|
| 666 |
+
{/* Spacing between icon and filename - one character width */}
|
| 667 |
+
<div className="w-2 shrink-0" />
|
| 668 |
+
{/* File name and type */}
|
| 669 |
+
<div className="flex-1 min-w-0 flex flex-col justify-center pr-7">
|
| 670 |
+
<p className="text-xs text-foreground truncate" title={file.name}>
|
| 671 |
+
{file.name}
|
| 672 |
+
</p>
|
| 673 |
+
<p className="text-[10px] text-muted-foreground mt-0.5 truncate">
|
| 674 |
+
{fileInfo.type}
|
| 675 |
+
</p>
|
| 676 |
+
</div>
|
| 677 |
+
{/* Remove button - top right corner inside card, always visible - ChatGPT style */}
|
| 678 |
+
<button
|
| 679 |
+
type="button"
|
| 680 |
+
className="absolute top-1 right-1 h-4 w-4 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-sm flex items-center justify-center cursor-pointer z-10"
|
| 681 |
+
onClick={(e) => {
|
| 682 |
+
e.stopPropagation();
|
| 683 |
+
onRemove(e);
|
| 684 |
+
}}
|
| 685 |
+
style={{ position: 'absolute', top: '4px', right: '4px', zIndex: 10 }}
|
| 686 |
+
>
|
| 687 |
+
<X
|
| 688 |
+
className="h-2.5 w-2.5"
|
| 689 |
+
style={{
|
| 690 |
+
color: 'rgb(0, 0, 0)',
|
| 691 |
+
strokeWidth: 2
|
| 692 |
+
}}
|
| 693 |
+
/>
|
| 694 |
+
</button>
|
| 695 |
+
</div>
|
| 696 |
+
</div>
|
| 697 |
+
);
|
| 698 |
+
};
|
| 699 |
+
|
| 700 |
+
// File Viewer Content Component
|
| 701 |
+
const FileViewerContent = ({ file }: { file: File }) => {
|
| 702 |
+
const [content, setContent] = useState<string>('');
|
| 703 |
+
const [loading, setLoading] = useState(true);
|
| 704 |
+
const [error, setError] = useState<string | null>(null);
|
| 705 |
+
|
| 706 |
+
useEffect(() => {
|
| 707 |
+
const loadFile = async () => {
|
| 708 |
+
try {
|
| 709 |
+
setLoading(true);
|
| 710 |
+
setError(null);
|
| 711 |
+
|
| 712 |
+
const ext = file.name.toLowerCase();
|
| 713 |
+
|
| 714 |
+
if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].some(e => ext.endsWith(e))) {
|
| 715 |
+
// Image file
|
| 716 |
+
const reader = new FileReader();
|
| 717 |
+
reader.onload = (e) => {
|
| 718 |
+
setContent(e.target?.result as string);
|
| 719 |
+
setLoading(false);
|
| 720 |
+
};
|
| 721 |
+
reader.onerror = () => {
|
| 722 |
+
setError('Failed to load image');
|
| 723 |
+
setLoading(false);
|
| 724 |
+
};
|
| 725 |
+
reader.readAsDataURL(file);
|
| 726 |
+
} else if (ext.endsWith('.pdf')) {
|
| 727 |
+
// PDF file - show info
|
| 728 |
+
setContent('PDF files cannot be previewed directly. Please download the file to view it.');
|
| 729 |
+
setLoading(false);
|
| 730 |
+
} else {
|
| 731 |
+
// Text-based files
|
| 732 |
+
const reader = new FileReader();
|
| 733 |
+
reader.onload = (e) => {
|
| 734 |
+
setContent(e.target?.result as string);
|
| 735 |
+
setLoading(false);
|
| 736 |
+
};
|
| 737 |
+
reader.onerror = () => {
|
| 738 |
+
setError('Failed to load file');
|
| 739 |
+
setLoading(false);
|
| 740 |
+
};
|
| 741 |
+
reader.readAsText(file);
|
| 742 |
+
}
|
| 743 |
+
} catch (err) {
|
| 744 |
+
setError('Failed to load file');
|
| 745 |
+
setLoading(false);
|
| 746 |
+
}
|
| 747 |
+
};
|
| 748 |
+
|
| 749 |
+
loadFile();
|
| 750 |
+
}, [file]);
|
| 751 |
+
|
| 752 |
+
if (loading) {
|
| 753 |
+
return <div className="text-center py-8">Loading...</div>;
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
if (error) {
|
| 757 |
+
return <div className="text-center py-8 text-destructive">{error}</div>;
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
const ext = file.name.toLowerCase();
|
| 761 |
+
const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].some(e => ext.endsWith(e));
|
| 762 |
+
|
| 763 |
+
if (isImage) {
|
| 764 |
+
return (
|
| 765 |
+
<div className="flex justify-center">
|
| 766 |
+
<img src={content} alt={file.name} className="max-w-full h-auto rounded-lg" />
|
| 767 |
+
</div>
|
| 768 |
+
);
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
return (
|
| 772 |
+
<div className="whitespace-pre-wrap text-sm font-mono p-4 bg-muted rounded-lg max-h-[60vh] overflow-y-auto">
|
| 773 |
+
{content}
|
| 774 |
+
</div>
|
| 775 |
+
);
|
| 776 |
+
};
|
| 777 |
+
|
| 778 |
+
const getFileTypeLabel = (type: FileType) => {
|
| 779 |
+
const labels: Record<FileType, string> = {
|
| 780 |
+
'syllabus': 'Syllabus',
|
| 781 |
+
'lecture-slides': 'Lecture Slides / PPT',
|
| 782 |
+
'literature-review': 'Literature Review / Paper',
|
| 783 |
+
'other': 'Other Course Document',
|
| 784 |
+
};
|
| 785 |
+
return labels[type];
|
| 786 |
+
};
|
| 787 |
+
|
| 788 |
+
return (
|
| 789 |
+
<div className="flex flex-col h-full min-h-0 overflow-hidden" style={{ overscrollBehavior: 'none' }}>
|
| 790 |
+
{/* Chat Area with Floating Input */}
|
| 791 |
+
<div className="flex-1 relative min-h-0 flex flex-col overflow-hidden" style={{ overscrollBehavior: 'none' }}>
|
| 792 |
+
{/* Messages Area */}
|
| 793 |
+
<div
|
| 794 |
+
ref={scrollContainerRef}
|
| 795 |
+
className="flex-1 overflow-y-auto"
|
| 796 |
+
style={{
|
| 797 |
+
overscrollBehavior: 'none',
|
| 798 |
+
}}
|
| 799 |
+
>
|
| 800 |
+
{/* Top Bar - Course Selector, Chat Mode Tabs, and Action Buttons - Sticky at top */}
|
| 801 |
+
<div
|
| 802 |
+
className={`sticky top-0 flex items-center justify-between px-4 z-20 bg-card ${
|
| 803 |
+
showTopBorder ? 'border-b border-border' : ''
|
| 804 |
+
}`}
|
| 805 |
+
style={{
|
| 806 |
+
height: '4.5rem',
|
| 807 |
+
margin: 0,
|
| 808 |
+
padding: '1rem 1rem',
|
| 809 |
+
boxSizing: 'border-box',
|
| 810 |
+
}}
|
| 811 |
+
>
|
| 812 |
+
{/* Course Selector - Left */}
|
| 813 |
+
<div className="flex-shrink-0">
|
| 814 |
+
{(() => {
|
| 815 |
+
const current = workspaces.find(w => w.id === currentWorkspaceId);
|
| 816 |
+
if (current?.type === 'group') {
|
| 817 |
+
if (current.category === 'course' && current.courseName) {
|
| 818 |
+
// Show fixed course label, not selectable
|
| 819 |
+
return (
|
| 820 |
+
<div className="h-9 px-3 inline-flex items-center rounded-md border font-semibold">
|
| 821 |
+
{current.courseName}
|
| 822 |
+
</div>
|
| 823 |
+
);
|
| 824 |
+
}
|
| 825 |
+
// Personal interest: hide selector
|
| 826 |
+
return null;
|
| 827 |
+
}
|
| 828 |
+
// Individual workspace: show selectable courses
|
| 829 |
+
return (
|
| 830 |
+
<Select value={currentCourseId || 'course1'} onValueChange={(val) => onCourseChange && onCourseChange(val)}>
|
| 831 |
+
<SelectTrigger className="w-[200px] h-9 font-semibold">
|
| 832 |
+
<SelectValue placeholder="Select course" />
|
| 833 |
+
</SelectTrigger>
|
| 834 |
+
<SelectContent>
|
| 835 |
+
{courses.map((course) => (
|
| 836 |
+
<SelectItem key={course.id} value={course.id}>
|
| 837 |
+
{course.name}
|
| 838 |
+
</SelectItem>
|
| 839 |
+
))}
|
| 840 |
+
</SelectContent>
|
| 841 |
+
</Select>
|
| 842 |
+
);
|
| 843 |
+
})()}
|
| 844 |
+
</div>
|
| 845 |
+
|
| 846 |
+
{/* Chat Mode Tabs - Center (aligned with panel toggle button) */}
|
| 847 |
+
<div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0">
|
| 848 |
+
<Tabs
|
| 849 |
+
value={chatMode}
|
| 850 |
+
onValueChange={(value) => onChatModeChange(value as ChatMode)}
|
| 851 |
+
className="w-auto"
|
| 852 |
+
orientation="horizontal"
|
| 853 |
+
>
|
| 854 |
+
<TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
|
| 855 |
+
<TabsTrigger value="ask" className="w-[140px] px-3 text-sm">Ask</TabsTrigger>
|
| 856 |
+
<TabsTrigger value="review" className="w-[140px] px-3 text-sm relative">
|
| 857 |
+
Review
|
| 858 |
+
{/* Red dot badge in top-right corner */}
|
| 859 |
+
<span
|
| 860 |
+
className="absolute top-0 right-0 bg-red-500 rounded-full border-2"
|
| 861 |
+
style={{
|
| 862 |
+
width: '10px',
|
| 863 |
+
height: '10px',
|
| 864 |
+
transform: 'translate(25%, -25%)',
|
| 865 |
+
zIndex: 10,
|
| 866 |
+
borderColor: 'var(--muted)'
|
| 867 |
+
}}
|
| 868 |
+
/>
|
| 869 |
+
</TabsTrigger>
|
| 870 |
+
<TabsTrigger value="quiz" className="w-[140px] px-3 text-sm">Quiz</TabsTrigger>
|
| 871 |
+
</TabsList>
|
| 872 |
+
</Tabs>
|
| 873 |
+
</div>
|
| 874 |
+
|
| 875 |
+
{/* Action Buttons - Right */}
|
| 876 |
+
<div className="flex items-center gap-2 flex-shrink-0">
|
| 877 |
+
<Button
|
| 878 |
+
variant="ghost"
|
| 879 |
+
size="icon"
|
| 880 |
+
onClick={handleSaveClick}
|
| 881 |
+
disabled={!isLoggedIn}
|
| 882 |
+
className={`h-8 w-8 rounded-md hover:bg-muted/50 ${
|
| 883 |
+
isCurrentChatSaved() ? 'text-primary' : ''
|
| 884 |
+
}`}
|
| 885 |
+
title={isCurrentChatSaved() ? 'Unsave' : 'Save'}
|
| 886 |
+
>
|
| 887 |
+
<Bookmark
|
| 888 |
+
className={`h-4 w-4 ${isCurrentChatSaved() ? 'fill-primary text-primary' : ''}`}
|
| 889 |
+
/>
|
| 890 |
+
</Button>
|
| 891 |
+
<Button
|
| 892 |
+
variant="ghost"
|
| 893 |
+
size="icon"
|
| 894 |
+
onClick={handleOpenDownloadDialog}
|
| 895 |
+
disabled={!isLoggedIn}
|
| 896 |
+
className="h-8 w-8 rounded-md hover:bg-muted/50"
|
| 897 |
+
title="Download"
|
| 898 |
+
>
|
| 899 |
+
<Download className="h-4 w-4" />
|
| 900 |
+
</Button>
|
| 901 |
+
<Button
|
| 902 |
+
variant="ghost"
|
| 903 |
+
size="icon"
|
| 904 |
+
onClick={handleShareClick}
|
| 905 |
+
disabled={!isLoggedIn}
|
| 906 |
+
className="h-8 w-8 rounded-md hover:bg-muted/50"
|
| 907 |
+
title="Share"
|
| 908 |
+
>
|
| 909 |
+
<Share2 className="h-4 w-4" />
|
| 910 |
+
</Button>
|
| 911 |
+
<Button
|
| 912 |
+
variant="outline"
|
| 913 |
+
onClick={handleClearClick}
|
| 914 |
+
disabled={!isLoggedIn}
|
| 915 |
+
className="h-8 px-3 gap-2 rounded-md border border-border disabled:opacity-60 !bg-[var(--card)] !text-[var(--card-foreground)] hover:!opacity-90 [&_svg]:!text-[var(--card-foreground)] [&_span]:!text-[var(--card-foreground)]"
|
| 916 |
+
title="New Chat"
|
| 917 |
+
>
|
| 918 |
+
<Plus className="h-4 w-4" />
|
| 919 |
+
<span className="text-sm font-medium">New chat</span>
|
| 920 |
+
</Button>
|
| 921 |
+
</div>
|
| 922 |
+
</div>
|
| 923 |
+
|
| 924 |
+
{/* Messages Content */}
|
| 925 |
+
<div
|
| 926 |
+
className="py-6"
|
| 927 |
+
style={{
|
| 928 |
+
paddingBottom: '12rem' // Ensure enough space for input box and action buttons
|
| 929 |
+
}}
|
| 930 |
+
>
|
| 931 |
+
<div className="w-full space-y-6 max-w-4xl mx-auto">
|
| 932 |
+
{messages.map((message, index) => (
|
| 933 |
+
<React.Fragment key={message.id}>
|
| 934 |
+
<Message
|
| 935 |
+
message={message}
|
| 936 |
+
showSenderInfo={spaceType === 'group'}
|
| 937 |
+
isFirstGreeting={
|
| 938 |
+
(message.id === '1' || message.id === 'review-1' || message.id === 'quiz-1') &&
|
| 939 |
+
message.role === 'assistant'
|
| 940 |
+
}
|
| 941 |
+
showNextButton={message.showNextButton && !isAppTyping}
|
| 942 |
+
onNextQuestion={onNextQuestion}
|
| 943 |
+
chatMode={chatMode}
|
| 944 |
+
/>
|
| 945 |
+
{/* Smart Review - Show below welcome message in Review mode */}
|
| 946 |
+
{chatMode === 'review' &&
|
| 947 |
+
message.id === 'review-1' &&
|
| 948 |
+
message.role === 'assistant' && (
|
| 949 |
+
<div className="flex gap-2 justify-start px-4">
|
| 950 |
+
{/* Avatar placeholder to align with message bubble */}
|
| 951 |
+
<div className="w-10 h-10 flex-shrink-0"></div>
|
| 952 |
+
<div
|
| 953 |
+
className="w-full"
|
| 954 |
+
style={{ maxWidth: 'min(770px, calc(100% - 2rem))' }}
|
| 955 |
+
>
|
| 956 |
+
<SmartReview
|
| 957 |
+
onReviewTopic={handleReviewTopic}
|
| 958 |
+
onReviewAll={handleReviewAll}
|
| 959 |
+
/>
|
| 960 |
+
</div>
|
| 961 |
+
</div>
|
| 962 |
+
)}
|
| 963 |
+
{/* Quiz Mode Start Button - Below welcome message */}
|
| 964 |
+
{chatMode === 'quiz' &&
|
| 965 |
+
message.id === 'quiz-1' &&
|
| 966 |
+
message.role === 'assistant' &&
|
| 967 |
+
quizState.currentQuestion === 0 &&
|
| 968 |
+
!quizState.waitingForAnswer &&
|
| 969 |
+
!isAppTyping && (
|
| 970 |
+
<div className="flex justify-center py-4">
|
| 971 |
+
<Button
|
| 972 |
+
onClick={onStartQuiz}
|
| 973 |
+
className="bg-red-500 hover:bg-red-600 text-white"
|
| 974 |
+
>
|
| 975 |
+
Start Quiz
|
| 976 |
+
</Button>
|
| 977 |
+
</div>
|
| 978 |
+
)}
|
| 979 |
+
</React.Fragment>
|
| 980 |
+
))}
|
| 981 |
+
|
| 982 |
+
{/* Show typing indicator - use isAppTyping for all modes to ensure consistent display */}
|
| 983 |
+
{isAppTyping && (
|
| 984 |
+
<div className="flex gap-2 justify-start px-4">
|
| 985 |
+
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
|
| 986 |
+
<img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
|
| 987 |
+
</div>
|
| 988 |
+
<div className="bg-muted rounded-2xl px-4 py-3">
|
| 989 |
+
<div className="flex gap-1">
|
| 990 |
+
<div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '0ms' }} />
|
| 991 |
+
<div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '150ms' }} />
|
| 992 |
+
<div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '300ms' }} />
|
| 993 |
+
</div>
|
| 994 |
+
</div>
|
| 995 |
+
</div>
|
| 996 |
+
)}
|
| 997 |
+
|
| 998 |
+
<div ref={messagesEndRef} />
|
| 999 |
+
</div>
|
| 1000 |
+
</div>
|
| 1001 |
+
</div>
|
| 1002 |
+
|
| 1003 |
+
{/* Scroll to Bottom Button - Above input box */}
|
| 1004 |
+
{showScrollButton && (
|
| 1005 |
+
<div
|
| 1006 |
+
className="fixed z-30 flex justify-center pointer-events-none"
|
| 1007 |
+
style={{
|
| 1008 |
+
bottom: '120px', // Position 20px above input box (input box height ~100px)
|
| 1009 |
+
left: leftPanelVisible ? '320px' : '0px',
|
| 1010 |
+
right: '0px'
|
| 1011 |
+
}}
|
| 1012 |
+
>
|
| 1013 |
+
<Button
|
| 1014 |
+
variant="secondary"
|
| 1015 |
+
size="icon"
|
| 1016 |
+
className="rounded-full shadow-lg hover:shadow-xl transition-shadow bg-background border border-border pointer-events-auto w-10 h-10"
|
| 1017 |
+
onClick={scrollToBottom}
|
| 1018 |
+
title="Scroll to bottom"
|
| 1019 |
+
>
|
| 1020 |
+
<ArrowDown className="h-5 w-5" />
|
| 1021 |
+
</Button>
|
| 1022 |
+
</div>
|
| 1023 |
+
)}
|
| 1024 |
+
|
| 1025 |
+
{/* Floating Input Area - Fixed at viewport bottom, horizontally centered in chat area */}
|
| 1026 |
+
<div
|
| 1027 |
+
className="fixed bottom-0 bg-background/95 backdrop-blur-sm z-10"
|
| 1028 |
+
style={{
|
| 1029 |
+
left: leftPanelVisible ? '320px' : '0px',
|
| 1030 |
+
right: '0px'
|
| 1031 |
+
}}
|
| 1032 |
+
>
|
| 1033 |
+
<div className="max-w-4xl mx-auto px-4 py-4">
|
| 1034 |
+
{/* Uploaded Files Display */}
|
| 1035 |
+
{uploadedFiles.length > 0 && (
|
| 1036 |
+
<div className="mb-2 flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
| 1037 |
+
{uploadedFiles.map((uploadedFile, index) => {
|
| 1038 |
+
const Icon = getFileIcon(uploadedFile.file.name);
|
| 1039 |
+
const fileInfo = getFileTypeInfo(uploadedFile.file.name);
|
| 1040 |
+
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].some(ext =>
|
| 1041 |
+
uploadedFile.file.name.toLowerCase().endsWith(`.${ext}`)
|
| 1042 |
+
);
|
| 1043 |
+
|
| 1044 |
+
return (
|
| 1045 |
+
<div key={index}>
|
| 1046 |
+
<FileThumbnail
|
| 1047 |
+
file={uploadedFile.file}
|
| 1048 |
+
Icon={Icon}
|
| 1049 |
+
fileInfo={fileInfo}
|
| 1050 |
+
isImage={isImage}
|
| 1051 |
+
onPreview={() => {
|
| 1052 |
+
setSelectedFile({ file: uploadedFile.file, index });
|
| 1053 |
+
setShowFileViewer(true);
|
| 1054 |
+
}}
|
| 1055 |
+
onRemove={(e) => {
|
| 1056 |
+
e.stopPropagation();
|
| 1057 |
+
setFileToDelete(index);
|
| 1058 |
+
setShowDeleteDialog(true);
|
| 1059 |
+
}}
|
| 1060 |
+
/>
|
| 1061 |
+
</div>
|
| 1062 |
+
);
|
| 1063 |
+
})}
|
| 1064 |
+
</div>
|
| 1065 |
+
)}
|
| 1066 |
+
|
| 1067 |
+
<form
|
| 1068 |
+
onSubmit={handleSubmit}
|
| 1069 |
+
onDragOver={handleDragOver}
|
| 1070 |
+
onDragLeave={handleDragLeave}
|
| 1071 |
+
onDrop={handleDrop}
|
| 1072 |
+
className={isDragging ? 'opacity-75' : ''}
|
| 1073 |
+
>
|
| 1074 |
+
<div className="relative">
|
| 1075 |
+
{/* Mode Selector and Upload Button - ChatGPT style at bottom left */}
|
| 1076 |
+
<div className="absolute bottom-3 left-2 flex items-center gap-1 z-10">
|
| 1077 |
+
{/* Only show mode selector in ask mode */}
|
| 1078 |
+
{chatMode === 'ask' && (
|
| 1079 |
+
<DropdownMenu>
|
| 1080 |
+
<DropdownMenuTrigger asChild>
|
| 1081 |
+
<Button
|
| 1082 |
+
variant="ghost"
|
| 1083 |
+
size="sm"
|
| 1084 |
+
className="gap-1.5 h-8 px-2 text-xs hover:bg-muted/50"
|
| 1085 |
+
disabled={!isLoggedIn}
|
| 1086 |
+
type="button"
|
| 1087 |
+
>
|
| 1088 |
+
<span>{modeLabels[learningMode]}</span>
|
| 1089 |
+
<svg
|
| 1090 |
+
className="h-3 w-3 opacity-50"
|
| 1091 |
+
fill="none"
|
| 1092 |
+
stroke="currentColor"
|
| 1093 |
+
viewBox="0 0 24 24"
|
| 1094 |
+
>
|
| 1095 |
+
<path
|
| 1096 |
+
strokeLinecap="round"
|
| 1097 |
+
strokeLinejoin="round"
|
| 1098 |
+
strokeWidth={2}
|
| 1099 |
+
d="M19 9l-7 7-7-7"
|
| 1100 |
+
/>
|
| 1101 |
+
</svg>
|
| 1102 |
+
</Button>
|
| 1103 |
+
</DropdownMenuTrigger>
|
| 1104 |
+
<DropdownMenuContent align="start" className="w-56">
|
| 1105 |
+
<DropdownMenuItem
|
| 1106 |
+
onClick={() => onLearningModeChange('general')}
|
| 1107 |
+
className={learningMode === 'general' ? 'bg-accent' : ''}
|
| 1108 |
+
>
|
| 1109 |
+
<div className="flex flex-col">
|
| 1110 |
+
<span className="font-medium">General</span>
|
| 1111 |
+
<span className="text-xs text-muted-foreground">
|
| 1112 |
+
Answer various questions (context required)
|
| 1113 |
+
</span>
|
| 1114 |
+
</div>
|
| 1115 |
+
</DropdownMenuItem>
|
| 1116 |
+
<DropdownMenuItem
|
| 1117 |
+
onClick={() => onLearningModeChange('concept')}
|
| 1118 |
+
className={learningMode === 'concept' ? 'bg-accent' : ''}
|
| 1119 |
+
>
|
| 1120 |
+
<div className="flex flex-col">
|
| 1121 |
+
<span className="font-medium">Concept Explainer</span>
|
| 1122 |
+
<span className="text-xs text-muted-foreground">
|
| 1123 |
+
Get detailed explanations of concepts
|
| 1124 |
+
</span>
|
| 1125 |
+
</div>
|
| 1126 |
+
</DropdownMenuItem>
|
| 1127 |
+
<DropdownMenuItem
|
| 1128 |
+
onClick={() => onLearningModeChange('socratic')}
|
| 1129 |
+
className={learningMode === 'socratic' ? 'bg-accent' : ''}
|
| 1130 |
+
>
|
| 1131 |
+
<div className="flex flex-col">
|
| 1132 |
+
<span className="font-medium">Socratic Tutor</span>
|
| 1133 |
+
<span className="text-xs text-muted-foreground">
|
| 1134 |
+
Learn through guided questions
|
| 1135 |
+
</span>
|
| 1136 |
+
</div>
|
| 1137 |
+
</DropdownMenuItem>
|
| 1138 |
+
<DropdownMenuItem
|
| 1139 |
+
onClick={() => onLearningModeChange('exam')}
|
| 1140 |
+
className={learningMode === 'exam' ? 'bg-accent' : ''}
|
| 1141 |
+
>
|
| 1142 |
+
<div className="flex flex-col">
|
| 1143 |
+
<span className="font-medium">Exam Prep</span>
|
| 1144 |
+
<span className="text-xs text-muted-foreground">
|
| 1145 |
+
Practice with quiz questions
|
| 1146 |
+
</span>
|
| 1147 |
+
</div>
|
| 1148 |
+
</DropdownMenuItem>
|
| 1149 |
+
<DropdownMenuItem
|
| 1150 |
+
onClick={() => onLearningModeChange('assignment')}
|
| 1151 |
+
className={learningMode === 'assignment' ? 'bg-accent' : ''}
|
| 1152 |
+
>
|
| 1153 |
+
<div className="flex flex-col">
|
| 1154 |
+
<span className="font-medium">Assignment Helper</span>
|
| 1155 |
+
<span className="text-xs text-muted-foreground">
|
| 1156 |
+
Get help with assignments
|
| 1157 |
+
</span>
|
| 1158 |
+
</div>
|
| 1159 |
+
</DropdownMenuItem>
|
| 1160 |
+
<DropdownMenuItem
|
| 1161 |
+
onClick={() => onLearningModeChange('summary')}
|
| 1162 |
+
className={learningMode === 'summary' ? 'bg-accent' : ''}
|
| 1163 |
+
>
|
| 1164 |
+
<div className="flex flex-col">
|
| 1165 |
+
<span className="font-medium">Quick Summary</span>
|
| 1166 |
+
<span className="text-xs text-muted-foreground">
|
| 1167 |
+
Get concise summaries
|
| 1168 |
+
</span>
|
| 1169 |
+
</div>
|
| 1170 |
+
</DropdownMenuItem>
|
| 1171 |
+
</DropdownMenuContent>
|
| 1172 |
+
</DropdownMenu>
|
| 1173 |
+
)}
|
| 1174 |
+
{/* Upload Button - Right of mode selector */}
|
| 1175 |
+
<Button
|
| 1176 |
+
type="button"
|
| 1177 |
+
size="icon"
|
| 1178 |
+
variant="ghost"
|
| 1179 |
+
disabled={!isLoggedIn || (chatMode === 'quiz' && !quizState.waitingForAnswer)}
|
| 1180 |
+
className="h-8 w-8 hover:bg-muted/50"
|
| 1181 |
+
onClick={() => fileInputRef.current?.click()}
|
| 1182 |
+
title="Upload files"
|
| 1183 |
+
>
|
| 1184 |
+
<Upload className="h-4 w-4" />
|
| 1185 |
+
</Button>
|
| 1186 |
+
</div>
|
| 1187 |
+
<Textarea
|
| 1188 |
+
value={input}
|
| 1189 |
+
onChange={(e) => setInput(e.target.value)}
|
| 1190 |
+
onKeyDown={handleKeyDown}
|
| 1191 |
+
placeholder={
|
| 1192 |
+
!isLoggedIn
|
| 1193 |
+
? "Please log in on the right to start chatting..."
|
| 1194 |
+
: chatMode === 'quiz'
|
| 1195 |
+
? quizState.waitingForAnswer
|
| 1196 |
+
? "Type your answer here..."
|
| 1197 |
+
: quizState.currentQuestion > 0
|
| 1198 |
+
? "Click 'Next Question' to continue..."
|
| 1199 |
+
: "Click 'Start Quiz' to begin..."
|
| 1200 |
+
: spaceType === 'group'
|
| 1201 |
+
? "Type a message or drag files here... (mention @Clare to get AI assistance)"
|
| 1202 |
+
: learningMode === 'general'
|
| 1203 |
+
? "Ask me anything! Please provide context about your question..."
|
| 1204 |
+
: "Ask Clare anything about the course or drag files here..."
|
| 1205 |
+
}
|
| 1206 |
+
disabled={!isLoggedIn || (chatMode === 'quiz' && !quizState.waitingForAnswer)}
|
| 1207 |
+
className={`min-h-[80px] pl-4 pr-20 resize-none bg-background border-2 ${
|
| 1208 |
+
isDragging ? 'border-primary border-dashed' : 'border-border'
|
| 1209 |
+
}`}
|
| 1210 |
+
/>
|
| 1211 |
+
<div className="absolute bottom-2 right-2 flex gap-1">
|
| 1212 |
+
<Button
|
| 1213 |
+
type="submit"
|
| 1214 |
+
size="icon"
|
| 1215 |
+
disabled={!input.trim() || !isLoggedIn}
|
| 1216 |
+
className="h-8 w-8 rounded-full"
|
| 1217 |
+
>
|
| 1218 |
+
<Send className="h-4 w-4" />
|
| 1219 |
+
</Button>
|
| 1220 |
+
</div>
|
| 1221 |
+
<input
|
| 1222 |
+
ref={fileInputRef}
|
| 1223 |
+
type="file"
|
| 1224 |
+
multiple
|
| 1225 |
+
accept=".pdf,.docx,.pptx,.doc,.ppt,.jpg,.jpeg,.png,.gif,.webp"
|
| 1226 |
+
onChange={handleFileSelect}
|
| 1227 |
+
className="hidden"
|
| 1228 |
+
disabled={!isLoggedIn}
|
| 1229 |
+
/>
|
| 1230 |
+
</div>
|
| 1231 |
+
</form>
|
| 1232 |
+
</div>
|
| 1233 |
+
</div>
|
| 1234 |
+
</div>
|
| 1235 |
+
|
| 1236 |
+
{/* Start New Conversation Confirmation Dialog */}
|
| 1237 |
+
<AlertDialog open={showClearDialog} onOpenChange={onCancelClear}>
|
| 1238 |
+
<AlertDialogContent>
|
| 1239 |
+
<AlertDialogHeader>
|
| 1240 |
+
<AlertDialogTitle>Start New Conversation</AlertDialogTitle>
|
| 1241 |
+
<AlertDialogDescription>
|
| 1242 |
+
Would you like to save the current chat before starting a new conversation?
|
| 1243 |
+
</AlertDialogDescription>
|
| 1244 |
+
{/* Close button in top-right corner */}
|
| 1245 |
+
<Button
|
| 1246 |
+
variant="ghost"
|
| 1247 |
+
size="icon"
|
| 1248 |
+
className="absolute right-4 top-4 h-6 w-6 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
| 1249 |
+
onClick={onCancelClear}
|
| 1250 |
+
>
|
| 1251 |
+
<X className="h-4 w-4" />
|
| 1252 |
+
<span className="sr-only">Close</span>
|
| 1253 |
+
</Button>
|
| 1254 |
+
</AlertDialogHeader>
|
| 1255 |
+
<AlertDialogFooter className="flex-col sm:flex-row gap-2 sm:justify-end">
|
| 1256 |
+
<Button
|
| 1257 |
+
variant="outline"
|
| 1258 |
+
onClick={() => onConfirmClear(false)}
|
| 1259 |
+
className="sm:flex-1 sm:max-w-[200px]"
|
| 1260 |
+
>
|
| 1261 |
+
Start New (Don't Save)
|
| 1262 |
+
</Button>
|
| 1263 |
+
<AlertDialogAction onClick={() => onConfirmClear(true)} className="sm:flex-1 sm:max-w-[200px]">
|
| 1264 |
+
Save & Start New
|
| 1265 |
+
</AlertDialogAction>
|
| 1266 |
+
</AlertDialogFooter>
|
| 1267 |
+
</AlertDialogContent>
|
| 1268 |
+
</AlertDialog>
|
| 1269 |
+
|
| 1270 |
+
{/* Download Preview Dialog */}
|
| 1271 |
+
<Dialog open={showDownloadDialog} onOpenChange={setShowDownloadDialog}>
|
| 1272 |
+
<DialogContent className="max-w-3xl">
|
| 1273 |
+
<DialogHeader>
|
| 1274 |
+
<DialogTitle>Download this chat</DialogTitle>
|
| 1275 |
+
<DialogDescription>Preview and copy before downloading.</DialogDescription>
|
| 1276 |
+
</DialogHeader>
|
| 1277 |
+
|
| 1278 |
+
{/* Tab Selection */}
|
| 1279 |
+
<Tabs value={downloadTab} onValueChange={(value) => {
|
| 1280 |
+
setDownloadTab(value as 'chat' | 'summary');
|
| 1281 |
+
setDownloadPreview(value === 'chat' ? buildPreviewContent() : buildSummaryContent());
|
| 1282 |
+
// Auto-check options based on selected tab
|
| 1283 |
+
if (value === 'summary') {
|
| 1284 |
+
setDownloadOptions({ chat: false, summary: true });
|
| 1285 |
+
} else {
|
| 1286 |
+
setDownloadOptions({ chat: true, summary: false });
|
| 1287 |
+
}
|
| 1288 |
+
}} className="w-full">
|
| 1289 |
+
<TabsList className="grid w-full grid-cols-2">
|
| 1290 |
+
<TabsTrigger value="chat">Download chat</TabsTrigger>
|
| 1291 |
+
<TabsTrigger value="summary">Summary of the chat</TabsTrigger>
|
| 1292 |
+
</TabsList>
|
| 1293 |
+
</Tabs>
|
| 1294 |
+
|
| 1295 |
+
{/* Preview Section */}
|
| 1296 |
+
<div className="border rounded-lg bg-muted/40 flex flex-col max-h-64">
|
| 1297 |
+
<div className="flex items-center justify-between p-4 sticky top-0 bg-muted/40 border-b z-10">
|
| 1298 |
+
<span className="text-sm font-medium">Preview</span>
|
| 1299 |
+
<Button
|
| 1300 |
+
variant="outline"
|
| 1301 |
+
size="sm"
|
| 1302 |
+
className="h-7 px-2 text-xs gap-1.5"
|
| 1303 |
+
onClick={handleCopyPreview}
|
| 1304 |
+
title="Copy preview"
|
| 1305 |
+
>
|
| 1306 |
+
<Copy className="h-3 w-3" />
|
| 1307 |
+
Copy
|
| 1308 |
+
</Button>
|
| 1309 |
+
</div>
|
| 1310 |
+
<div className="text-sm text-foreground overflow-y-auto flex-1 p-4">
|
| 1311 |
+
<div className="whitespace-pre-wrap">{downloadPreview}</div>
|
| 1312 |
+
</div>
|
| 1313 |
+
</div>
|
| 1314 |
+
|
| 1315 |
+
{/* Download Options Checkboxes */}
|
| 1316 |
+
<div className="space-y-3">
|
| 1317 |
+
<div className="flex items-center space-x-2">
|
| 1318 |
+
<Checkbox
|
| 1319 |
+
id="download-chat"
|
| 1320 |
+
checked={downloadOptions.chat}
|
| 1321 |
+
onCheckedChange={(checked) => {
|
| 1322 |
+
setDownloadOptions({ ...downloadOptions, chat: checked === true });
|
| 1323 |
+
}}
|
| 1324 |
+
/>
|
| 1325 |
+
<label htmlFor="download-chat" className="text-sm font-medium cursor-pointer">
|
| 1326 |
+
Download chat
|
| 1327 |
+
</label>
|
| 1328 |
+
</div>
|
| 1329 |
+
<div className="flex items-center space-x-2">
|
| 1330 |
+
<Checkbox
|
| 1331 |
+
id="download-summary"
|
| 1332 |
+
checked={downloadOptions.summary}
|
| 1333 |
+
onCheckedChange={(checked) => {
|
| 1334 |
+
setDownloadOptions({ ...downloadOptions, summary: checked === true });
|
| 1335 |
+
}}
|
| 1336 |
+
/>
|
| 1337 |
+
<label htmlFor="download-summary" className="text-sm font-medium cursor-pointer">
|
| 1338 |
+
Download summary
|
| 1339 |
+
</label>
|
| 1340 |
+
</div>
|
| 1341 |
+
</div>
|
| 1342 |
+
|
| 1343 |
+
<DialogFooter>
|
| 1344 |
+
<Button variant="outline" onClick={() => setShowDownloadDialog(false)}>Cancel</Button>
|
| 1345 |
+
<Button onClick={handleDownloadFile}>Download</Button>
|
| 1346 |
+
</DialogFooter>
|
| 1347 |
+
</DialogContent>
|
| 1348 |
+
</Dialog>
|
| 1349 |
+
|
| 1350 |
+
{/* Share Dialog */}
|
| 1351 |
+
<Dialog open={showShareDialog} onOpenChange={setShowShareDialog}>
|
| 1352 |
+
<DialogContent className="w-[600px] max-w-[600px] sm:max-w-[600px]">
|
| 1353 |
+
<DialogHeader>
|
| 1354 |
+
<DialogTitle>Share Conversation</DialogTitle>
|
| 1355 |
+
<DialogDescription>Select how you want to share.</DialogDescription>
|
| 1356 |
+
</DialogHeader>
|
| 1357 |
+
<div className="space-y-4">
|
| 1358 |
+
<div className="space-y-2">
|
| 1359 |
+
<Label>Copy Link</Label>
|
| 1360 |
+
<div className="flex gap-2 items-center">
|
| 1361 |
+
<Input value={shareLink} readOnly className="flex-1" />
|
| 1362 |
+
<Button variant="secondary" onClick={handleCopyShareLink}>Copy</Button>
|
| 1363 |
+
</div>
|
| 1364 |
+
<p className="text-xs text-muted-foreground">Temporary link valid for this session.</p>
|
| 1365 |
+
</div>
|
| 1366 |
+
<div className="space-y-2">
|
| 1367 |
+
<Label>Send to Workspace</Label>
|
| 1368 |
+
<Select value={targetWorkspaceId} onValueChange={setTargetWorkspaceId}>
|
| 1369 |
+
<SelectTrigger className="w-full">
|
| 1370 |
+
<SelectValue placeholder="Choose a workspace" />
|
| 1371 |
+
</SelectTrigger>
|
| 1372 |
+
<SelectContent>
|
| 1373 |
+
{workspaces.map(w => (
|
| 1374 |
+
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
|
| 1375 |
+
))}
|
| 1376 |
+
</SelectContent>
|
| 1377 |
+
</Select>
|
| 1378 |
+
<p className="text-xs text-muted-foreground">Sends this conversation to the selected workspace's Saved Files.</p>
|
| 1379 |
+
<Button onClick={handleShareSendToWorkspace} className="w-full">Send</Button>
|
| 1380 |
+
</div>
|
| 1381 |
+
</div>
|
| 1382 |
+
</DialogContent>
|
| 1383 |
+
</Dialog>
|
| 1384 |
+
|
| 1385 |
+
{/* Delete File Confirmation Dialog */}
|
| 1386 |
+
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
| 1387 |
+
<AlertDialogContent>
|
| 1388 |
+
<AlertDialogHeader>
|
| 1389 |
+
<AlertDialogTitle>Delete File</AlertDialogTitle>
|
| 1390 |
+
<AlertDialogDescription>
|
| 1391 |
+
Are you sure you want to delete "{fileToDelete !== null ? uploadedFiles[fileToDelete]?.file.name : ''}"? This action cannot be undone.
|
| 1392 |
+
</AlertDialogDescription>
|
| 1393 |
+
</AlertDialogHeader>
|
| 1394 |
+
<AlertDialogFooter>
|
| 1395 |
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
| 1396 |
+
<AlertDialogAction
|
| 1397 |
+
onClick={() => {
|
| 1398 |
+
if (fileToDelete !== null) {
|
| 1399 |
+
onRemoveFile(fileToDelete);
|
| 1400 |
+
setFileToDelete(null);
|
| 1401 |
+
}
|
| 1402 |
+
setShowDeleteDialog(false);
|
| 1403 |
+
}}
|
| 1404 |
+
>
|
| 1405 |
+
Delete
|
| 1406 |
+
</AlertDialogAction>
|
| 1407 |
+
</AlertDialogFooter>
|
| 1408 |
+
</AlertDialogContent>
|
| 1409 |
+
</AlertDialog>
|
| 1410 |
+
|
| 1411 |
+
{/* File Viewer Dialog */}
|
| 1412 |
+
<Dialog open={showFileViewer} onOpenChange={setShowFileViewer}>
|
| 1413 |
+
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
|
| 1414 |
+
<DialogHeader className="min-w-0 flex-shrink-0">
|
| 1415 |
+
<DialogTitle
|
| 1416 |
+
className="pr-8 break-words break-all overflow-wrap-anywhere leading-relaxed"
|
| 1417 |
+
style={{
|
| 1418 |
+
wordBreak: 'break-all',
|
| 1419 |
+
overflowWrap: 'anywhere',
|
| 1420 |
+
maxWidth: '100%',
|
| 1421 |
+
lineHeight: '1.6'
|
| 1422 |
+
}}
|
| 1423 |
+
>
|
| 1424 |
+
{selectedFile?.file.name}
|
| 1425 |
+
</DialogTitle>
|
| 1426 |
+
<DialogDescription>
|
| 1427 |
+
File size: {selectedFile ? formatFileSize(selectedFile.file.size) : ''}
|
| 1428 |
+
</DialogDescription>
|
| 1429 |
+
</DialogHeader>
|
| 1430 |
+
<div className="flex-1 min-h-0 overflow-y-auto mt-4">
|
| 1431 |
+
{selectedFile && (
|
| 1432 |
+
<FileViewerContent file={selectedFile.file} />
|
| 1433 |
+
)}
|
| 1434 |
+
</div>
|
| 1435 |
+
</DialogContent>
|
| 1436 |
+
</Dialog>
|
| 1437 |
+
|
| 1438 |
+
{/* File Type Selection Dialog - Highest z-index */}
|
| 1439 |
+
{showTypeDialog && (
|
| 1440 |
+
<Dialog open={showTypeDialog} onOpenChange={setShowTypeDialog}>
|
| 1441 |
+
<DialogContent
|
| 1442 |
+
className="sm:max-w-[425px]"
|
| 1443 |
+
overlayClassName="!z-[99998]"
|
| 1444 |
+
style={{ zIndex: 99999 }}
|
| 1445 |
+
>
|
| 1446 |
+
<DialogHeader>
|
| 1447 |
+
<DialogTitle>Select File Types</DialogTitle>
|
| 1448 |
+
<DialogDescription>
|
| 1449 |
+
Please select the type for each file you are uploading.
|
| 1450 |
+
</DialogDescription>
|
| 1451 |
+
</DialogHeader>
|
| 1452 |
+
<div className="space-y-3 max-h-64 overflow-y-auto">
|
| 1453 |
+
{pendingFiles.map((pendingFile, index) => {
|
| 1454 |
+
const Icon = getFileIcon(pendingFile.file.name);
|
| 1455 |
+
return (
|
| 1456 |
+
<div
|
| 1457 |
+
key={index}
|
| 1458 |
+
className="p-3 bg-muted rounded-md space-y-2"
|
| 1459 |
+
>
|
| 1460 |
+
<div className="flex items-center gap-2 group">
|
| 1461 |
+
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
| 1462 |
+
<div className="flex-1 min-w-0">
|
| 1463 |
+
<p className="text-sm truncate">{pendingFile.file.name}</p>
|
| 1464 |
+
<p className="text-xs text-muted-foreground">
|
| 1465 |
+
{formatFileSize(pendingFile.file.size)}
|
| 1466 |
+
</p>
|
| 1467 |
+
</div>
|
| 1468 |
+
</div>
|
| 1469 |
+
<div className="space-y-1">
|
| 1470 |
+
<label className="text-xs text-muted-foreground">File Type</label>
|
| 1471 |
+
<Select
|
| 1472 |
+
value={pendingFile.type}
|
| 1473 |
+
onValueChange={(value) => handlePendingFileTypeChange(index, value as FileType)}
|
| 1474 |
+
>
|
| 1475 |
+
<SelectTrigger className="h-8 text-xs">
|
| 1476 |
+
<SelectValue />
|
| 1477 |
+
</SelectTrigger>
|
| 1478 |
+
<SelectContent
|
| 1479 |
+
className="!z-[100000] !bg-background !text-foreground"
|
| 1480 |
+
style={{ zIndex: 100000 }}
|
| 1481 |
+
>
|
| 1482 |
+
<SelectItem value="syllabus">Syllabus</SelectItem>
|
| 1483 |
+
<SelectItem value="lecture-slides">Lecture Slides / PPT</SelectItem>
|
| 1484 |
+
<SelectItem value="literature-review">Literature Review / Paper</SelectItem>
|
| 1485 |
+
<SelectItem value="other">Other Course Document</SelectItem>
|
| 1486 |
+
</SelectContent>
|
| 1487 |
+
</Select>
|
| 1488 |
+
</div>
|
| 1489 |
+
</div>
|
| 1490 |
+
);
|
| 1491 |
+
})}
|
| 1492 |
+
</div>
|
| 1493 |
+
<DialogFooter>
|
| 1494 |
+
<Button
|
| 1495 |
+
variant="outline"
|
| 1496 |
+
onClick={handleCancelUpload}
|
| 1497 |
+
>
|
| 1498 |
+
Cancel
|
| 1499 |
+
</Button>
|
| 1500 |
+
<Button
|
| 1501 |
+
onClick={handleConfirmUpload}
|
| 1502 |
+
>
|
| 1503 |
+
Upload
|
| 1504 |
+
</Button>
|
| 1505 |
+
</DialogFooter>
|
| 1506 |
+
</DialogContent>
|
| 1507 |
+
</Dialog>
|
| 1508 |
+
)}
|
| 1509 |
+
</div>
|
| 1510 |
+
);
|
| 1511 |
+
}
|
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,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Button } from './ui/button';
|
| 3 |
+
import { Download, Sparkles } from 'lucide-react';
|
| 4 |
+
import { toast } from 'sonner';
|
| 5 |
+
import type { User } from '../App';
|
| 6 |
+
|
| 7 |
+
interface FloatingActionButtonsProps {
|
| 8 |
+
user: User | null;
|
| 9 |
+
isLoggedIn: boolean;
|
| 10 |
+
onOpenPanel: () => void;
|
| 11 |
+
onExport: () => void;
|
| 12 |
+
onSummary: () => void;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function FloatingActionButtons({
|
| 16 |
+
user,
|
| 17 |
+
isLoggedIn,
|
| 18 |
+
onOpenPanel,
|
| 19 |
+
onExport,
|
| 20 |
+
onSummary,
|
| 21 |
+
}: FloatingActionButtonsProps) {
|
| 22 |
+
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
|
| 23 |
+
|
| 24 |
+
const handleAction = (action: () => void, actionName: string, shouldOpenPanel: boolean = false) => {
|
| 25 |
+
if (!isLoggedIn) {
|
| 26 |
+
toast.error('Please log in to use this feature');
|
| 27 |
+
return;
|
| 28 |
+
}
|
| 29 |
+
action();
|
| 30 |
+
if (shouldOpenPanel) {
|
| 31 |
+
onOpenPanel();
|
| 32 |
+
}
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const buttons = [
|
| 36 |
+
{
|
| 37 |
+
id: 'export',
|
| 38 |
+
icon: Download,
|
| 39 |
+
label: 'Export Conversation',
|
| 40 |
+
action: onExport,
|
| 41 |
+
openPanel: true, // Open panel for export
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
id: 'summary',
|
| 45 |
+
icon: Sparkles,
|
| 46 |
+
label: 'Summarization',
|
| 47 |
+
action: onSummary,
|
| 48 |
+
openPanel: true, // Open panel for summary
|
| 49 |
+
},
|
| 50 |
+
];
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div className="fixed right-4 bottom-[28rem] z-40 flex flex-col gap-2">
|
| 54 |
+
{buttons.map((button, index) => {
|
| 55 |
+
const Icon = button.icon;
|
| 56 |
+
const isHovered = hoveredButton === button.id;
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div
|
| 60 |
+
key={button.id}
|
| 61 |
+
className="relative group"
|
| 62 |
+
onMouseEnter={() => setHoveredButton(button.id)}
|
| 63 |
+
onMouseLeave={() => setHoveredButton(null)}
|
| 64 |
+
>
|
| 65 |
+
{/* Tooltip */}
|
| 66 |
+
<div
|
| 67 |
+
className={`
|
| 68 |
+
absolute right-full mr-3 top-1/2 -translate-y-1/2
|
| 69 |
+
px-3 py-2 rounded-lg bg-popover border border-border
|
| 70 |
+
whitespace-nowrap text-sm shadow-lg
|
| 71 |
+
transition-all duration-200
|
| 72 |
+
${isHovered ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-2 pointer-events-none'}
|
| 73 |
+
`}
|
| 74 |
+
>
|
| 75 |
+
{button.label}
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
{/* Floating Button */}
|
| 79 |
+
<Button
|
| 80 |
+
size="icon"
|
| 81 |
+
className={`
|
| 82 |
+
h-6 w-6 rounded-full shadow-md opacity-60 hover:opacity-100
|
| 83 |
+
transition-all duration-200
|
| 84 |
+
${isLoggedIn
|
| 85 |
+
? 'bg-primary hover:bg-primary/90 text-primary-foreground'
|
| 86 |
+
: 'bg-muted hover:bg-muted/90 text-muted-foreground'
|
| 87 |
+
}
|
| 88 |
+
${isHovered ? 'scale-110' : 'scale-100'}
|
| 89 |
+
`}
|
| 90 |
+
onClick={() => handleAction(button.action, button.label, button.openPanel)}
|
| 91 |
+
style={{
|
| 92 |
+
animationDelay: `${index * 100}ms`,
|
| 93 |
+
}}
|
| 94 |
+
>
|
| 95 |
+
<Icon className="h-3 w-3" />
|
| 96 |
+
</Button>
|
| 97 |
+
</div>
|
| 98 |
+
);
|
| 99 |
+
})}
|
| 100 |
+
</div>
|
| 101 |
+
);
|
| 102 |
+
}
|
web/src/components/GroupMembers.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Users, MailPlus } from 'lucide-react';
|
| 3 |
+
import { Badge } from './ui/badge';
|
| 4 |
+
import { Button } from './ui/button';
|
| 5 |
+
import { Input } from './ui/input';
|
| 6 |
+
import { toast } from 'sonner';
|
| 7 |
+
import {
|
| 8 |
+
Dialog,
|
| 9 |
+
DialogContent,
|
| 10 |
+
DialogDescription,
|
| 11 |
+
DialogFooter,
|
| 12 |
+
DialogHeader,
|
| 13 |
+
DialogTitle,
|
| 14 |
+
} from './ui/dialog';
|
| 15 |
+
import clareAvatar from '../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png';
|
| 16 |
+
import type { GroupMember } from '../App';
|
| 17 |
+
|
| 18 |
+
interface GroupMembersProps {
|
| 19 |
+
members: GroupMember[];
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function GroupMembers({ members }: GroupMembersProps) {
|
| 23 |
+
const [inviteOpen, setInviteOpen] = useState(false);
|
| 24 |
+
const [inviteEmail, setInviteEmail] = useState('');
|
| 25 |
+
|
| 26 |
+
const handleSendInvite = () => {
|
| 27 |
+
if (!inviteEmail.trim()) {
|
| 28 |
+
toast.error('Please enter an email to invite');
|
| 29 |
+
return;
|
| 30 |
+
}
|
| 31 |
+
toast.success(`Invitation sent to ${inviteEmail}`);
|
| 32 |
+
setInviteEmail('');
|
| 33 |
+
setInviteOpen(false);
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<div className="space-y-3">
|
| 38 |
+
<div className="flex items-center justify-between gap-2">
|
| 39 |
+
<div className="flex items-center gap-2">
|
| 40 |
+
<Users className="h-4 w-4 text-muted-foreground" />
|
| 41 |
+
<h3 className="text-sm">Group Members ({members.length})</h3>
|
| 42 |
+
</div>
|
| 43 |
+
<Button
|
| 44 |
+
size="sm"
|
| 45 |
+
variant="secondary"
|
| 46 |
+
className="h-8 gap-2"
|
| 47 |
+
onClick={() => setInviteOpen(true)}
|
| 48 |
+
>
|
| 49 |
+
<MailPlus className="h-4 w-4" />
|
| 50 |
+
<span className="text-xs font-medium">Invite</span>
|
| 51 |
+
</Button>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<div className="space-y-2">
|
| 55 |
+
{members.map((member) => {
|
| 56 |
+
const isAI = !!member.isAI;
|
| 57 |
+
return (
|
| 58 |
+
<div
|
| 59 |
+
key={member.id}
|
| 60 |
+
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 transition-colors"
|
| 61 |
+
>
|
| 62 |
+
{/* Avatar */}
|
| 63 |
+
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
| 64 |
+
isAI
|
| 65 |
+
? 'overflow-hidden bg-white'
|
| 66 |
+
: 'bg-muted'
|
| 67 |
+
}`}>
|
| 68 |
+
{isAI ? (
|
| 69 |
+
<img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
|
| 70 |
+
) : (
|
| 71 |
+
<span className="text-sm">
|
| 72 |
+
{member.name.split(' ').map(n => n[0]).join('').toUpperCase()}
|
| 73 |
+
</span>
|
| 74 |
+
)}
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
{/* Member Info */}
|
| 78 |
+
<div className="flex-1 min-w-0">
|
| 79 |
+
<div className="flex items-center gap-2">
|
| 80 |
+
<p className="text-sm truncate">{member.name}</p>
|
| 81 |
+
{isAI && (
|
| 82 |
+
<Badge variant="secondary" className="text-xs">AI</Badge>
|
| 83 |
+
)}
|
| 84 |
+
</div>
|
| 85 |
+
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
{/* Online Status */}
|
| 89 |
+
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" title="Online" />
|
| 90 |
+
</div>
|
| 91 |
+
)})}
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
|
| 95 |
+
<DialogContent className="w-[600px] max-w-[600px] sm:max-w-[600px]" style={{ maxWidth: 600 }}>
|
| 96 |
+
<DialogHeader>
|
| 97 |
+
<DialogTitle>Invite member</DialogTitle>
|
| 98 |
+
<DialogDescription>Send a quick email invite with the team details.</DialogDescription>
|
| 99 |
+
</DialogHeader>
|
| 100 |
+
<div className="space-y-3">
|
| 101 |
+
<Input
|
| 102 |
+
type="email"
|
| 103 |
+
placeholder="name@example.com"
|
| 104 |
+
value={inviteEmail}
|
| 105 |
+
onChange={(e) => setInviteEmail(e.target.value)}
|
| 106 |
+
/>
|
| 107 |
+
<p className="text-xs text-muted-foreground">
|
| 108 |
+
An invitation email with a join link will be sent to this address.
|
| 109 |
+
</p>
|
| 110 |
+
</div>
|
| 111 |
+
<DialogFooter>
|
| 112 |
+
<Button variant="outline" onClick={() => setInviteOpen(false)}>Cancel</Button>
|
| 113 |
+
<Button onClick={handleSendInvite}>Send invite</Button>
|
| 114 |
+
</DialogFooter>
|
| 115 |
+
</DialogContent>
|
| 116 |
+
</Dialog>
|
| 117 |
+
</div>
|
| 118 |
+
);
|
| 119 |
+
}
|
web/src/components/Header.tsx
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Button } from './ui/button';
|
| 3 |
+
import { Menu, Sun, Moon, Languages, ChevronDown, LogOut, Plus, X, User, Edit, Star } from 'lucide-react';
|
| 4 |
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
| 5 |
+
import {
|
| 6 |
+
DropdownMenu,
|
| 7 |
+
DropdownMenuContent,
|
| 8 |
+
DropdownMenuItem,
|
| 9 |
+
DropdownMenuTrigger,
|
| 10 |
+
DropdownMenuSeparator,
|
| 11 |
+
} from './ui/dropdown-menu';
|
| 12 |
+
import clareAvatar from '../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png';
|
| 13 |
+
import type { Workspace, CourseInfo } from '../App';
|
| 14 |
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
|
| 15 |
+
import { Input } from './ui/input';
|
| 16 |
+
import { Label } from './ui/label';
|
| 17 |
+
import { RadioGroup, RadioGroupItem } from './ui/radio-group';
|
| 18 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
| 19 |
+
import { toast } from 'sonner';
|
| 20 |
+
import { ProfileEditor } from './ProfileEditor';
|
| 21 |
+
|
| 22 |
+
interface HeaderProps {
|
| 23 |
+
user: UserType | null;
|
| 24 |
+
onMenuClick: () => void;
|
| 25 |
+
onUserClick: () => void;
|
| 26 |
+
isDarkMode: boolean;
|
| 27 |
+
onToggleDarkMode: () => void;
|
| 28 |
+
language: Language;
|
| 29 |
+
onLanguageChange: (lang: Language) => void;
|
| 30 |
+
workspaces: Workspace[];
|
| 31 |
+
currentWorkspace: Workspace | undefined;
|
| 32 |
+
onWorkspaceChange: (workspaceId: string) => void;
|
| 33 |
+
onCreateWorkspace?: (payload: {
|
| 34 |
+
name: string;
|
| 35 |
+
category: 'course' | 'personal';
|
| 36 |
+
courseId?: string;
|
| 37 |
+
invites: string[];
|
| 38 |
+
}) => void;
|
| 39 |
+
onLogout: () => void;
|
| 40 |
+
availableCourses?: CourseInfo[];
|
| 41 |
+
onUserUpdate?: (user: UserType) => void;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
type UserType = {
|
| 45 |
+
name: string;
|
| 46 |
+
email: string;
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
type Language = 'auto' | 'en' | 'zh';
|
| 50 |
+
|
| 51 |
+
export function Header({
|
| 52 |
+
user,
|
| 53 |
+
onMenuClick,
|
| 54 |
+
onUserClick,
|
| 55 |
+
isDarkMode,
|
| 56 |
+
onToggleDarkMode,
|
| 57 |
+
language,
|
| 58 |
+
onLanguageChange,
|
| 59 |
+
workspaces,
|
| 60 |
+
currentWorkspace,
|
| 61 |
+
onWorkspaceChange,
|
| 62 |
+
onLogout,
|
| 63 |
+
onCreateWorkspace,
|
| 64 |
+
availableCourses = [],
|
| 65 |
+
onUserUpdate,
|
| 66 |
+
}: HeaderProps) {
|
| 67 |
+
const [showProfileEditor, setShowProfileEditor] = useState(false);
|
| 68 |
+
// Star brightness levels: 0-10 (11 levels), 0 = 100%, 10 = 0%
|
| 69 |
+
const [starBrightness, setStarBrightness] = useState(0);
|
| 70 |
+
const languageLabels = {
|
| 71 |
+
auto: 'Auto',
|
| 72 |
+
en: 'English',
|
| 73 |
+
zh: '简体中文',
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
const [createOpen, setCreateOpen] = useState(false);
|
| 77 |
+
const [workspaceName, setWorkspaceName] = useState('');
|
| 78 |
+
const [category, setCategory] = useState<'course' | 'personal'>('course');
|
| 79 |
+
const [courseId, setCourseId] = useState('');
|
| 80 |
+
const [inviteEmail, setInviteEmail] = useState('');
|
| 81 |
+
const [invites, setInvites] = useState<string[]>([]);
|
| 82 |
+
|
| 83 |
+
const addInvite = () => {
|
| 84 |
+
const email = inviteEmail.trim();
|
| 85 |
+
if (!email) return;
|
| 86 |
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
| 87 |
+
toast.error('Please enter a valid email');
|
| 88 |
+
return;
|
| 89 |
+
}
|
| 90 |
+
if (invites.includes(email)) return;
|
| 91 |
+
setInvites(prev => [...prev, email]);
|
| 92 |
+
setInviteEmail('');
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
const removeInvite = (email: string) => {
|
| 96 |
+
setInvites(prev => prev.filter(e => e !== email));
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
const handleStarClick = (e: React.MouseEvent) => {
|
| 100 |
+
e.preventDefault();
|
| 101 |
+
e.stopPropagation();
|
| 102 |
+
// Cycle through brightness levels: 0 -> 1 -> ... -> 10 -> 0
|
| 103 |
+
setStarBrightness((prev) => (prev + 1) % 11);
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
const getStarOpacity = () => {
|
| 107 |
+
// 11 levels: 0 = 100% (1.0), 10 = 0% (0.0)
|
| 108 |
+
return 1 - (starBrightness / 10);
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
const getEnergyValue = () => {
|
| 112 |
+
// Energy value: 0 = 100%, 1 = 90%, ..., 10 = 0%
|
| 113 |
+
return 100 - (starBrightness * 10);
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
const getStarColor = () => {
|
| 117 |
+
// Color transitions from gold (100%) to gray/white (0%)
|
| 118 |
+
if (starBrightness === 10) {
|
| 119 |
+
return { fill: 'transparent', stroke: 'white', strokeWidth: 1.5, strokeDasharray: '2 2' };
|
| 120 |
+
}
|
| 121 |
+
// Interpolate from gold (#fbbf24) to lighter gold/gray
|
| 122 |
+
const energy = getEnergyValue();
|
| 123 |
+
if (energy >= 50) {
|
| 124 |
+
// High energy: bright gold
|
| 125 |
+
return { fill: '#fbbf24', stroke: '#fbbf24', strokeWidth: 0 };
|
| 126 |
+
} else if (energy >= 20) {
|
| 127 |
+
// Medium energy: lighter gold
|
| 128 |
+
return { fill: '#fcd34d', stroke: '#fcd34d', strokeWidth: 0 };
|
| 129 |
+
} else {
|
| 130 |
+
// Low energy: very light gold/gray
|
| 131 |
+
return { fill: '#fde68a', stroke: '#fde68a', strokeWidth: 0 };
|
| 132 |
+
}
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
const handleCreate = () => {
|
| 136 |
+
if (!workspaceName.trim()) {
|
| 137 |
+
toast.error('Please enter a workspace name');
|
| 138 |
+
return;
|
| 139 |
+
}
|
| 140 |
+
if (category === 'course' && !courseId) {
|
| 141 |
+
toast.error('Please select a course');
|
| 142 |
+
return;
|
| 143 |
+
}
|
| 144 |
+
if (invites.length === 0) {
|
| 145 |
+
toast.error('Please add at least one member');
|
| 146 |
+
return;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// Call the create function
|
| 150 |
+
onCreateWorkspace?.({
|
| 151 |
+
name: workspaceName.trim(),
|
| 152 |
+
category,
|
| 153 |
+
courseId: courseId || undefined,
|
| 154 |
+
invites,
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
// Reset form and close dialog
|
| 158 |
+
setWorkspaceName('');
|
| 159 |
+
setCourseId('');
|
| 160 |
+
setCategory('course');
|
| 161 |
+
setInvites([]);
|
| 162 |
+
setInviteEmail('');
|
| 163 |
+
setCreateOpen(false);
|
| 164 |
+
};
|
| 165 |
+
|
| 166 |
+
return (
|
| 167 |
+
<header className="h-16 border-b border-border bg-card px-4 lg:px-6 flex items-center justify-between sticky top-0 z-[100]">
|
| 168 |
+
<div className="flex items-center gap-4">
|
| 169 |
+
<Button
|
| 170 |
+
variant="ghost"
|
| 171 |
+
size="icon"
|
| 172 |
+
className="lg:hidden"
|
| 173 |
+
onClick={onMenuClick}
|
| 174 |
+
>
|
| 175 |
+
<Menu className="h-5 w-5" />
|
| 176 |
+
</Button>
|
| 177 |
+
|
| 178 |
+
<div className="flex items-center gap-3">
|
| 179 |
+
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center">
|
| 180 |
+
<img src={clareAvatar} alt="Clare AI" className="w-full h-full object-cover" />
|
| 181 |
+
</div>
|
| 182 |
+
<div>
|
| 183 |
+
<h1 className="text-lg sm:text-xl tracking-tight" style={{ fontFamily: 'Inter, sans-serif', fontWeight: 600, letterSpacing: '-0.02em' }}>
|
| 184 |
+
Clare <span className="text-sm font-bold text-muted-foreground hidden sm:inline ml-2">Your Personalized AI Tutor</span>
|
| 185 |
+
</h1>
|
| 186 |
+
<p className="text-xs text-muted-foreground hidden sm:block">
|
| 187 |
+
Personalized guidance, review, and intelligent reinforcement
|
| 188 |
+
</p>
|
| 189 |
+
</div>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
<div className="flex items-center gap-2">
|
| 194 |
+
<DropdownMenu>
|
| 195 |
+
<DropdownMenuTrigger asChild>
|
| 196 |
+
<Button
|
| 197 |
+
variant="ghost"
|
| 198 |
+
size="icon"
|
| 199 |
+
aria-label="Change language"
|
| 200 |
+
>
|
| 201 |
+
<Languages className="h-5 w-5" />
|
| 202 |
+
</Button>
|
| 203 |
+
</DropdownMenuTrigger>
|
| 204 |
+
<DropdownMenuContent align="end">
|
| 205 |
+
<DropdownMenuItem onClick={() => onLanguageChange('auto')}>
|
| 206 |
+
{language === 'auto' && '✓ '}Auto
|
| 207 |
+
</DropdownMenuItem>
|
| 208 |
+
<DropdownMenuItem onClick={() => onLanguageChange('en')}>
|
| 209 |
+
{language === 'en' && '✓ '}English
|
| 210 |
+
</DropdownMenuItem>
|
| 211 |
+
<DropdownMenuItem onClick={() => onLanguageChange('zh')}>
|
| 212 |
+
{language === 'zh' && '✓ '}简体中文
|
| 213 |
+
</DropdownMenuItem>
|
| 214 |
+
</DropdownMenuContent>
|
| 215 |
+
</DropdownMenu>
|
| 216 |
+
|
| 217 |
+
<Button
|
| 218 |
+
variant="ghost"
|
| 219 |
+
size="icon"
|
| 220 |
+
onClick={onToggleDarkMode}
|
| 221 |
+
aria-label="Toggle dark mode"
|
| 222 |
+
>
|
| 223 |
+
{isDarkMode ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
| 224 |
+
</Button>
|
| 225 |
+
|
| 226 |
+
{user && currentWorkspace ? (
|
| 227 |
+
<>
|
| 228 |
+
<DropdownMenu>
|
| 229 |
+
<DropdownMenuTrigger asChild>
|
| 230 |
+
<Button
|
| 231 |
+
variant="outline"
|
| 232 |
+
className="gap-2 pl-2 pr-3"
|
| 233 |
+
aria-label="Switch workspace"
|
| 234 |
+
>
|
| 235 |
+
<img
|
| 236 |
+
src={currentWorkspace.avatar}
|
| 237 |
+
alt={currentWorkspace.name}
|
| 238 |
+
className="w-6 h-6 rounded-full object-cover"
|
| 239 |
+
/>
|
| 240 |
+
<span className="hidden sm:inline max-w-[120px] truncate">{currentWorkspace.name}</span>
|
| 241 |
+
<ChevronDown className="h-4 w-4 opacity-50" />
|
| 242 |
+
</Button>
|
| 243 |
+
</DropdownMenuTrigger>
|
| 244 |
+
<DropdownMenuContent align="end" className="min-w-[14rem]">
|
| 245 |
+
{workspaces.map((workspace) => (
|
| 246 |
+
<DropdownMenuItem
|
| 247 |
+
key={workspace.id}
|
| 248 |
+
onClick={() => onWorkspaceChange(workspace.id)}
|
| 249 |
+
className={`gap-3 ${currentWorkspace.id === workspace.id ? 'bg-accent' : ''}`}
|
| 250 |
+
>
|
| 251 |
+
<img
|
| 252 |
+
src={workspace.avatar}
|
| 253 |
+
alt={workspace.name}
|
| 254 |
+
className="w-6 h-6 rounded-full object-cover flex-shrink-0"
|
| 255 |
+
/>
|
| 256 |
+
<span className="truncate">{workspace.name}</span>
|
| 257 |
+
{currentWorkspace.id === workspace.id && (
|
| 258 |
+
<span className="ml-auto text-primary">✓</span>
|
| 259 |
+
)}
|
| 260 |
+
</DropdownMenuItem>
|
| 261 |
+
))}
|
| 262 |
+
<DropdownMenuSeparator />
|
| 263 |
+
<DropdownMenuItem className="gap-2" onClick={() => setCreateOpen(true)}>
|
| 264 |
+
<Plus className="h-4 w-4" />
|
| 265 |
+
<span>New Group Workspace</span>
|
| 266 |
+
</DropdownMenuItem>
|
| 267 |
+
</DropdownMenuContent>
|
| 268 |
+
</DropdownMenu>
|
| 269 |
+
|
| 270 |
+
{/* Profile Avatar Button */}
|
| 271 |
+
<div className="relative inline-block">
|
| 272 |
+
<DropdownMenu>
|
| 273 |
+
<DropdownMenuTrigger asChild>
|
| 274 |
+
<Button
|
| 275 |
+
variant="ghost"
|
| 276 |
+
size="icon"
|
| 277 |
+
className="rounded-full"
|
| 278 |
+
aria-label="User profile"
|
| 279 |
+
>
|
| 280 |
+
<img
|
| 281 |
+
src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`}
|
| 282 |
+
alt={user.name}
|
| 283 |
+
className="w-8 h-8 rounded-full object-cover"
|
| 284 |
+
/>
|
| 285 |
+
</Button>
|
| 286 |
+
</DropdownMenuTrigger>
|
| 287 |
+
<DropdownMenuContent align="end" className="w-56">
|
| 288 |
+
<div className="px-2 py-1.5">
|
| 289 |
+
<div className="flex items-center justify-between gap-2">
|
| 290 |
+
<div className="flex-1 min-w-0">
|
| 291 |
+
<p className="text-sm font-medium truncate">{user.name}</p>
|
| 292 |
+
<p className="text-xs text-muted-foreground truncate">ID: {user.email.split('@')[0] || user.email}</p>
|
| 293 |
+
</div>
|
| 294 |
+
<div className="flex items-center gap-2 flex-shrink-0">
|
| 295 |
+
{starBrightness === 10 ? (
|
| 296 |
+
<Star
|
| 297 |
+
className="w-4 h-4"
|
| 298 |
+
style={{
|
| 299 |
+
fill: 'transparent',
|
| 300 |
+
stroke: 'white',
|
| 301 |
+
strokeWidth: 1.5,
|
| 302 |
+
strokeDasharray: '2 2',
|
| 303 |
+
opacity: 0.6
|
| 304 |
+
}}
|
| 305 |
+
/>
|
| 306 |
+
) : (
|
| 307 |
+
<Star
|
| 308 |
+
className="w-4 h-4"
|
| 309 |
+
style={{
|
| 310 |
+
...getStarColor(),
|
| 311 |
+
opacity: getStarOpacity(),
|
| 312 |
+
filter: starBrightness === 0 ? 'drop-shadow(0 0 2px rgba(251, 191, 36, 0.8))' : 'none'
|
| 313 |
+
}}
|
| 314 |
+
/>
|
| 315 |
+
)}
|
| 316 |
+
<div className="flex flex-col items-end">
|
| 317 |
+
<span className="text-xs font-medium">{getEnergyValue()}%</span>
|
| 318 |
+
<div className="w-12 h-1.5 bg-muted rounded-full overflow-hidden">
|
| 319 |
+
<div
|
| 320 |
+
className="h-full bg-gradient-to-r from-amber-400 to-yellow-500 transition-all duration-300"
|
| 321 |
+
style={{ width: `${getEnergyValue()}%` }}
|
| 322 |
+
/>
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
<DropdownMenuSeparator />
|
| 329 |
+
<DropdownMenuItem onClick={() => setShowProfileEditor(true)}>
|
| 330 |
+
<Edit className="h-4 w-4 mr-2" />
|
| 331 |
+
Edit Profile
|
| 332 |
+
</DropdownMenuItem>
|
| 333 |
+
<DropdownMenuSeparator />
|
| 334 |
+
<DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
|
| 335 |
+
<LogOut className="h-4 w-4 mr-2" />
|
| 336 |
+
Log out
|
| 337 |
+
</DropdownMenuItem>
|
| 338 |
+
</DropdownMenuContent>
|
| 339 |
+
</DropdownMenu>
|
| 340 |
+
{/* Star badge in top-right corner of avatar - positioned outside, not overlapping */}
|
| 341 |
+
<TooltipProvider>
|
| 342 |
+
<Tooltip>
|
| 343 |
+
<TooltipTrigger asChild>
|
| 344 |
+
<button
|
| 345 |
+
type="button"
|
| 346 |
+
className="absolute cursor-pointer z-20 pointer-events-auto bg-transparent border-0 p-0"
|
| 347 |
+
style={{
|
| 348 |
+
top: '-8px',
|
| 349 |
+
right: '-16px',
|
| 350 |
+
opacity: starBrightness === 10 ? 0.6 : 1,
|
| 351 |
+
transition: 'opacity 0.3s ease-in-out',
|
| 352 |
+
filter: starBrightness === 0 ? 'drop-shadow(0 0 4px rgba(251, 191, 36, 0.8)) drop-shadow(0 0 8px rgba(251, 191, 36, 0.4))' : 'none'
|
| 353 |
+
}}
|
| 354 |
+
onClick={handleStarClick}
|
| 355 |
+
>
|
| 356 |
+
{starBrightness === 10 ? (
|
| 357 |
+
<Star
|
| 358 |
+
className="w-5 h-5"
|
| 359 |
+
style={{
|
| 360 |
+
fill: 'transparent',
|
| 361 |
+
stroke: 'white',
|
| 362 |
+
strokeWidth: 1.5,
|
| 363 |
+
strokeDasharray: '2 2'
|
| 364 |
+
}}
|
| 365 |
+
/>
|
| 366 |
+
) : (
|
| 367 |
+
<Star
|
| 368 |
+
className="w-5 h-5"
|
| 369 |
+
style={{
|
| 370 |
+
...getStarColor(),
|
| 371 |
+
opacity: getStarOpacity(),
|
| 372 |
+
filter: starBrightness === 0 ? 'drop-shadow(0 0 2px rgba(251, 191, 36, 1))' : 'none'
|
| 373 |
+
}}
|
| 374 |
+
/>
|
| 375 |
+
)}
|
| 376 |
+
</button>
|
| 377 |
+
</TooltipTrigger>
|
| 378 |
+
<TooltipContent
|
| 379 |
+
className="z-[200] border border-amber-300/30 shadow-md"
|
| 380 |
+
style={{
|
| 381 |
+
zIndex: 200,
|
| 382 |
+
backgroundColor: 'rgba(251, 191, 36, 0.95)',
|
| 383 |
+
color: 'rgb(28, 25, 23)'
|
| 384 |
+
}}
|
| 385 |
+
sideOffset={5}
|
| 386 |
+
>
|
| 387 |
+
<div className="space-y-1">
|
| 388 |
+
<p className="text-sm font-medium">Energy: {getEnergyValue()}%</p>
|
| 389 |
+
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
|
| 390 |
+
<div
|
| 391 |
+
className="h-full bg-gradient-to-r from-amber-400 to-yellow-500 transition-all duration-300"
|
| 392 |
+
style={{ width: `${getEnergyValue()}%` }}
|
| 393 |
+
/>
|
| 394 |
+
</div>
|
| 395 |
+
</div>
|
| 396 |
+
</TooltipContent>
|
| 397 |
+
</Tooltip>
|
| 398 |
+
</TooltipProvider>
|
| 399 |
+
</div>
|
| 400 |
+
</>
|
| 401 |
+
) : null}
|
| 402 |
+
</div>
|
| 403 |
+
{/* Create Group Workspace Dialog */}
|
| 404 |
+
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
| 405 |
+
<DialogContent
|
| 406 |
+
className="w-[600px] max-w-[600px] sm:max-w-[600px] z-[1001] pointer-events-auto"
|
| 407 |
+
style={{ width: 600, maxWidth: 600, zIndex: 1001 }}
|
| 408 |
+
overlayClassName="!z-[99]"
|
| 409 |
+
overlayStyle={{
|
| 410 |
+
top: '64px',
|
| 411 |
+
left: 0,
|
| 412 |
+
right: 0,
|
| 413 |
+
bottom: 0,
|
| 414 |
+
zIndex: 99,
|
| 415 |
+
position: 'fixed'
|
| 416 |
+
}}
|
| 417 |
+
onPointerDownOutside={(e) => e.preventDefault()}
|
| 418 |
+
onInteractOutside={(e) => e.preventDefault()}
|
| 419 |
+
>
|
| 420 |
+
<DialogHeader>
|
| 421 |
+
<DialogTitle>Create Group Workspace</DialogTitle>
|
| 422 |
+
</DialogHeader>
|
| 423 |
+
<div className="space-y-6">
|
| 424 |
+
<div className="space-y-2">
|
| 425 |
+
<Label htmlFor="ws-name">Workspace Name</Label>
|
| 426 |
+
<Input id="ws-name" value={workspaceName} onChange={e => setWorkspaceName(e.target.value)} placeholder="e.g., CS 101 Study Group" />
|
| 427 |
+
</div>
|
| 428 |
+
<div className="space-y-2">
|
| 429 |
+
<Label>Category</Label>
|
| 430 |
+
<RadioGroup value={category} onValueChange={(val) => setCategory(val as 'course' | 'personal')} className="flex gap-4">
|
| 431 |
+
<div className="flex items-center space-x-2">
|
| 432 |
+
<RadioGroupItem id="cat-course" value="course" />
|
| 433 |
+
<Label htmlFor="cat-course">Course</Label>
|
| 434 |
+
</div>
|
| 435 |
+
<div className="flex items-center space-x-2">
|
| 436 |
+
<RadioGroupItem id="cat-personal" value="personal" />
|
| 437 |
+
<Label htmlFor="cat-personal">Personal Interest</Label>
|
| 438 |
+
</div>
|
| 439 |
+
</RadioGroup>
|
| 440 |
+
</div>
|
| 441 |
+
{category === 'course' && (
|
| 442 |
+
<div className="space-y-2">
|
| 443 |
+
<Label htmlFor="course-select">Course Name</Label>
|
| 444 |
+
<Select value={courseId} onValueChange={setCourseId}>
|
| 445 |
+
<SelectTrigger id="course-select">
|
| 446 |
+
<SelectValue placeholder="Select a course" />
|
| 447 |
+
</SelectTrigger>
|
| 448 |
+
<SelectContent>
|
| 449 |
+
{availableCourses.map((course) => (
|
| 450 |
+
<SelectItem key={course.id} value={course.id}>
|
| 451 |
+
{course.name}
|
| 452 |
+
</SelectItem>
|
| 453 |
+
))}
|
| 454 |
+
</SelectContent>
|
| 455 |
+
</Select>
|
| 456 |
+
</div>
|
| 457 |
+
)}
|
| 458 |
+
<div className="space-y-2">
|
| 459 |
+
<Label>Invite Members (emails)</Label>
|
| 460 |
+
<div className="flex gap-2">
|
| 461 |
+
<Input value={inviteEmail} onChange={e => setInviteEmail(e.target.value)} placeholder="Enter email and click Add" onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addInvite(); } }} />
|
| 462 |
+
<Button variant="secondary" onClick={addInvite}>Add</Button>
|
| 463 |
+
</div>
|
| 464 |
+
{invites.length > 0 && (
|
| 465 |
+
<div className="flex flex-wrap gap-2">
|
| 466 |
+
{invites.map(email => (
|
| 467 |
+
<span key={email} className="inline-flex items-center px-2 py-1 rounded-full bg-muted text-sm">
|
| 468 |
+
{email}
|
| 469 |
+
<Button variant="ghost" size="icon" className="h-4 w-4 ml-1" onClick={() => removeInvite(email)} aria-label={`Remove ${email}`}>
|
| 470 |
+
<X className="h-3 w-3" />
|
| 471 |
+
</Button>
|
| 472 |
+
</span>
|
| 473 |
+
))}
|
| 474 |
+
</div>
|
| 475 |
+
)}
|
| 476 |
+
</div>
|
| 477 |
+
</div>
|
| 478 |
+
<DialogFooter>
|
| 479 |
+
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
| 480 |
+
<Button onClick={handleCreate}>Create</Button>
|
| 481 |
+
</DialogFooter>
|
| 482 |
+
</DialogContent>
|
| 483 |
+
</Dialog>
|
| 484 |
+
|
| 485 |
+
{/* Profile Editor Dialog */}
|
| 486 |
+
{user && showProfileEditor && (
|
| 487 |
+
<ProfileEditor
|
| 488 |
+
user={user}
|
| 489 |
+
onSave={(updatedUser) => {
|
| 490 |
+
if (onUserUpdate) {
|
| 491 |
+
onUserUpdate(updatedUser);
|
| 492 |
+
}
|
| 493 |
+
setShowProfileEditor(false);
|
| 494 |
+
}}
|
| 495 |
+
onClose={() => setShowProfileEditor(false)}
|
| 496 |
+
/>
|
| 497 |
+
)}
|
| 498 |
+
</header>
|
| 499 |
+
);
|
| 500 |
+
}
|
web/src/components/LearningModeSelector.tsx
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Card } from './ui/card';
|
| 3 |
+
import {
|
| 4 |
+
Lightbulb,
|
| 5 |
+
MessageCircleQuestion,
|
| 6 |
+
GraduationCap,
|
| 7 |
+
FileEdit,
|
| 8 |
+
Zap,
|
| 9 |
+
MessageSquare
|
| 10 |
+
} from 'lucide-react';
|
| 11 |
+
import type { LearningMode } from '../App';
|
| 12 |
+
|
| 13 |
+
interface ModeSelectorProps {
|
| 14 |
+
selectedMode: LearningMode;
|
| 15 |
+
onModeChange: (mode: LearningMode) => void;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const modes = [
|
| 19 |
+
{
|
| 20 |
+
id: 'general' as LearningMode,
|
| 21 |
+
icon: MessageSquare,
|
| 22 |
+
title: 'General',
|
| 23 |
+
description: 'Answer various questions (context required)',
|
| 24 |
+
color: 'from-purple-500 to-purple-600',
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
id: 'concept' as LearningMode,
|
| 28 |
+
icon: Lightbulb,
|
| 29 |
+
title: 'Concept Explainer',
|
| 30 |
+
description: 'Break down complex topics',
|
| 31 |
+
color: 'from-blue-500 to-blue-600',
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
id: 'socratic' as LearningMode,
|
| 35 |
+
title: 'Socratic Tutor',
|
| 36 |
+
description: 'Learn through questions',
|
| 37 |
+
color: 'from-red-500 to-rose-600',
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
id: 'exam' as LearningMode,
|
| 41 |
+
icon: GraduationCap,
|
| 42 |
+
title: 'Exam Prep/Quiz',
|
| 43 |
+
description: 'Test your knowledge',
|
| 44 |
+
color: 'from-green-500 to-green-600',
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
id: 'assignment' as LearningMode,
|
| 48 |
+
icon: FileEdit,
|
| 49 |
+
title: 'Assignment Helper',
|
| 50 |
+
description: 'Get homework guidance',
|
| 51 |
+
color: 'from-orange-500 to-orange-600',
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
id: 'summary' as LearningMode,
|
| 55 |
+
icon: Zap,
|
| 56 |
+
title: 'Quick Summary',
|
| 57 |
+
description: 'Fast key points review',
|
| 58 |
+
color: 'from-pink-500 to-pink-600',
|
| 59 |
+
},
|
| 60 |
+
];
|
| 61 |
+
|
| 62 |
+
export function LearningModeSelector({ selectedMode, onModeChange }: ModeSelectorProps) {
|
| 63 |
+
return (
|
| 64 |
+
<div className="space-y-2">
|
| 65 |
+
{modes.map((mode) => {
|
| 66 |
+
const Icon = mode.icon;
|
| 67 |
+
const isSelected = selectedMode === mode.id;
|
| 68 |
+
|
| 69 |
+
return (
|
| 70 |
+
<Card
|
| 71 |
+
key={mode.id}
|
| 72 |
+
className={`
|
| 73 |
+
p-3 cursor-pointer transition-all duration-200
|
| 74 |
+
${isSelected
|
| 75 |
+
? 'border-primary bg-accent shadow-sm'
|
| 76 |
+
: 'hover:border-primary/50 hover:shadow-sm'
|
| 77 |
+
}
|
| 78 |
+
`}
|
| 79 |
+
onClick={() => onModeChange(mode.id)}
|
| 80 |
+
>
|
| 81 |
+
<div className="flex items-start gap-3">
|
| 82 |
+
<div className={`
|
| 83 |
+
w-10 h-10 rounded-lg bg-gradient-to-br ${mode.color}
|
| 84 |
+
flex items-center justify-center flex-shrink-0
|
| 85 |
+
`}>
|
| 86 |
+
<Icon className="h-5 w-5 text-white" />
|
| 87 |
+
</div>
|
| 88 |
+
<div className="flex-1 min-w-0">
|
| 89 |
+
<h4 className="text-sm mb-1">{mode.title}</h4>
|
| 90 |
+
<p className="text-xs text-muted-foreground">{mode.description}</p>
|
| 91 |
+
</div>
|
| 92 |
+
{isSelected && (
|
| 93 |
+
<div className="w-2 h-2 rounded-full bg-primary flex-shrink-0 mt-2" />
|
| 94 |
+
)}
|
| 95 |
+
</div>
|
| 96 |
+
</Card>
|
| 97 |
+
);
|
| 98 |
+
})}
|
| 99 |
+
</div>
|
| 100 |
+
);
|
| 101 |
+
}
|
web/src/components/LeftSidebar.tsx
ADDED
|
@@ -0,0 +1,834 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { LearningModeSelector } from './LearningModeSelector';
|
| 3 |
+
import { Label } from './ui/label';
|
| 4 |
+
import { Button } from './ui/button';
|
| 5 |
+
import { LogIn, LogOut, Bookmark, Download, Copy, Search, X, MessageSquare, Trash2, Edit2, Check, X as XIcon } from 'lucide-react';
|
| 6 |
+
import { Separator } from './ui/separator';
|
| 7 |
+
import { GroupMembers } from './GroupMembers';
|
| 8 |
+
import { Card } from './ui/card';
|
| 9 |
+
import { Input } from './ui/input';
|
| 10 |
+
import type { LearningMode, Language, SpaceType, GroupMember, User as UserType, SavedItem, SavedChat, Message as MessageType } from '../App';
|
| 11 |
+
import { toast } from 'sonner';
|
| 12 |
+
import { Document, HeadingLevel, Packer, Paragraph, TextRun } from 'docx';
|
| 13 |
+
import { jsPDF } from 'jspdf';
|
| 14 |
+
import {
|
| 15 |
+
Dialog,
|
| 16 |
+
DialogContent,
|
| 17 |
+
DialogDescription,
|
| 18 |
+
DialogHeader,
|
| 19 |
+
DialogTitle,
|
| 20 |
+
} from './ui/dialog';
|
| 21 |
+
import type { CourseInfo } from '../App';
|
| 22 |
+
|
| 23 |
+
// Saved Chat Item Component with rename functionality
|
| 24 |
+
function SavedChatItem({
|
| 25 |
+
chat,
|
| 26 |
+
onLoadChat,
|
| 27 |
+
onDeleteSavedChat,
|
| 28 |
+
onRenameSavedChat,
|
| 29 |
+
}: {
|
| 30 |
+
chat: SavedChat;
|
| 31 |
+
onLoadChat: (chat: SavedChat) => void;
|
| 32 |
+
onDeleteSavedChat: (id: string) => void;
|
| 33 |
+
onRenameSavedChat?: (id: string, newTitle: string) => void;
|
| 34 |
+
}) {
|
| 35 |
+
const [isEditing, setIsEditing] = useState(false);
|
| 36 |
+
const [editTitle, setEditTitle] = useState(chat.title);
|
| 37 |
+
const [originalTitle, setOriginalTitle] = useState(chat.title);
|
| 38 |
+
const inputRef = useRef<HTMLInputElement>(null);
|
| 39 |
+
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
| 40 |
+
const saveButtonRef = useRef<HTMLButtonElement>(null);
|
| 41 |
+
|
| 42 |
+
// Update originalTitle when chat.title changes (e.g., from external rename)
|
| 43 |
+
useEffect(() => {
|
| 44 |
+
if (!isEditing) {
|
| 45 |
+
setOriginalTitle(chat.title);
|
| 46 |
+
setEditTitle(chat.title);
|
| 47 |
+
}
|
| 48 |
+
}, [chat.title, isEditing]);
|
| 49 |
+
|
| 50 |
+
const handleStartEdit = (e: React.MouseEvent) => {
|
| 51 |
+
e.preventDefault();
|
| 52 |
+
e.stopPropagation();
|
| 53 |
+
setOriginalTitle(chat.title);
|
| 54 |
+
setEditTitle(chat.title);
|
| 55 |
+
setIsEditing(true);
|
| 56 |
+
setTimeout(() => inputRef.current?.focus(), 0);
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const handleSaveEdit = (e: React.MouseEvent) => {
|
| 60 |
+
e.preventDefault();
|
| 61 |
+
e.stopPropagation();
|
| 62 |
+
if (editTitle.trim() && onRenameSavedChat) {
|
| 63 |
+
onRenameSavedChat(chat.id, editTitle.trim());
|
| 64 |
+
setIsEditing(false);
|
| 65 |
+
}
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
const handleCancelEdit = (e: React.MouseEvent) => {
|
| 69 |
+
e.preventDefault();
|
| 70 |
+
e.stopPropagation();
|
| 71 |
+
// Reset to original title when canceling
|
| 72 |
+
setEditTitle(originalTitle);
|
| 73 |
+
setIsEditing(false);
|
| 74 |
+
// Prevent onBlur from firing
|
| 75 |
+
if (inputRef.current) {
|
| 76 |
+
inputRef.current.blur();
|
| 77 |
+
}
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
| 81 |
+
// Check if the blur is caused by clicking cancel or save button
|
| 82 |
+
const relatedTarget = e.relatedTarget as HTMLElement;
|
| 83 |
+
if (relatedTarget && (
|
| 84 |
+
cancelButtonRef.current?.contains(relatedTarget) ||
|
| 85 |
+
saveButtonRef.current?.contains(relatedTarget)
|
| 86 |
+
)) {
|
| 87 |
+
return; // Don't save if clicking cancel or save button
|
| 88 |
+
}
|
| 89 |
+
// Save on blur if title changed
|
| 90 |
+
if (editTitle.trim() && editTitle !== originalTitle && onRenameSavedChat) {
|
| 91 |
+
onRenameSavedChat(chat.id, editTitle.trim());
|
| 92 |
+
}
|
| 93 |
+
setIsEditing(false);
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
| 97 |
+
if (e.key === 'Enter') {
|
| 98 |
+
e.preventDefault();
|
| 99 |
+
e.stopPropagation();
|
| 100 |
+
if (editTitle.trim() && onRenameSavedChat) {
|
| 101 |
+
onRenameSavedChat(chat.id, editTitle.trim());
|
| 102 |
+
setIsEditing(false);
|
| 103 |
+
}
|
| 104 |
+
} else if (e.key === 'Escape') {
|
| 105 |
+
e.preventDefault();
|
| 106 |
+
e.stopPropagation();
|
| 107 |
+
setEditTitle(originalTitle);
|
| 108 |
+
setIsEditing(false);
|
| 109 |
+
}
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
return (
|
| 113 |
+
<Card
|
| 114 |
+
className="p-3 cursor-pointer hover:bg-muted/50 transition-all bg-muted/30"
|
| 115 |
+
onClick={() => !isEditing && onLoadChat(chat)}
|
| 116 |
+
>
|
| 117 |
+
<div className="flex items-start gap-2">
|
| 118 |
+
<MessageSquare className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-muted-foreground" />
|
| 119 |
+
<div className="flex-1 min-w-0">
|
| 120 |
+
<div className="flex items-start justify-between gap-2">
|
| 121 |
+
{isEditing ? (
|
| 122 |
+
<Input
|
| 123 |
+
ref={inputRef}
|
| 124 |
+
value={editTitle}
|
| 125 |
+
onChange={(e) => setEditTitle(e.target.value)}
|
| 126 |
+
onKeyDown={handleKeyDown}
|
| 127 |
+
onClick={(e) => e.stopPropagation()}
|
| 128 |
+
onBlur={handleInputBlur}
|
| 129 |
+
className="h-auto text-sm font-medium px-2 py-1 border border-border bg-background focus-visible:ring-2 focus-visible:ring-ring flex-1"
|
| 130 |
+
style={{ height: 'auto' }}
|
| 131 |
+
/>
|
| 132 |
+
) : (
|
| 133 |
+
<h4
|
| 134 |
+
className="text-sm font-medium truncate flex-1 cursor-text"
|
| 135 |
+
onDoubleClick={(e) => {
|
| 136 |
+
e.preventDefault();
|
| 137 |
+
e.stopPropagation();
|
| 138 |
+
handleStartEdit(e);
|
| 139 |
+
}}
|
| 140 |
+
onClick={(e) => {
|
| 141 |
+
e.stopPropagation();
|
| 142 |
+
}}
|
| 143 |
+
title="Double click to rename"
|
| 144 |
+
>
|
| 145 |
+
{chat.title}
|
| 146 |
+
</h4>
|
| 147 |
+
)}
|
| 148 |
+
<div className="flex items-center gap-1 flex-shrink-0">
|
| 149 |
+
{isEditing ? (
|
| 150 |
+
<>
|
| 151 |
+
<Button
|
| 152 |
+
ref={saveButtonRef}
|
| 153 |
+
variant="ghost"
|
| 154 |
+
size="icon"
|
| 155 |
+
className="h-5 w-5 flex-shrink-0 hover:bg-green-500/20"
|
| 156 |
+
onClick={(e) => {
|
| 157 |
+
e.preventDefault();
|
| 158 |
+
e.stopPropagation();
|
| 159 |
+
handleSaveEdit(e);
|
| 160 |
+
}}
|
| 161 |
+
title="Save"
|
| 162 |
+
type="button"
|
| 163 |
+
>
|
| 164 |
+
<Check className="h-3 w-3" />
|
| 165 |
+
</Button>
|
| 166 |
+
<Button
|
| 167 |
+
ref={cancelButtonRef}
|
| 168 |
+
variant="ghost"
|
| 169 |
+
size="icon"
|
| 170 |
+
className="h-5 w-5 flex-shrink-0 hover:bg-destructive/20"
|
| 171 |
+
onClick={(e) => {
|
| 172 |
+
e.preventDefault();
|
| 173 |
+
e.stopPropagation();
|
| 174 |
+
handleCancelEdit(e);
|
| 175 |
+
}}
|
| 176 |
+
title="Cancel"
|
| 177 |
+
type="button"
|
| 178 |
+
>
|
| 179 |
+
<XIcon className="h-3 w-3" />
|
| 180 |
+
</Button>
|
| 181 |
+
</>
|
| 182 |
+
) : (
|
| 183 |
+
<>
|
| 184 |
+
{onRenameSavedChat && (
|
| 185 |
+
<Button
|
| 186 |
+
variant="ghost"
|
| 187 |
+
size="icon"
|
| 188 |
+
className="h-5 w-5 flex-shrink-0 hover:bg-muted"
|
| 189 |
+
onClick={handleStartEdit}
|
| 190 |
+
title="Rename chat"
|
| 191 |
+
>
|
| 192 |
+
<Edit2 className="h-3 w-3" />
|
| 193 |
+
</Button>
|
| 194 |
+
)}
|
| 195 |
+
<Button
|
| 196 |
+
variant="ghost"
|
| 197 |
+
size="icon"
|
| 198 |
+
className="h-5 w-5 flex-shrink-0 hover:bg-destructive/20"
|
| 199 |
+
onClick={(e) => {
|
| 200 |
+
e.stopPropagation();
|
| 201 |
+
onDeleteSavedChat(chat.id);
|
| 202 |
+
}}
|
| 203 |
+
title="Delete chat"
|
| 204 |
+
>
|
| 205 |
+
<Trash2 className="h-3 w-3" />
|
| 206 |
+
</Button>
|
| 207 |
+
</>
|
| 208 |
+
)}
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 212 |
+
{chat.chatMode === 'ask' ? 'Ask' : chat.chatMode === 'review' ? 'Review' : 'Quiz'} • {chat.timestamp.toLocaleDateString()}
|
| 213 |
+
</p>
|
| 214 |
+
<p className="text-xs text-muted-foreground/70 mt-1">
|
| 215 |
+
{chat.messages.length} message{chat.messages.length !== 1 ? 's' : ''}
|
| 216 |
+
</p>
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
</Card>
|
| 220 |
+
);
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
interface LeftSidebarProps {
|
| 224 |
+
learningMode: LearningMode;
|
| 225 |
+
language: Language;
|
| 226 |
+
onLearningModeChange: (mode: LearningMode) => void;
|
| 227 |
+
onLanguageChange: (lang: Language) => void;
|
| 228 |
+
spaceType: SpaceType;
|
| 229 |
+
groupMembers: GroupMember[];
|
| 230 |
+
user: UserType | null;
|
| 231 |
+
onLogin: (user: UserType) => void;
|
| 232 |
+
onLogout: () => void;
|
| 233 |
+
isLoggedIn: boolean;
|
| 234 |
+
onEditProfile: () => void;
|
| 235 |
+
savedItems: SavedItem[];
|
| 236 |
+
recentlySavedId: string | null;
|
| 237 |
+
onUnsave: (id: string) => void;
|
| 238 |
+
onSave: (content: string, type: 'export' | 'quiz' | 'summary') => void;
|
| 239 |
+
savedChats: SavedChat[];
|
| 240 |
+
onLoadChat: (chat: SavedChat) => void;
|
| 241 |
+
onDeleteSavedChat: (id: string) => void;
|
| 242 |
+
onRenameSavedChat?: (id: string, newTitle: string) => void;
|
| 243 |
+
currentWorkspaceId: string;
|
| 244 |
+
workspaces?: Array<{ id: string; type: SpaceType; category?: 'course' | 'personal'; courseName?: string; courseInfo?: CourseInfo; members?: GroupMember[]; isEditable?: boolean; name?: string }>;
|
| 245 |
+
selectedCourse?: string;
|
| 246 |
+
courses?: Array<{ id: string; name: string }>;
|
| 247 |
+
availableCourses?: CourseInfo[];
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
export function LeftSidebar({
|
| 251 |
+
learningMode,
|
| 252 |
+
language,
|
| 253 |
+
onLearningModeChange,
|
| 254 |
+
onLanguageChange,
|
| 255 |
+
spaceType,
|
| 256 |
+
groupMembers,
|
| 257 |
+
user,
|
| 258 |
+
onLogin,
|
| 259 |
+
onLogout,
|
| 260 |
+
isLoggedIn,
|
| 261 |
+
onEditProfile,
|
| 262 |
+
savedItems,
|
| 263 |
+
recentlySavedId,
|
| 264 |
+
onUnsave,
|
| 265 |
+
onSave,
|
| 266 |
+
savedChats,
|
| 267 |
+
onLoadChat,
|
| 268 |
+
onDeleteSavedChat,
|
| 269 |
+
onRenameSavedChat,
|
| 270 |
+
currentWorkspaceId,
|
| 271 |
+
workspaces = [],
|
| 272 |
+
selectedCourse,
|
| 273 |
+
courses = [],
|
| 274 |
+
availableCourses = [],
|
| 275 |
+
}: LeftSidebarProps) {
|
| 276 |
+
const [showLoginForm, setShowLoginForm] = useState(false);
|
| 277 |
+
const [name, setName] = useState('');
|
| 278 |
+
const [email, setEmail] = useState('');
|
| 279 |
+
const [selectedItem, setSelectedItem] = useState<SavedItem | null>(null);
|
| 280 |
+
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
| 281 |
+
const [showSearch, setShowSearch] = useState(false);
|
| 282 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 283 |
+
|
| 284 |
+
const handleLogin = () => {
|
| 285 |
+
if (!name.trim() || !email.trim()) {
|
| 286 |
+
toast.error('Please fill in all fields');
|
| 287 |
+
return;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
onLogin({ name: name.trim(), email: email.trim() });
|
| 291 |
+
setShowLoginForm(false);
|
| 292 |
+
setName('');
|
| 293 |
+
setEmail('');
|
| 294 |
+
toast.success(`Welcome, ${name}!`);
|
| 295 |
+
};
|
| 296 |
+
|
| 297 |
+
const handleLogout = () => {
|
| 298 |
+
onLogout();
|
| 299 |
+
setShowLoginForm(false);
|
| 300 |
+
toast.success('Logged out successfully');
|
| 301 |
+
};
|
| 302 |
+
|
| 303 |
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
| 304 |
+
const [isDownloading, setIsDownloading] = useState(false);
|
| 305 |
+
const [copied, setCopied] = useState(false);
|
| 306 |
+
|
| 307 |
+
const downloadBlob = (blob: Blob, filename: string) => {
|
| 308 |
+
const url = URL.createObjectURL(blob);
|
| 309 |
+
const a = document.createElement('a');
|
| 310 |
+
a.href = url;
|
| 311 |
+
a.download = filename;
|
| 312 |
+
document.body.appendChild(a);
|
| 313 |
+
a.click();
|
| 314 |
+
a.remove();
|
| 315 |
+
URL.revokeObjectURL(url);
|
| 316 |
+
};
|
| 317 |
+
|
| 318 |
+
const formatDateStamp = (date: Date) => {
|
| 319 |
+
const yyyy = date.getFullYear();
|
| 320 |
+
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
| 321 |
+
const dd = String(date.getDate()).padStart(2, '0');
|
| 322 |
+
return `${yyyy}-${mm}-${dd}`;
|
| 323 |
+
};
|
| 324 |
+
|
| 325 |
+
const getDefaultFilenameBase = (item: SavedItem) => {
|
| 326 |
+
const kind = item.type === 'export' ? 'export' : item.type === 'summary' ? 'summary' : 'quiz';
|
| 327 |
+
return `clare-${kind}-${formatDateStamp(item.timestamp)}`;
|
| 328 |
+
};
|
| 329 |
+
|
| 330 |
+
const handleDownloadMd = async (item: SavedItem) => {
|
| 331 |
+
try {
|
| 332 |
+
setIsDownloading(true);
|
| 333 |
+
toast.message('Preparing .md…');
|
| 334 |
+
const blob = new Blob([item.content], { type: 'text/markdown;charset=utf-8' });
|
| 335 |
+
downloadBlob(blob, `${getDefaultFilenameBase(item)}.md`);
|
| 336 |
+
toast.success('Downloaded .md');
|
| 337 |
+
} catch (e) {
|
| 338 |
+
console.error(e);
|
| 339 |
+
toast.error('Failed to download .md');
|
| 340 |
+
} finally {
|
| 341 |
+
setIsDownloading(false);
|
| 342 |
+
}
|
| 343 |
+
};
|
| 344 |
+
|
| 345 |
+
const handleDownloadDocx = async (item: SavedItem) => {
|
| 346 |
+
try {
|
| 347 |
+
setIsDownloading(true);
|
| 348 |
+
toast.message('Preparing .docx…');
|
| 349 |
+
const lines = item.content.split('\n');
|
| 350 |
+
const paragraphs: Paragraph[] = lines.map((line) => {
|
| 351 |
+
const trimmed = line.trim();
|
| 352 |
+
if (!trimmed) return new Paragraph({ text: '' });
|
| 353 |
+
if (trimmed.startsWith('### ')) {
|
| 354 |
+
return new Paragraph({ text: trimmed.replace(/^###\s+/, ''), heading: HeadingLevel.HEADING_3 });
|
| 355 |
+
}
|
| 356 |
+
if (trimmed.startsWith('## ')) {
|
| 357 |
+
return new Paragraph({ text: trimmed.replace(/^##\s+/, ''), heading: HeadingLevel.HEADING_2 });
|
| 358 |
+
}
|
| 359 |
+
if (trimmed.startsWith('# ')) {
|
| 360 |
+
return new Paragraph({ text: trimmed.replace(/^#\s+/, ''), heading: HeadingLevel.HEADING_1 });
|
| 361 |
+
}
|
| 362 |
+
return new Paragraph({ children: [new TextRun({ text: line })] });
|
| 363 |
+
});
|
| 364 |
+
const doc = new Document({
|
| 365 |
+
sections: [{ properties: {}, children: paragraphs }],
|
| 366 |
+
});
|
| 367 |
+
const blob = await Packer.toBlob(doc);
|
| 368 |
+
downloadBlob(blob, `${getDefaultFilenameBase(item)}.docx`);
|
| 369 |
+
toast.success('Downloaded .docx');
|
| 370 |
+
} catch (e) {
|
| 371 |
+
console.error(e);
|
| 372 |
+
toast.error('Failed to download .docx');
|
| 373 |
+
} finally {
|
| 374 |
+
setIsDownloading(false);
|
| 375 |
+
}
|
| 376 |
+
};
|
| 377 |
+
|
| 378 |
+
const handleDownloadPdf = async (item: SavedItem) => {
|
| 379 |
+
try {
|
| 380 |
+
setIsDownloading(true);
|
| 381 |
+
toast.message('Preparing .pdf…');
|
| 382 |
+
const doc = new jsPDF({ unit: 'pt', format: 'a4' });
|
| 383 |
+
const pageWidth = doc.internal.pageSize.getWidth();
|
| 384 |
+
const pageHeight = doc.internal.pageSize.getHeight();
|
| 385 |
+
const margin = 40;
|
| 386 |
+
const contentWidth = pageWidth - margin * 2;
|
| 387 |
+
const lineHeight = 16;
|
| 388 |
+
|
| 389 |
+
const lines = doc.splitTextToSize(item.content, contentWidth);
|
| 390 |
+
let y = margin;
|
| 391 |
+
lines.forEach((line) => {
|
| 392 |
+
if (y + lineHeight > pageHeight - margin) {
|
| 393 |
+
doc.addPage();
|
| 394 |
+
y = margin;
|
| 395 |
+
}
|
| 396 |
+
doc.text(line, margin, y);
|
| 397 |
+
y += lineHeight;
|
| 398 |
+
});
|
| 399 |
+
|
| 400 |
+
const filenameBase = getDefaultFilenameBase(item);
|
| 401 |
+
doc.save(`${filenameBase}.pdf`);
|
| 402 |
+
toast.success('Downloaded .pdf');
|
| 403 |
+
} catch (e) {
|
| 404 |
+
console.error(e);
|
| 405 |
+
toast.error('Failed to download .pdf');
|
| 406 |
+
} finally {
|
| 407 |
+
setIsDownloading(false);
|
| 408 |
+
}
|
| 409 |
+
};
|
| 410 |
+
|
| 411 |
+
const handleCopy = async (content: string) => {
|
| 412 |
+
await navigator.clipboard.writeText(content);
|
| 413 |
+
setCopied(true);
|
| 414 |
+
toast.success('Copied to clipboard!');
|
| 415 |
+
setTimeout(() => setCopied(false), 2000);
|
| 416 |
+
};
|
| 417 |
+
|
| 418 |
+
const handleUnsaveItem = (id: string) => {
|
| 419 |
+
onUnsave(id);
|
| 420 |
+
// Don't close the dialog when unsaving - keep the window open
|
| 421 |
+
};
|
| 422 |
+
|
| 423 |
+
// Check if selectedItem is still in savedItems (by id if it has one, or by content+type)
|
| 424 |
+
const isItemSaved = selectedItem
|
| 425 |
+
? savedItems.some(item => {
|
| 426 |
+
// First try to match by id if selectedItem has one
|
| 427 |
+
if (selectedItem.id && item.id === selectedItem.id) return true;
|
| 428 |
+
// Otherwise match by content and type
|
| 429 |
+
return item.content === selectedItem.content && item.type === selectedItem.type;
|
| 430 |
+
})
|
| 431 |
+
: false;
|
| 432 |
+
|
| 433 |
+
// Update selectedItem when savedItems changes (e.g., after saving)
|
| 434 |
+
useEffect(() => {
|
| 435 |
+
if (selectedItem && isDialogOpen) {
|
| 436 |
+
// Find the item in savedItems by content and type
|
| 437 |
+
const updatedItem = savedItems.find(item =>
|
| 438 |
+
item.content === selectedItem.content && item.type === selectedItem.type
|
| 439 |
+
);
|
| 440 |
+
if (updatedItem && updatedItem.id !== selectedItem.id) {
|
| 441 |
+
// Update selectedItem to have the correct id from savedItems (only if id changed)
|
| 442 |
+
setSelectedItem(updatedItem);
|
| 443 |
+
}
|
| 444 |
+
}
|
| 445 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 446 |
+
}, [savedItems, isDialogOpen]);
|
| 447 |
+
|
| 448 |
+
const handleToggleSave = () => {
|
| 449 |
+
if (!selectedItem) return;
|
| 450 |
+
|
| 451 |
+
if (isItemSaved) {
|
| 452 |
+
// Unsave the item - find it by id or content+type
|
| 453 |
+
const itemToUnsave = savedItems.find(item =>
|
| 454 |
+
(selectedItem.id && item.id === selectedItem.id) ||
|
| 455 |
+
(item.content === selectedItem.content && item.type === selectedItem.type)
|
| 456 |
+
);
|
| 457 |
+
if (itemToUnsave) {
|
| 458 |
+
handleUnsaveItem(itemToUnsave.id);
|
| 459 |
+
}
|
| 460 |
+
} else {
|
| 461 |
+
// Save the item
|
| 462 |
+
onSave(selectedItem.content, selectedItem.type);
|
| 463 |
+
}
|
| 464 |
+
};
|
| 465 |
+
|
| 466 |
+
// Filter saved items based on search query
|
| 467 |
+
const filteredSavedItems = savedItems.filter(item => {
|
| 468 |
+
if (!searchQuery.trim()) return true;
|
| 469 |
+
const query = searchQuery.toLowerCase();
|
| 470 |
+
return (
|
| 471 |
+
item.title.toLowerCase().includes(query) ||
|
| 472 |
+
item.content.toLowerCase().includes(query) ||
|
| 473 |
+
item.type.toLowerCase().includes(query)
|
| 474 |
+
);
|
| 475 |
+
}).filter(item => item.workspaceId === currentWorkspaceId);
|
| 476 |
+
|
| 477 |
+
// Use native event listeners to prevent scroll propagation
|
| 478 |
+
useEffect(() => {
|
| 479 |
+
const container = scrollContainerRef.current;
|
| 480 |
+
if (!container) return;
|
| 481 |
+
|
| 482 |
+
const handleWheel = (e: WheelEvent) => {
|
| 483 |
+
// Always stop propagation to prevent scrolling other panels
|
| 484 |
+
e.stopPropagation();
|
| 485 |
+
e.stopImmediatePropagation();
|
| 486 |
+
|
| 487 |
+
// Only prevent default if we're at the boundaries
|
| 488 |
+
const { scrollTop, scrollHeight, clientHeight } = container;
|
| 489 |
+
const isScrollable = scrollHeight > clientHeight;
|
| 490 |
+
const isAtTop = scrollTop === 0;
|
| 491 |
+
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1;
|
| 492 |
+
|
| 493 |
+
// If scrolling up at top or down at bottom, prevent default to stop propagation
|
| 494 |
+
if (isScrollable && ((isAtTop && e.deltaY < 0) || (isAtBottom && e.deltaY > 0))) {
|
| 495 |
+
e.preventDefault();
|
| 496 |
+
}
|
| 497 |
+
};
|
| 498 |
+
|
| 499 |
+
container.addEventListener('wheel', handleWheel, { passive: false, capture: true });
|
| 500 |
+
|
| 501 |
+
return () => {
|
| 502 |
+
container.removeEventListener('wheel', handleWheel, { capture: true });
|
| 503 |
+
};
|
| 504 |
+
}, []);
|
| 505 |
+
|
| 506 |
+
// Sample courses - same as ChatArea
|
| 507 |
+
const defaultCourses = [
|
| 508 |
+
{ id: 'course1', name: 'Introduction to AI' },
|
| 509 |
+
{ id: 'course2', name: 'Machine Learning' },
|
| 510 |
+
{ id: 'course3', name: 'Data Structures' },
|
| 511 |
+
{ id: 'course4', name: 'Web Development' },
|
| 512 |
+
];
|
| 513 |
+
const coursesList = courses.length > 0 ? courses : defaultCourses;
|
| 514 |
+
|
| 515 |
+
// Get current workspace
|
| 516 |
+
const currentWorkspace = workspaces?.find(w => w.id === currentWorkspaceId);
|
| 517 |
+
|
| 518 |
+
// State for editable workspace title (for personal interest workspaces)
|
| 519 |
+
const [editableTitle, setEditableTitle] = useState('Untitled');
|
| 520 |
+
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
| 521 |
+
|
| 522 |
+
// Determine what to display based on workspace type
|
| 523 |
+
const getCourseDisplayInfo = () => {
|
| 524 |
+
if (!currentWorkspace) return null;
|
| 525 |
+
|
| 526 |
+
// Group workspace with course
|
| 527 |
+
if (currentWorkspace.type === 'group' && currentWorkspace.category === 'course') {
|
| 528 |
+
if (currentWorkspace.courseInfo) {
|
| 529 |
+
return {
|
| 530 |
+
type: 'course' as const,
|
| 531 |
+
name: currentWorkspace.courseInfo.name,
|
| 532 |
+
instructor: currentWorkspace.courseInfo.instructor,
|
| 533 |
+
teachingAssistant: currentWorkspace.courseInfo.teachingAssistant,
|
| 534 |
+
};
|
| 535 |
+
}
|
| 536 |
+
// Fallback if courseInfo is missing
|
| 537 |
+
return {
|
| 538 |
+
type: 'course' as const,
|
| 539 |
+
name: currentWorkspace.courseName || 'Unknown Course',
|
| 540 |
+
instructor: { name: 'Unknown', email: '' },
|
| 541 |
+
teachingAssistant: { name: 'Unknown', email: '' },
|
| 542 |
+
};
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
// Group workspace with personal interest
|
| 546 |
+
if (currentWorkspace.type === 'group' && currentWorkspace.category === 'personal') {
|
| 547 |
+
return {
|
| 548 |
+
type: 'personal' as const,
|
| 549 |
+
name: editableTitle,
|
| 550 |
+
members: currentWorkspace.members || [],
|
| 551 |
+
};
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
// Individual workspace (My Space)
|
| 555 |
+
if (currentWorkspace.type === 'individual') {
|
| 556 |
+
const saved = selectedCourse || localStorage.getItem('myspace_selected_course') || 'course1';
|
| 557 |
+
const courseInfo = availableCourses?.find(c => c.id === saved);
|
| 558 |
+
if (courseInfo) {
|
| 559 |
+
return {
|
| 560 |
+
type: 'course' as const,
|
| 561 |
+
name: courseInfo.name,
|
| 562 |
+
instructor: courseInfo.instructor,
|
| 563 |
+
teachingAssistant: courseInfo.teachingAssistant,
|
| 564 |
+
};
|
| 565 |
+
}
|
| 566 |
+
const course = coursesList.find(c => c.id === saved);
|
| 567 |
+
return {
|
| 568 |
+
type: 'course' as const,
|
| 569 |
+
name: course?.name || saved,
|
| 570 |
+
instructor: { name: 'Unknown', email: '' },
|
| 571 |
+
teachingAssistant: { name: 'Unknown', email: '' },
|
| 572 |
+
};
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
return null;
|
| 576 |
+
};
|
| 577 |
+
|
| 578 |
+
const courseDisplayInfo = getCourseDisplayInfo();
|
| 579 |
+
|
| 580 |
+
return (
|
| 581 |
+
<div
|
| 582 |
+
ref={scrollContainerRef}
|
| 583 |
+
className="flex-1 overflow-auto overscroll-contain flex flex-col"
|
| 584 |
+
style={{ overscrollBehavior: 'contain' }}
|
| 585 |
+
>
|
| 586 |
+
{/* Course Information Section */}
|
| 587 |
+
{isLoggedIn && courseDisplayInfo && (
|
| 588 |
+
<div className="p-4 border-b border-border flex-shrink-0">
|
| 589 |
+
{courseDisplayInfo.type === 'course' ? (
|
| 590 |
+
<>
|
| 591 |
+
<h3 className="text-base font-semibold mb-4">{courseDisplayInfo.name}</h3>
|
| 592 |
+
<div className="space-y-2 text-sm">
|
| 593 |
+
<div>
|
| 594 |
+
<span className="text-muted-foreground">Instructor: </span>
|
| 595 |
+
<a
|
| 596 |
+
href={`mailto:${courseDisplayInfo.instructor.email}`}
|
| 597 |
+
className="text-primary hover:underline"
|
| 598 |
+
>
|
| 599 |
+
{courseDisplayInfo.instructor.name}
|
| 600 |
+
</a>
|
| 601 |
+
</div>
|
| 602 |
+
<div>
|
| 603 |
+
<span className="text-muted-foreground">TA: </span>
|
| 604 |
+
<a
|
| 605 |
+
href={`mailto:${courseDisplayInfo.teachingAssistant.email}`}
|
| 606 |
+
className="text-primary hover:underline"
|
| 607 |
+
>
|
| 608 |
+
{courseDisplayInfo.teachingAssistant.name}
|
| 609 |
+
</a>
|
| 610 |
+
</div>
|
| 611 |
+
</div>
|
| 612 |
+
</>
|
| 613 |
+
) : (
|
| 614 |
+
<>
|
| 615 |
+
{/* Personal Interest Workspace - Editable Title */}
|
| 616 |
+
<div className="mb-4">
|
| 617 |
+
{isEditingTitle ? (
|
| 618 |
+
<Input
|
| 619 |
+
value={editableTitle}
|
| 620 |
+
onChange={(e) => setEditableTitle(e.target.value)}
|
| 621 |
+
onBlur={() => setIsEditingTitle(false)}
|
| 622 |
+
onKeyDown={(e) => {
|
| 623 |
+
if (e.key === 'Enter') {
|
| 624 |
+
setIsEditingTitle(false);
|
| 625 |
+
}
|
| 626 |
+
}}
|
| 627 |
+
autoFocus
|
| 628 |
+
className="text-base font-semibold"
|
| 629 |
+
/>
|
| 630 |
+
) : (
|
| 631 |
+
<h3
|
| 632 |
+
className="text-base font-semibold cursor-pointer hover:text-primary"
|
| 633 |
+
onClick={() => setIsEditingTitle(true)}
|
| 634 |
+
>
|
| 635 |
+
{editableTitle}
|
| 636 |
+
</h3>
|
| 637 |
+
)}
|
| 638 |
+
</div>
|
| 639 |
+
<div className="space-y-2 text-sm">
|
| 640 |
+
<div className="text-muted-foreground mb-2">Members:</div>
|
| 641 |
+
{courseDisplayInfo.members.map((member, idx) => (
|
| 642 |
+
<div key={member.id}>
|
| 643 |
+
<span className="text-muted-foreground">{idx === 0 ? 'Creator: ' : 'Member: '}</span>
|
| 644 |
+
<a
|
| 645 |
+
href={`mailto:${member.email}`}
|
| 646 |
+
className="text-primary hover:underline"
|
| 647 |
+
>
|
| 648 |
+
{member.name}
|
| 649 |
+
</a>
|
| 650 |
+
</div>
|
| 651 |
+
))}
|
| 652 |
+
</div>
|
| 653 |
+
</>
|
| 654 |
+
)}
|
| 655 |
+
</div>
|
| 656 |
+
)}
|
| 657 |
+
|
| 658 |
+
{/* Login Section - Only show when not logged in */}
|
| 659 |
+
{!isLoggedIn && (
|
| 660 |
+
<div className="p-4 border-b border-border flex-shrink-0">
|
| 661 |
+
<h3 className="text-base font-medium mb-4">Login</h3>
|
| 662 |
+
<Card className="p-4">
|
| 663 |
+
<div className="space-y-4">
|
| 664 |
+
<div className="flex flex-col items-center py-4">
|
| 665 |
+
<img
|
| 666 |
+
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"
|
| 667 |
+
alt="Student studying"
|
| 668 |
+
className="w-20 h-20 rounded-full object-cover mb-4"
|
| 669 |
+
/>
|
| 670 |
+
<h3 className="mb-2">Welcome to Clare!</h3>
|
| 671 |
+
<p className="text-sm text-muted-foreground text-center mb-4">
|
| 672 |
+
Log in to start your personalized learning journey
|
| 673 |
+
</p>
|
| 674 |
+
</div>
|
| 675 |
+
|
| 676 |
+
{!showLoginForm ? (
|
| 677 |
+
<Button onClick={() => setShowLoginForm(true)} className="w-full gap-2">
|
| 678 |
+
<LogIn className="h-4 w-4" />
|
| 679 |
+
Student Login
|
| 680 |
+
</Button>
|
| 681 |
+
) : (
|
| 682 |
+
<div className="space-y-3">
|
| 683 |
+
<div className="space-y-2">
|
| 684 |
+
<Label htmlFor="name">Name</Label>
|
| 685 |
+
<Input
|
| 686 |
+
id="name"
|
| 687 |
+
value={name}
|
| 688 |
+
onChange={(e) => setName(e.target.value)}
|
| 689 |
+
placeholder="Enter your name"
|
| 690 |
+
/>
|
| 691 |
+
</div>
|
| 692 |
+
<div className="space-y-2">
|
| 693 |
+
<Label htmlFor="email">Email / Student ID</Label>
|
| 694 |
+
<Input
|
| 695 |
+
id="email"
|
| 696 |
+
type="email"
|
| 697 |
+
value={email}
|
| 698 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 699 |
+
placeholder="Enter your email or ID"
|
| 700 |
+
/>
|
| 701 |
+
</div>
|
| 702 |
+
<div className="flex gap-2">
|
| 703 |
+
<Button onClick={handleLogin} className="flex-1">
|
| 704 |
+
Enter
|
| 705 |
+
</Button>
|
| 706 |
+
<Button
|
| 707 |
+
variant="outline"
|
| 708 |
+
onClick={() => setShowLoginForm(false)}
|
| 709 |
+
>
|
| 710 |
+
Cancel
|
| 711 |
+
</Button>
|
| 712 |
+
</div>
|
| 713 |
+
</div>
|
| 714 |
+
)}
|
| 715 |
+
</div>
|
| 716 |
+
</Card>
|
| 717 |
+
</div>
|
| 718 |
+
)}
|
| 719 |
+
|
| 720 |
+
{/* Group Members - Only show in group mode */}
|
| 721 |
+
{spaceType === 'group' && (
|
| 722 |
+
<div className="p-4 border-b border-border flex-shrink-0">
|
| 723 |
+
<GroupMembers members={groupMembers} />
|
| 724 |
+
</div>
|
| 725 |
+
)}
|
| 726 |
+
|
| 727 |
+
{/* Saved Chat Section - Above Saved Files */}
|
| 728 |
+
{isLoggedIn && (
|
| 729 |
+
<div className="border-b border-border p-4 flex-1 min-h-0 flex flex-col">
|
| 730 |
+
<h3 className="text-base font-medium mb-4 flex-shrink-0">Saved Chat</h3>
|
| 731 |
+
{savedChats.length === 0 ? (
|
| 732 |
+
<div className="text-sm text-muted-foreground text-center py-4 flex-shrink-0">
|
| 733 |
+
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
| 734 |
+
<p>No saved chats yet</p>
|
| 735 |
+
<p className="text-xs mt-1">Save conversations to view them here</p>
|
| 736 |
+
</div>
|
| 737 |
+
) : (
|
| 738 |
+
<div className="space-y-2 overflow-y-auto flex-1 min-h-0">
|
| 739 |
+
{savedChats.map((chat) => (
|
| 740 |
+
<div key={chat.id}>
|
| 741 |
+
<SavedChatItem
|
| 742 |
+
chat={chat}
|
| 743 |
+
onLoadChat={onLoadChat}
|
| 744 |
+
onDeleteSavedChat={onDeleteSavedChat}
|
| 745 |
+
onRenameSavedChat={onRenameSavedChat}
|
| 746 |
+
/>
|
| 747 |
+
</div>
|
| 748 |
+
))}
|
| 749 |
+
</div>
|
| 750 |
+
)}
|
| 751 |
+
</div>
|
| 752 |
+
)}
|
| 753 |
+
|
| 754 |
+
{/* Saved Item Dialog */}
|
| 755 |
+
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
| 756 |
+
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
|
| 757 |
+
<DialogHeader>
|
| 758 |
+
<DialogTitle>{selectedItem?.title}</DialogTitle>
|
| 759 |
+
<DialogDescription>
|
| 760 |
+
{selectedItem?.type === 'export' ? 'Export' : selectedItem?.type === 'quiz' ? 'Quiz' : 'Summary'} • {selectedItem?.timestamp.toLocaleDateString()}
|
| 761 |
+
</DialogDescription>
|
| 762 |
+
</DialogHeader>
|
| 763 |
+
<div className="flex flex-col flex-1 min-h-0 space-y-4 mt-4">
|
| 764 |
+
{selectedItem && (
|
| 765 |
+
<>
|
| 766 |
+
<div className="flex items-center justify-end gap-2 flex-shrink-0">
|
| 767 |
+
<Button
|
| 768 |
+
variant="outline"
|
| 769 |
+
size="sm"
|
| 770 |
+
disabled={isDownloading}
|
| 771 |
+
onClick={() => handleDownloadMd(selectedItem)}
|
| 772 |
+
title="Download as .md"
|
| 773 |
+
className="h-7 px-2 text-xs gap-1.5"
|
| 774 |
+
>
|
| 775 |
+
<Download className="h-3 w-3" />
|
| 776 |
+
.md
|
| 777 |
+
</Button>
|
| 778 |
+
<Button
|
| 779 |
+
variant="outline"
|
| 780 |
+
size="sm"
|
| 781 |
+
disabled={isDownloading}
|
| 782 |
+
onClick={() => handleDownloadDocx(selectedItem)}
|
| 783 |
+
title="Download as .docx"
|
| 784 |
+
className="h-7 px-2 text-xs gap-1.5"
|
| 785 |
+
>
|
| 786 |
+
<Download className="h-3 w-3" />
|
| 787 |
+
.docx
|
| 788 |
+
</Button>
|
| 789 |
+
{selectedItem.format === 'pdf' && (
|
| 790 |
+
<Button
|
| 791 |
+
variant="outline"
|
| 792 |
+
size="sm"
|
| 793 |
+
disabled={isDownloading}
|
| 794 |
+
onClick={() => handleDownloadPdf(selectedItem)}
|
| 795 |
+
title="Download as .pdf"
|
| 796 |
+
className="h-7 px-2 text-xs gap-1.5"
|
| 797 |
+
>
|
| 798 |
+
<Download className="h-3 w-3" />
|
| 799 |
+
.pdf
|
| 800 |
+
</Button>
|
| 801 |
+
)}
|
| 802 |
+
<Button
|
| 803 |
+
variant="outline"
|
| 804 |
+
size="sm"
|
| 805 |
+
onClick={() => handleCopy(selectedItem.content)}
|
| 806 |
+
disabled={isDownloading}
|
| 807 |
+
className="h-7 px-2 text-xs gap-1.5"
|
| 808 |
+
title="Copy"
|
| 809 |
+
>
|
| 810 |
+
<Copy className={`h-3 w-3 ${copied ? 'text-green-600' : ''}`} />
|
| 811 |
+
</Button>
|
| 812 |
+
<Button
|
| 813 |
+
variant="outline"
|
| 814 |
+
size="sm"
|
| 815 |
+
onClick={handleToggleSave}
|
| 816 |
+
disabled={isDownloading}
|
| 817 |
+
className={`h-7 px-2 text-xs gap-1.5 ${isItemSaved ? 'bg-red-50 dark:bg-red-950/20 border-red-300 dark:border-red-800' : ''}`}
|
| 818 |
+
title={isItemSaved ? 'Unsave' : 'Save for later'}
|
| 819 |
+
>
|
| 820 |
+
<Bookmark className={`h-3 w-3 ${isItemSaved ? 'fill-red-600 text-red-600' : ''}`} />
|
| 821 |
+
</Button>
|
| 822 |
+
</div>
|
| 823 |
+
<Separator className="flex-shrink-0" />
|
| 824 |
+
<div className="text-sm whitespace-pre-wrap text-foreground overflow-y-auto flex-1 min-h-0">
|
| 825 |
+
{selectedItem.content}
|
| 826 |
+
</div>
|
| 827 |
+
</>
|
| 828 |
+
)}
|
| 829 |
+
</div>
|
| 830 |
+
</DialogContent>
|
| 831 |
+
</Dialog>
|
| 832 |
+
</div>
|
| 833 |
+
);
|
| 834 |
+
}
|
web/src/components/LoginScreen.tsx
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 clareAvatar from '../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png';
|
| 7 |
+
import type { User } from '../App';
|
| 8 |
+
|
| 9 |
+
interface LoginScreenProps {
|
| 10 |
+
onLogin: (user: User) => void;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function LoginScreen({ onLogin }: LoginScreenProps) {
|
| 14 |
+
const [showForm, setShowForm] = useState(false);
|
| 15 |
+
const [name, setName] = useState('');
|
| 16 |
+
const [email, setEmail] = useState('');
|
| 17 |
+
|
| 18 |
+
const handleSubmit = (e: React.FormEvent) => {
|
| 19 |
+
e.preventDefault();
|
| 20 |
+
if (name.trim() && email.trim()) {
|
| 21 |
+
onLogin({ name: name.trim(), email: email.trim() });
|
| 22 |
+
}
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
| 27 |
+
<Card className="w-full max-w-md p-8">
|
| 28 |
+
<div className="flex flex-col items-center space-y-6">
|
| 29 |
+
{/* Clare Avatar */}
|
| 30 |
+
<div className="w-24 h-24 rounded-full overflow-hidden bg-white flex items-center justify-center">
|
| 31 |
+
<img src={clareAvatar} alt="Clare AI" className="w-full h-full object-cover" />
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
{/* Welcome Text */}
|
| 35 |
+
<div className="text-center space-y-2">
|
| 36 |
+
<h1 className="text-2xl">Welcome to Clare</h1>
|
| 37 |
+
<p className="text-sm text-muted-foreground">
|
| 38 |
+
Your AI teaching assistant for personalized learning
|
| 39 |
+
</p>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
{!showForm ? (
|
| 43 |
+
<Button
|
| 44 |
+
onClick={() => setShowForm(true)}
|
| 45 |
+
className="w-full"
|
| 46 |
+
size="lg"
|
| 47 |
+
>
|
| 48 |
+
Sign In
|
| 49 |
+
</Button>
|
| 50 |
+
) : (
|
| 51 |
+
<form onSubmit={handleSubmit} className="w-full space-y-4">
|
| 52 |
+
<div className="space-y-2">
|
| 53 |
+
<Label htmlFor="login-name">Name</Label>
|
| 54 |
+
<Input
|
| 55 |
+
id="login-name"
|
| 56 |
+
value={name}
|
| 57 |
+
onChange={(e) => setName(e.target.value)}
|
| 58 |
+
placeholder="Enter your name"
|
| 59 |
+
required
|
| 60 |
+
/>
|
| 61 |
+
</div>
|
| 62 |
+
<div className="space-y-2">
|
| 63 |
+
<Label htmlFor="login-email">Email / Student ID</Label>
|
| 64 |
+
<Input
|
| 65 |
+
id="login-email"
|
| 66 |
+
type="email"
|
| 67 |
+
value={email}
|
| 68 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 69 |
+
placeholder="Enter your email or ID"
|
| 70 |
+
required
|
| 71 |
+
/>
|
| 72 |
+
</div>
|
| 73 |
+
<div className="flex gap-2">
|
| 74 |
+
<Button type="submit" className="flex-1">
|
| 75 |
+
Enter
|
| 76 |
+
</Button>
|
| 77 |
+
<Button
|
| 78 |
+
type="button"
|
| 79 |
+
variant="outline"
|
| 80 |
+
onClick={() => setShowForm(false)}
|
| 81 |
+
>
|
| 82 |
+
Cancel
|
| 83 |
+
</Button>
|
| 84 |
+
</div>
|
| 85 |
+
</form>
|
| 86 |
+
)}
|
| 87 |
+
</div>
|
| 88 |
+
</Card>
|
| 89 |
+
</div>
|
| 90 |
+
);
|
| 91 |
+
}
|
web/src/components/Message.tsx
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
X
|
| 12 |
+
} from 'lucide-react';
|
| 13 |
+
import { Badge } from './ui/badge';
|
| 14 |
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
| 15 |
+
import { Textarea } from './ui/textarea';
|
| 16 |
+
import type { Message as MessageType } from '../App';
|
| 17 |
+
import { toast } from 'sonner';
|
| 18 |
+
import clareAvatar from '../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png';
|
| 19 |
+
|
| 20 |
+
interface MessageProps {
|
| 21 |
+
key?: React.Key;
|
| 22 |
+
message: MessageType;
|
| 23 |
+
showSenderInfo?: boolean; // For group chat mode
|
| 24 |
+
isFirstGreeting?: boolean; // Indicates if this is the first greeting message
|
| 25 |
+
showNextButton?: boolean; // For quiz mode
|
| 26 |
+
onNextQuestion?: () => void; // For quiz mode
|
| 27 |
+
chatMode?: 'ask' | 'review' | 'quiz'; // Current chat mode
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// 反馈标签选项
|
| 31 |
+
const FEEDBACK_TAGS = {
|
| 32 |
+
'not-helpful': [
|
| 33 |
+
'Code was incorrect',
|
| 34 |
+
"Shouldn't have used Memory",
|
| 35 |
+
"Don't like the personality",
|
| 36 |
+
"Don't like the style",
|
| 37 |
+
'Not factually correct',
|
| 38 |
+
],
|
| 39 |
+
'helpful': [
|
| 40 |
+
'Accurate and helpful',
|
| 41 |
+
'Clear explanation',
|
| 42 |
+
'Good examples',
|
| 43 |
+
'Solved my problem',
|
| 44 |
+
'Well structured',
|
| 45 |
+
],
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
export function Message({
|
| 49 |
+
message,
|
| 50 |
+
showSenderInfo = false,
|
| 51 |
+
isFirstGreeting = false,
|
| 52 |
+
showNextButton = false,
|
| 53 |
+
onNextQuestion,
|
| 54 |
+
chatMode = 'ask',
|
| 55 |
+
}: MessageProps) {
|
| 56 |
+
const [feedback, setFeedback] = useState<'helpful' | 'not-helpful' | null>(null);
|
| 57 |
+
const [copied, setCopied] = useState(false);
|
| 58 |
+
const [referencesOpen, setReferencesOpen] = useState(false);
|
| 59 |
+
const [showFeedbackArea, setShowFeedbackArea] = useState(false);
|
| 60 |
+
const [feedbackType, setFeedbackType] = useState<'helpful' | 'not-helpful' | null>(null);
|
| 61 |
+
const [feedbackText, setFeedbackText] = useState('');
|
| 62 |
+
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
| 63 |
+
const [isHovered, setIsHovered] = useState(false);
|
| 64 |
+
const [nextButtonClicked, setNextButtonClicked] = useState(false);
|
| 65 |
+
|
| 66 |
+
const isUser = message.role === 'user';
|
| 67 |
+
// For user messages: always show. For assistant messages: always show except first greeting (or review-1, quiz-1)
|
| 68 |
+
const isWelcomeMessage = isFirstGreeting || message.id === 'review-1' || message.id === 'quiz-1';
|
| 69 |
+
const shouldShowActions = isUser ? true : !isWelcomeMessage;
|
| 70 |
+
|
| 71 |
+
const handleCopy = async () => {
|
| 72 |
+
await navigator.clipboard.writeText(message.content);
|
| 73 |
+
setCopied(true);
|
| 74 |
+
toast.success('Message copied to clipboard');
|
| 75 |
+
setTimeout(() => setCopied(false), 2000);
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
const handleFeedbackClick = (type: 'helpful' | 'not-helpful') => {
|
| 79 |
+
if (feedback === type) {
|
| 80 |
+
// 如果点击的是已选中的反馈,则关闭反馈区域
|
| 81 |
+
setFeedback(null);
|
| 82 |
+
setShowFeedbackArea(false);
|
| 83 |
+
setFeedbackType(null);
|
| 84 |
+
setFeedbackText('');
|
| 85 |
+
setSelectedTags([]);
|
| 86 |
+
} else {
|
| 87 |
+
// 立即设置反馈状态(按钮会立即显示颜色)
|
| 88 |
+
setFeedback(type);
|
| 89 |
+
// 打开反馈区域
|
| 90 |
+
setFeedbackType(type);
|
| 91 |
+
setShowFeedbackArea(true);
|
| 92 |
+
}
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
const handleFeedbackClose = () => {
|
| 96 |
+
setShowFeedbackArea(false);
|
| 97 |
+
setFeedbackType(null);
|
| 98 |
+
setFeedbackText('');
|
| 99 |
+
setSelectedTags([]);
|
| 100 |
+
// 注意:不重置 feedback,保持点赞/点踩的状态
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
const handleTagToggle = (tag: string) => {
|
| 104 |
+
setSelectedTags(prev =>
|
| 105 |
+
prev.includes(tag)
|
| 106 |
+
? prev.filter(t => t !== tag)
|
| 107 |
+
: [...prev, tag]
|
| 108 |
+
);
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
const handleFeedbackSubmit = () => {
|
| 112 |
+
// 这里可以发送反馈到后端
|
| 113 |
+
const feedbackData = {
|
| 114 |
+
type: feedbackType,
|
| 115 |
+
tags: selectedTags,
|
| 116 |
+
text: feedbackText,
|
| 117 |
+
messageId: message.id || message.content.substring(0, 50),
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
console.log('Feedback submitted:', feedbackData);
|
| 121 |
+
toast.success('感谢您的反馈!');
|
| 122 |
+
handleFeedbackClose();
|
| 123 |
+
// 保持反馈状态(点赞/点踩)
|
| 124 |
+
};
|
| 125 |
+
|
| 126 |
+
return (
|
| 127 |
+
<div className={`flex gap-2 ${isUser && !showSenderInfo ? 'justify-end' : 'justify-start'} px-4`}>
|
| 128 |
+
{/* Avatar */}
|
| 129 |
+
{showSenderInfo && message.sender ? (
|
| 130 |
+
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
|
| 131 |
+
message.sender.isAI
|
| 132 |
+
? 'overflow-hidden bg-white'
|
| 133 |
+
: 'overflow-hidden bg-white'
|
| 134 |
+
}`}>
|
| 135 |
+
{message.sender.isAI ? (
|
| 136 |
+
<img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
|
| 137 |
+
) : (
|
| 138 |
+
<img
|
| 139 |
+
src={
|
| 140 |
+
message.sender.avatar ||
|
| 141 |
+
`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(message.sender.email || message.sender.name)}`
|
| 142 |
+
}
|
| 143 |
+
alt={message.sender.name}
|
| 144 |
+
className="w-full h-full object-cover"
|
| 145 |
+
/>
|
| 146 |
+
)}
|
| 147 |
+
</div>
|
| 148 |
+
) : !isUser ? (
|
| 149 |
+
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
|
| 150 |
+
<img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
|
| 151 |
+
</div>
|
| 152 |
+
) : null}
|
| 153 |
+
|
| 154 |
+
<div
|
| 155 |
+
className={`group flex flex-col gap-2 ${isUser && !showSenderInfo ? 'items-end' : 'items-start'}`}
|
| 156 |
+
style={{ maxWidth: 'min(770px, calc(100% - 2rem))' }}
|
| 157 |
+
onMouseEnter={() => setIsHovered(true)}
|
| 158 |
+
onMouseLeave={() => setIsHovered(false)}
|
| 159 |
+
>
|
| 160 |
+
{/* Sender name in group chat */}
|
| 161 |
+
{showSenderInfo && message.sender && (
|
| 162 |
+
<div className="flex items-center gap-2 px-1">
|
| 163 |
+
<span className="text-xs">{message.sender.name}</span>
|
| 164 |
+
{message.sender.isAI && (
|
| 165 |
+
<Badge variant="secondary" className="text-xs h-4 px-1">AI</Badge>
|
| 166 |
+
)}
|
| 167 |
+
</div>
|
| 168 |
+
)}
|
| 169 |
+
|
| 170 |
+
<div
|
| 171 |
+
className={`
|
| 172 |
+
rounded-2xl px-4 py-3
|
| 173 |
+
${isUser && !showSenderInfo
|
| 174 |
+
? 'bg-primary text-primary-foreground'
|
| 175 |
+
: 'bg-muted'
|
| 176 |
+
}
|
| 177 |
+
`}
|
| 178 |
+
>
|
| 179 |
+
<p className="whitespace-pre-wrap text-base">{message.content}</p>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
{/* Next Question Button for Quiz Mode */}
|
| 183 |
+
{!isUser && showNextButton && !nextButtonClicked && chatMode === 'quiz' && onNextQuestion && (
|
| 184 |
+
<div className="mt-2">
|
| 185 |
+
<Button
|
| 186 |
+
onClick={() => {
|
| 187 |
+
setNextButtonClicked(true);
|
| 188 |
+
onNextQuestion();
|
| 189 |
+
}}
|
| 190 |
+
className="bg-primary hover:bg-primary/90"
|
| 191 |
+
>
|
| 192 |
+
Next Question
|
| 193 |
+
</Button>
|
| 194 |
+
</div>
|
| 195 |
+
)}
|
| 196 |
+
|
| 197 |
+
{/* References */}
|
| 198 |
+
{message.references && message.references.length > 0 && (
|
| 199 |
+
<Collapsible open={referencesOpen} onOpenChange={setReferencesOpen}>
|
| 200 |
+
<CollapsibleTrigger asChild>
|
| 201 |
+
<Button variant="ghost" size="sm" className="gap-1 h-7 text-xs">
|
| 202 |
+
{referencesOpen ? (
|
| 203 |
+
<ChevronUp className="h-3 w-3" />
|
| 204 |
+
) : (
|
| 205 |
+
<ChevronDown className="h-3 w-3" />
|
| 206 |
+
)}
|
| 207 |
+
{message.references.length} {message.references.length === 1 ? 'reference' : 'references'}
|
| 208 |
+
</Button>
|
| 209 |
+
</CollapsibleTrigger>
|
| 210 |
+
<CollapsibleContent className="space-y-1 mt-1">
|
| 211 |
+
{message.references.map((ref, index) => (
|
| 212 |
+
<Badge key={index} variant="outline" className="text-xs">
|
| 213 |
+
{ref}
|
| 214 |
+
</Badge>
|
| 215 |
+
))}
|
| 216 |
+
</CollapsibleContent>
|
| 217 |
+
</Collapsible>
|
| 218 |
+
)}
|
| 219 |
+
|
| 220 |
+
{/* Message Actions */}
|
| 221 |
+
{shouldShowActions && (
|
| 222 |
+
<div className="flex items-center gap-1">
|
| 223 |
+
<Button
|
| 224 |
+
variant="ghost"
|
| 225 |
+
size="icon"
|
| 226 |
+
className={`h-7 w-7 rounded-md transition-all hover:bg-accent hover:scale-110 active:scale-95 ${
|
| 227 |
+
copied ? 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400' : ''
|
| 228 |
+
}`}
|
| 229 |
+
onClick={handleCopy}
|
| 230 |
+
title="Copy"
|
| 231 |
+
>
|
| 232 |
+
{copied ? (
|
| 233 |
+
<Check className="h-4 w-4" />
|
| 234 |
+
) : (
|
| 235 |
+
<Copy className="h-4 w-4" />
|
| 236 |
+
)}
|
| 237 |
+
</Button>
|
| 238 |
+
|
| 239 |
+
{!isUser && (
|
| 240 |
+
<>
|
| 241 |
+
<Button
|
| 242 |
+
variant="ghost"
|
| 243 |
+
size="icon"
|
| 244 |
+
className={`h-7 w-7 rounded-md transition-all hover:bg-accent hover:scale-110 active:scale-95 ${
|
| 245 |
+
feedback === 'helpful' ? 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400' : ''
|
| 246 |
+
}`}
|
| 247 |
+
onClick={() => handleFeedbackClick('helpful')}
|
| 248 |
+
title="Helpful"
|
| 249 |
+
>
|
| 250 |
+
<ThumbsUp className="h-4 w-4" />
|
| 251 |
+
</Button>
|
| 252 |
+
<Button
|
| 253 |
+
variant="ghost"
|
| 254 |
+
size="icon"
|
| 255 |
+
className={`h-7 w-7 rounded-md transition-all hover:bg-accent hover:scale-110 active:scale-95 ${
|
| 256 |
+
feedback === 'not-helpful' ? 'bg-red-100 text-red-600 dark:bg-red-900/20 dark:text-red-400' : ''
|
| 257 |
+
}`}
|
| 258 |
+
onClick={() => handleFeedbackClick('not-helpful')}
|
| 259 |
+
title="Not helpful"
|
| 260 |
+
>
|
| 261 |
+
<ThumbsDown className="h-4 w-4" />
|
| 262 |
+
</Button>
|
| 263 |
+
</>
|
| 264 |
+
)}
|
| 265 |
+
</div>
|
| 266 |
+
)}
|
| 267 |
+
|
| 268 |
+
{/* Feedback Area - 展开在消息下方 */}
|
| 269 |
+
{!isUser && showFeedbackArea && feedbackType && (
|
| 270 |
+
<div className="w-full mt-2 bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
| 271 |
+
<div className="flex items-start justify-between mb-4">
|
| 272 |
+
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
| 273 |
+
Tell us more:
|
| 274 |
+
</h4>
|
| 275 |
+
<Button
|
| 276 |
+
variant="ghost"
|
| 277 |
+
size="sm"
|
| 278 |
+
className="h-6 w-6 p-0"
|
| 279 |
+
onClick={handleFeedbackClose}
|
| 280 |
+
>
|
| 281 |
+
<X className="h-4 w-4" />
|
| 282 |
+
</Button>
|
| 283 |
+
</div>
|
| 284 |
+
|
| 285 |
+
{/* 标签按钮 */}
|
| 286 |
+
<div className="flex flex-wrap gap-2 mb-4">
|
| 287 |
+
{FEEDBACK_TAGS[feedbackType].map((tag) => (
|
| 288 |
+
<Button
|
| 289 |
+
key={tag}
|
| 290 |
+
variant={selectedTags.includes(tag) ? 'default' : 'outline'}
|
| 291 |
+
size="sm"
|
| 292 |
+
className="h-7 text-xs"
|
| 293 |
+
onClick={() => handleTagToggle(tag)}
|
| 294 |
+
>
|
| 295 |
+
{tag}
|
| 296 |
+
</Button>
|
| 297 |
+
))}
|
| 298 |
+
</div>
|
| 299 |
+
|
| 300 |
+
{/* 文本输入框 */}
|
| 301 |
+
<Textarea
|
| 302 |
+
className="min-h-[60px] mb-4 bg-gray-100/50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600"
|
| 303 |
+
value={feedbackText}
|
| 304 |
+
onChange={(e) => setFeedbackText(e.target.value)}
|
| 305 |
+
placeholder="Additional feedback (optional)..."
|
| 306 |
+
/>
|
| 307 |
+
|
| 308 |
+
{/* 提交按钮 */}
|
| 309 |
+
<div className="flex justify-end gap-2">
|
| 310 |
+
<Button
|
| 311 |
+
variant="outline"
|
| 312 |
+
size="sm"
|
| 313 |
+
onClick={handleFeedbackClose}
|
| 314 |
+
>
|
| 315 |
+
Cancel
|
| 316 |
+
</Button>
|
| 317 |
+
<Button
|
| 318 |
+
size="sm"
|
| 319 |
+
onClick={handleFeedbackSubmit}
|
| 320 |
+
disabled={selectedTags.length === 0 && !feedbackText.trim()}
|
| 321 |
+
>
|
| 322 |
+
Submit
|
| 323 |
+
</Button>
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
)}
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
+
{isUser && !showSenderInfo && (
|
| 330 |
+
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
|
| 331 |
+
{message.sender ? (
|
| 332 |
+
<img
|
| 333 |
+
src={
|
| 334 |
+
message.sender.avatar ||
|
| 335 |
+
`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(message.sender.email || message.sender.name)}`
|
| 336 |
+
}
|
| 337 |
+
alt={message.sender.name}
|
| 338 |
+
className="w-full h-full object-cover"
|
| 339 |
+
/>
|
| 340 |
+
) : (
|
| 341 |
+
<span className="text-base">👤</span>
|
| 342 |
+
)}
|
| 343 |
+
</div>
|
| 344 |
+
)}
|
| 345 |
+
</div>
|
| 346 |
+
);
|
| 347 |
+
}
|
web/src/components/Onboarding.tsx
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef } from 'react';
|
| 2 |
+
import { Button } from './ui/button';
|
| 3 |
+
import { Input } from './ui/input';
|
| 4 |
+
import { Label } from './ui/label';
|
| 5 |
+
import { Textarea } from './ui/textarea';
|
| 6 |
+
import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
|
| 7 |
+
import type { User as UserType } from '../App';
|
| 8 |
+
import { toast } from 'sonner';
|
| 9 |
+
import {
|
| 10 |
+
Select,
|
| 11 |
+
SelectContent,
|
| 12 |
+
SelectItem,
|
| 13 |
+
SelectTrigger,
|
| 14 |
+
SelectValue,
|
| 15 |
+
} from './ui/select';
|
| 16 |
+
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
| 17 |
+
|
| 18 |
+
interface OnboardingProps {
|
| 19 |
+
user: UserType;
|
| 20 |
+
onComplete: (user: UserType) => void;
|
| 21 |
+
onSkip: () => void;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const TOTAL_STEPS = 5;
|
| 25 |
+
|
| 26 |
+
export function Onboarding({ user, onComplete, onSkip }: OnboardingProps) {
|
| 27 |
+
const [currentStep, setCurrentStep] = useState(1);
|
| 28 |
+
const [name, setName] = useState(user.name);
|
| 29 |
+
const [email, setEmail] = useState(user.email);
|
| 30 |
+
const [studentId, setStudentId] = useState('');
|
| 31 |
+
const [department, setDepartment] = useState('');
|
| 32 |
+
const [year, setYear] = useState('');
|
| 33 |
+
const [major, setMajor] = useState('');
|
| 34 |
+
const [bio, setBio] = useState('');
|
| 35 |
+
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
| 36 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 37 |
+
|
| 38 |
+
const handlePhotoSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 39 |
+
const file = e.target.files?.[0];
|
| 40 |
+
if (file) {
|
| 41 |
+
// Validate file type
|
| 42 |
+
if (!file.type.startsWith('image/')) {
|
| 43 |
+
toast.error('Please select an image file');
|
| 44 |
+
return;
|
| 45 |
+
}
|
| 46 |
+
// Validate file size (2MB)
|
| 47 |
+
if (file.size > 2 * 1024 * 1024) {
|
| 48 |
+
toast.error('File size must be less than 2MB');
|
| 49 |
+
return;
|
| 50 |
+
}
|
| 51 |
+
// Create preview
|
| 52 |
+
const reader = new FileReader();
|
| 53 |
+
reader.onload = (e) => {
|
| 54 |
+
setPhotoPreview(e.target?.result as string);
|
| 55 |
+
};
|
| 56 |
+
reader.readAsDataURL(file);
|
| 57 |
+
}
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
const handleChangePhotoClick = () => {
|
| 61 |
+
fileInputRef.current?.click();
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
const handleNext = () => {
|
| 65 |
+
if (currentStep < TOTAL_STEPS) {
|
| 66 |
+
setCurrentStep(currentStep + 1);
|
| 67 |
+
} else {
|
| 68 |
+
handleComplete();
|
| 69 |
+
}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const handlePrevious = () => {
|
| 73 |
+
if (currentStep > 1) {
|
| 74 |
+
setCurrentStep(currentStep - 1);
|
| 75 |
+
}
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
const handleSkip = () => {
|
| 79 |
+
// Skip all remaining steps and complete onboarding
|
| 80 |
+
onSkip();
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
const handleComplete = () => {
|
| 84 |
+
if (name.trim() && email.trim()) {
|
| 85 |
+
onComplete({ name: name.trim(), email: email.trim() });
|
| 86 |
+
toast.success('Profile setup completed!');
|
| 87 |
+
} else {
|
| 88 |
+
toast.error('Please fill in all required fields');
|
| 89 |
+
}
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
const renderStepContent = () => {
|
| 93 |
+
switch (currentStep) {
|
| 94 |
+
case 1:
|
| 95 |
+
return (
|
| 96 |
+
<div className="space-y-4">
|
| 97 |
+
<h3 className="text-lg font-medium">Basic Information</h3>
|
| 98 |
+
<p className="text-sm text-muted-foreground">
|
| 99 |
+
Let's start with your basic information
|
| 100 |
+
</p>
|
| 101 |
+
<div className="space-y-4">
|
| 102 |
+
<div className="space-y-2">
|
| 103 |
+
<Label htmlFor="onboarding-name">Full Name *</Label>
|
| 104 |
+
<Input
|
| 105 |
+
id="onboarding-name"
|
| 106 |
+
value={name}
|
| 107 |
+
onChange={(e) => setName(e.target.value)}
|
| 108 |
+
placeholder="Enter your full name"
|
| 109 |
+
/>
|
| 110 |
+
</div>
|
| 111 |
+
<div className="space-y-2">
|
| 112 |
+
<Label htmlFor="onboarding-email">Email *</Label>
|
| 113 |
+
<Input
|
| 114 |
+
id="onboarding-email"
|
| 115 |
+
type="email"
|
| 116 |
+
value={email}
|
| 117 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 118 |
+
placeholder="Enter your email"
|
| 119 |
+
/>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
);
|
| 124 |
+
case 2:
|
| 125 |
+
return (
|
| 126 |
+
<div className="space-y-4">
|
| 127 |
+
<h3 className="text-lg font-medium">Academic Background</h3>
|
| 128 |
+
<p className="text-sm text-muted-foreground">
|
| 129 |
+
Tell us about your academic information
|
| 130 |
+
</p>
|
| 131 |
+
<div className="space-y-4">
|
| 132 |
+
<div className="space-y-2">
|
| 133 |
+
<Label htmlFor="onboarding-student-id">Student ID</Label>
|
| 134 |
+
<Input
|
| 135 |
+
id="onboarding-student-id"
|
| 136 |
+
value={studentId}
|
| 137 |
+
onChange={(e) => setStudentId(e.target.value)}
|
| 138 |
+
placeholder="Enter your student ID"
|
| 139 |
+
/>
|
| 140 |
+
</div>
|
| 141 |
+
<div className="space-y-2">
|
| 142 |
+
<Label htmlFor="onboarding-department">Department</Label>
|
| 143 |
+
<Input
|
| 144 |
+
id="onboarding-department"
|
| 145 |
+
value={department}
|
| 146 |
+
onChange={(e) => setDepartment(e.target.value)}
|
| 147 |
+
placeholder="Enter your department"
|
| 148 |
+
/>
|
| 149 |
+
</div>
|
| 150 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 151 |
+
<div className="space-y-2">
|
| 152 |
+
<Label htmlFor="onboarding-year">Year Level</Label>
|
| 153 |
+
<Select value={year} onValueChange={setYear}>
|
| 154 |
+
<SelectTrigger id="onboarding-year">
|
| 155 |
+
<SelectValue placeholder="Select year level" />
|
| 156 |
+
</SelectTrigger>
|
| 157 |
+
<SelectContent>
|
| 158 |
+
<SelectItem value="1st Year">1st Year</SelectItem>
|
| 159 |
+
<SelectItem value="2nd Year">2nd Year</SelectItem>
|
| 160 |
+
<SelectItem value="3rd Year">3rd Year</SelectItem>
|
| 161 |
+
<SelectItem value="4th Year">4th Year</SelectItem>
|
| 162 |
+
<SelectItem value="Graduate">Graduate</SelectItem>
|
| 163 |
+
</SelectContent>
|
| 164 |
+
</Select>
|
| 165 |
+
</div>
|
| 166 |
+
<div className="space-y-2">
|
| 167 |
+
<Label htmlFor="onboarding-major">Major</Label>
|
| 168 |
+
<Input
|
| 169 |
+
id="onboarding-major"
|
| 170 |
+
value={major}
|
| 171 |
+
onChange={(e) => setMajor(e.target.value)}
|
| 172 |
+
placeholder="Enter your major"
|
| 173 |
+
/>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
);
|
| 179 |
+
case 3:
|
| 180 |
+
return (
|
| 181 |
+
<div className="space-y-4">
|
| 182 |
+
<h3 className="text-lg font-medium">About You</h3>
|
| 183 |
+
<p className="text-sm text-muted-foreground">
|
| 184 |
+
Share a brief introduction about yourself
|
| 185 |
+
</p>
|
| 186 |
+
<div className="space-y-2">
|
| 187 |
+
<Label htmlFor="onboarding-bio">Bio</Label>
|
| 188 |
+
<Textarea
|
| 189 |
+
id="onboarding-bio"
|
| 190 |
+
value={bio}
|
| 191 |
+
onChange={(e) => setBio(e.target.value)}
|
| 192 |
+
placeholder="Tell us about yourself..."
|
| 193 |
+
className="min-h-[120px] resize-none"
|
| 194 |
+
/>
|
| 195 |
+
<p className="text-xs text-muted-foreground">
|
| 196 |
+
Brief description for your profile. Max 200 characters.
|
| 197 |
+
</p>
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
);
|
| 201 |
+
case 4:
|
| 202 |
+
return (
|
| 203 |
+
<div className="space-y-4">
|
| 204 |
+
<h3 className="text-lg font-medium">Learning Preferences</h3>
|
| 205 |
+
<p className="text-sm text-muted-foreground">
|
| 206 |
+
Help us personalize your learning experience
|
| 207 |
+
</p>
|
| 208 |
+
<div className="space-y-4">
|
| 209 |
+
<div className="space-y-2">
|
| 210 |
+
<Label htmlFor="onboarding-learning-style">Preferred Learning Style</Label>
|
| 211 |
+
<Select defaultValue="visual">
|
| 212 |
+
<SelectTrigger id="onboarding-learning-style">
|
| 213 |
+
<SelectValue />
|
| 214 |
+
</SelectTrigger>
|
| 215 |
+
<SelectContent>
|
| 216 |
+
<SelectItem value="visual">Visual</SelectItem>
|
| 217 |
+
<SelectItem value="auditory">Auditory</SelectItem>
|
| 218 |
+
<SelectItem value="reading">Reading/Writing</SelectItem>
|
| 219 |
+
<SelectItem value="kinesthetic">Kinesthetic</SelectItem>
|
| 220 |
+
</SelectContent>
|
| 221 |
+
</Select>
|
| 222 |
+
</div>
|
| 223 |
+
<div className="space-y-2">
|
| 224 |
+
<Label htmlFor="onboarding-pace">Learning Pace</Label>
|
| 225 |
+
<Select defaultValue="moderate">
|
| 226 |
+
<SelectTrigger id="onboarding-pace">
|
| 227 |
+
<SelectValue />
|
| 228 |
+
</SelectTrigger>
|
| 229 |
+
<SelectContent>
|
| 230 |
+
<SelectItem value="slow">Slow & Steady</SelectItem>
|
| 231 |
+
<SelectItem value="moderate">Moderate</SelectItem>
|
| 232 |
+
<SelectItem value="fast">Fast-paced</SelectItem>
|
| 233 |
+
</SelectContent>
|
| 234 |
+
</Select>
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
);
|
| 239 |
+
case 5:
|
| 240 |
+
return (
|
| 241 |
+
<div className="space-y-4">
|
| 242 |
+
<h3 className="text-lg font-medium">Profile Picture</h3>
|
| 243 |
+
<p className="text-sm text-muted-foreground">
|
| 244 |
+
Upload a photo to personalize your profile (optional)
|
| 245 |
+
</p>
|
| 246 |
+
<div className="flex items-center gap-4">
|
| 247 |
+
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center text-white text-3xl overflow-hidden">
|
| 248 |
+
{photoPreview ? (
|
| 249 |
+
<img src={photoPreview} alt="Profile" className="w-full h-full object-cover" />
|
| 250 |
+
) : (
|
| 251 |
+
name.charAt(0).toUpperCase()
|
| 252 |
+
)}
|
| 253 |
+
</div>
|
| 254 |
+
<div>
|
| 255 |
+
<input
|
| 256 |
+
ref={fileInputRef}
|
| 257 |
+
type="file"
|
| 258 |
+
accept="image/jpeg,image/png,image/gif,image/webp"
|
| 259 |
+
onChange={handlePhotoSelect}
|
| 260 |
+
className="hidden"
|
| 261 |
+
/>
|
| 262 |
+
<Button
|
| 263 |
+
variant="outline"
|
| 264 |
+
size="sm"
|
| 265 |
+
onClick={handleChangePhotoClick}
|
| 266 |
+
>
|
| 267 |
+
Change Photo
|
| 268 |
+
</Button>
|
| 269 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 270 |
+
JPG, PNG or GIF. Max size 2MB
|
| 271 |
+
</p>
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
);
|
| 276 |
+
default:
|
| 277 |
+
return null;
|
| 278 |
+
}
|
| 279 |
+
};
|
| 280 |
+
|
| 281 |
+
return (
|
| 282 |
+
<Dialog open onOpenChange={(open) => { if (!open) onSkip(); }}>
|
| 283 |
+
<DialogContent
|
| 284 |
+
className="sm:max-w-lg p-0 gap-0 max-h-[90vh] overflow-hidden"
|
| 285 |
+
style={{ zIndex: 1001 }}
|
| 286 |
+
overlayClassName="!inset-0 !z-[99]"
|
| 287 |
+
overlayStyle={{
|
| 288 |
+
top: 0,
|
| 289 |
+
left: 0,
|
| 290 |
+
right: 0,
|
| 291 |
+
bottom: 0,
|
| 292 |
+
zIndex: 99,
|
| 293 |
+
position: 'fixed'
|
| 294 |
+
}}
|
| 295 |
+
>
|
| 296 |
+
<div className="flex flex-col max-h-[90vh]">
|
| 297 |
+
{/* Header */}
|
| 298 |
+
<div className="border-b border-border p-4 flex items-center justify-between flex-shrink-0">
|
| 299 |
+
<div className="flex-1">
|
| 300 |
+
<DialogTitle className="text-xl font-medium">Welcome! Let's set up your profile</DialogTitle>
|
| 301 |
+
<p className="text-sm text-muted-foreground mt-1">
|
| 302 |
+
Step {currentStep} of {TOTAL_STEPS}
|
| 303 |
+
</p>
|
| 304 |
+
</div>
|
| 305 |
+
{/* Progress indicator */}
|
| 306 |
+
<div className="flex gap-1">
|
| 307 |
+
{Array.from({ length: TOTAL_STEPS }).map((_, index) => (
|
| 308 |
+
<div
|
| 309 |
+
key={index}
|
| 310 |
+
className={`h-2 w-2 rounded-full transition-colors ${
|
| 311 |
+
index + 1 <= currentStep
|
| 312 |
+
? 'bg-primary'
|
| 313 |
+
: 'bg-muted'
|
| 314 |
+
}`}
|
| 315 |
+
/>
|
| 316 |
+
))}
|
| 317 |
+
</div>
|
| 318 |
+
</div>
|
| 319 |
+
|
| 320 |
+
{/* Content */}
|
| 321 |
+
<div className="p-6 overflow-y-auto flex-1">
|
| 322 |
+
{renderStepContent()}
|
| 323 |
+
</div>
|
| 324 |
+
|
| 325 |
+
{/* Footer */}
|
| 326 |
+
<div className="border-t border-border p-4 flex justify-between gap-2 flex-shrink-0">
|
| 327 |
+
<div className="flex gap-2">
|
| 328 |
+
{currentStep > 1 && (
|
| 329 |
+
<Button variant="outline" onClick={handlePrevious}>
|
| 330 |
+
<ChevronLeft className="h-4 w-4 mr-1" />
|
| 331 |
+
Previous
|
| 332 |
+
</Button>
|
| 333 |
+
)}
|
| 334 |
+
</div>
|
| 335 |
+
<div className="flex gap-2">
|
| 336 |
+
<Button variant="outline" onClick={handleSkip}>
|
| 337 |
+
Skip all
|
| 338 |
+
</Button>
|
| 339 |
+
<Button onClick={handleNext}>
|
| 340 |
+
{currentStep === TOTAL_STEPS ? 'Complete' : 'Next Step'}
|
| 341 |
+
{currentStep < TOTAL_STEPS && <ChevronRight className="h-4 w-4 ml-1" />}
|
| 342 |
+
</Button>
|
| 343 |
+
</div>
|
| 344 |
+
</div>
|
| 345 |
+
</div>
|
| 346 |
+
</DialogContent>
|
| 347 |
+
</Dialog>
|
| 348 |
+
);
|
| 349 |
+
}
|
| 350 |
+
|
web/src/components/ProfileEditor.tsx
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef } from 'react';
|
| 2 |
+
import { Button } from './ui/button';
|
| 3 |
+
import { Input } from './ui/input';
|
| 4 |
+
import { Label } from './ui/label';
|
| 5 |
+
import { Textarea } from './ui/textarea';
|
| 6 |
+
import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
|
| 7 |
+
import type { User as UserType } from '../App';
|
| 8 |
+
import { toast } from 'sonner';
|
| 9 |
+
import {
|
| 10 |
+
Select,
|
| 11 |
+
SelectContent,
|
| 12 |
+
SelectItem,
|
| 13 |
+
SelectTrigger,
|
| 14 |
+
SelectValue,
|
| 15 |
+
} from './ui/select';
|
| 16 |
+
|
| 17 |
+
interface ProfileEditorProps {
|
| 18 |
+
user: UserType;
|
| 19 |
+
onSave: (user: UserType) => void;
|
| 20 |
+
onClose: () => void;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function ProfileEditor({ user, onSave, onClose }: ProfileEditorProps) {
|
| 24 |
+
const [name, setName] = useState(user.name);
|
| 25 |
+
const [email, setEmail] = useState(user.email);
|
| 26 |
+
const [studentId, setStudentId] = useState('S12345678');
|
| 27 |
+
const [department, setDepartment] = useState('Computer Science');
|
| 28 |
+
const [year, setYear] = useState('3rd Year');
|
| 29 |
+
const [major, setMajor] = useState('Artificial Intelligence');
|
| 30 |
+
const [bio, setBio] = useState('Passionate about AI and machine learning');
|
| 31 |
+
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
| 32 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 33 |
+
|
| 34 |
+
const handleSave = () => {
|
| 35 |
+
if (name.trim() && email.trim()) {
|
| 36 |
+
onSave({ name: name.trim(), email: email.trim() });
|
| 37 |
+
toast.success('Profile updated successfully!');
|
| 38 |
+
onClose();
|
| 39 |
+
} else {
|
| 40 |
+
toast.error('Please fill in all required fields');
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const handlePhotoSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 45 |
+
const file = e.target.files?.[0];
|
| 46 |
+
if (file) {
|
| 47 |
+
// Validate file type
|
| 48 |
+
if (!file.type.startsWith('image/')) {
|
| 49 |
+
toast.error('Please select an image file');
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
// Validate file size (2MB)
|
| 53 |
+
if (file.size > 2 * 1024 * 1024) {
|
| 54 |
+
toast.error('File size must be less than 2MB');
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
// Create preview
|
| 58 |
+
const reader = new FileReader();
|
| 59 |
+
reader.onload = (e) => {
|
| 60 |
+
setPhotoPreview(e.target?.result as string);
|
| 61 |
+
toast.success('Photo updated successfully!');
|
| 62 |
+
};
|
| 63 |
+
reader.readAsDataURL(file);
|
| 64 |
+
}
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const handleChangePhotoClick = () => {
|
| 68 |
+
fileInputRef.current?.click();
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
return (
|
| 72 |
+
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
| 73 |
+
<DialogContent
|
| 74 |
+
className="sm:max-w-[800px] p-0 gap-0 max-h-[90vh] overflow-hidden"
|
| 75 |
+
style={{ zIndex: 1001, maxWidth: '800px', width: '800px' }}
|
| 76 |
+
overlayClassName="!top-16 !left-0 !right-0 !bottom-0 !z-[99]"
|
| 77 |
+
overlayStyle={{
|
| 78 |
+
top: '64px',
|
| 79 |
+
left: 0,
|
| 80 |
+
right: 0,
|
| 81 |
+
bottom: 0,
|
| 82 |
+
zIndex: 99,
|
| 83 |
+
position: 'fixed'
|
| 84 |
+
}}
|
| 85 |
+
>
|
| 86 |
+
<div className="flex flex-col max-h-[90vh]">
|
| 87 |
+
{/* Header */}
|
| 88 |
+
<div className="p-4 flex items-center justify-between flex-shrink-0">
|
| 89 |
+
<DialogTitle className="text-xl font-medium">Edit Profile</DialogTitle>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
{/* Content */}
|
| 93 |
+
<div className="p-6 space-y-6 overflow-y-auto flex-1">
|
| 94 |
+
{/* Profile Picture */}
|
| 95 |
+
<div className="flex items-center gap-4">
|
| 96 |
+
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center text-white text-2xl overflow-hidden">
|
| 97 |
+
{photoPreview ? (
|
| 98 |
+
<img src={photoPreview} alt="Profile" className="w-full h-full object-cover" />
|
| 99 |
+
) : (
|
| 100 |
+
name.charAt(0).toUpperCase()
|
| 101 |
+
)}
|
| 102 |
+
</div>
|
| 103 |
+
<div>
|
| 104 |
+
<input
|
| 105 |
+
ref={fileInputRef}
|
| 106 |
+
type="file"
|
| 107 |
+
accept="image/jpeg,image/png,image/gif,image/webp"
|
| 108 |
+
onChange={handlePhotoSelect}
|
| 109 |
+
className="hidden"
|
| 110 |
+
/>
|
| 111 |
+
<Button
|
| 112 |
+
variant="outline"
|
| 113 |
+
size="sm"
|
| 114 |
+
onClick={handleChangePhotoClick}
|
| 115 |
+
>
|
| 116 |
+
Change Photo
|
| 117 |
+
</Button>
|
| 118 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 119 |
+
JPG, PNG or GIF. Max size 2MB
|
| 120 |
+
</p>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
{/* Basic Information */}
|
| 125 |
+
<div className="space-y-4">
|
| 126 |
+
<h3 className="text-sm font-medium">Basic Information</h3>
|
| 127 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 128 |
+
<div className="space-y-2">
|
| 129 |
+
<Label htmlFor="edit-name">Full Name *</Label>
|
| 130 |
+
<Input
|
| 131 |
+
id="edit-name"
|
| 132 |
+
value={name}
|
| 133 |
+
onChange={(e) => setName(e.target.value)}
|
| 134 |
+
placeholder="Enter your full name"
|
| 135 |
+
/>
|
| 136 |
+
</div>
|
| 137 |
+
<div className="space-y-2">
|
| 138 |
+
<Label htmlFor="edit-email">Email *</Label>
|
| 139 |
+
<Input
|
| 140 |
+
id="edit-email"
|
| 141 |
+
type="email"
|
| 142 |
+
value={email}
|
| 143 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 144 |
+
placeholder="Enter your email"
|
| 145 |
+
/>
|
| 146 |
+
</div>
|
| 147 |
+
<div className="space-y-2">
|
| 148 |
+
<Label htmlFor="edit-student-id">Student ID</Label>
|
| 149 |
+
<Input
|
| 150 |
+
id="edit-student-id"
|
| 151 |
+
value={studentId}
|
| 152 |
+
onChange={(e) => setStudentId(e.target.value)}
|
| 153 |
+
placeholder="Enter your student ID"
|
| 154 |
+
/>
|
| 155 |
+
</div>
|
| 156 |
+
<div className="space-y-2">
|
| 157 |
+
<Label htmlFor="edit-department">Department</Label>
|
| 158 |
+
<Input
|
| 159 |
+
id="edit-department"
|
| 160 |
+
value={department}
|
| 161 |
+
onChange={(e) => setDepartment(e.target.value)}
|
| 162 |
+
placeholder="Enter your department"
|
| 163 |
+
/>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
|
| 168 |
+
{/* Academic Background */}
|
| 169 |
+
<div className="space-y-4">
|
| 170 |
+
<h3 className="text-sm font-medium">Academic Background</h3>
|
| 171 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 172 |
+
<div className="space-y-2">
|
| 173 |
+
<Label htmlFor="edit-year">Year Level</Label>
|
| 174 |
+
<Select value={year} onValueChange={setYear}>
|
| 175 |
+
<SelectTrigger id="edit-year">
|
| 176 |
+
<SelectValue />
|
| 177 |
+
</SelectTrigger>
|
| 178 |
+
<SelectContent>
|
| 179 |
+
<SelectItem value="1st Year">1st Year</SelectItem>
|
| 180 |
+
<SelectItem value="2nd Year">2nd Year</SelectItem>
|
| 181 |
+
<SelectItem value="3rd Year">3rd Year</SelectItem>
|
| 182 |
+
<SelectItem value="4th Year">4th Year</SelectItem>
|
| 183 |
+
<SelectItem value="Graduate">Graduate</SelectItem>
|
| 184 |
+
</SelectContent>
|
| 185 |
+
</Select>
|
| 186 |
+
</div>
|
| 187 |
+
<div className="space-y-2">
|
| 188 |
+
<Label htmlFor="edit-major">Major</Label>
|
| 189 |
+
<Input
|
| 190 |
+
id="edit-major"
|
| 191 |
+
value={major}
|
| 192 |
+
onChange={(e) => setMajor(e.target.value)}
|
| 193 |
+
placeholder="Enter your major"
|
| 194 |
+
/>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
|
| 199 |
+
{/* Bio */}
|
| 200 |
+
<div className="space-y-2">
|
| 201 |
+
<Label htmlFor="edit-bio">Bio</Label>
|
| 202 |
+
<Textarea
|
| 203 |
+
id="edit-bio"
|
| 204 |
+
value={bio}
|
| 205 |
+
onChange={(e) => setBio(e.target.value)}
|
| 206 |
+
placeholder="Tell us about yourself..."
|
| 207 |
+
className="min-h-[100px] resize-none"
|
| 208 |
+
/>
|
| 209 |
+
<p className="text-xs text-muted-foreground">
|
| 210 |
+
Brief description for your profile. Max 200 characters.
|
| 211 |
+
</p>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
{/* Learning Preferences */}
|
| 215 |
+
<div className="space-y-4">
|
| 216 |
+
<h3 className="text-sm font-medium">Learning Preferences</h3>
|
| 217 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 218 |
+
<div className="space-y-2">
|
| 219 |
+
<Label htmlFor="edit-learning-style">Preferred Learning Style</Label>
|
| 220 |
+
<Select defaultValue="visual">
|
| 221 |
+
<SelectTrigger id="edit-learning-style">
|
| 222 |
+
<SelectValue />
|
| 223 |
+
</SelectTrigger>
|
| 224 |
+
<SelectContent>
|
| 225 |
+
<SelectItem value="visual">Visual</SelectItem>
|
| 226 |
+
<SelectItem value="auditory">Auditory</SelectItem>
|
| 227 |
+
<SelectItem value="reading">Reading/Writing</SelectItem>
|
| 228 |
+
<SelectItem value="kinesthetic">Kinesthetic</SelectItem>
|
| 229 |
+
</SelectContent>
|
| 230 |
+
</Select>
|
| 231 |
+
</div>
|
| 232 |
+
<div className="space-y-2">
|
| 233 |
+
<Label htmlFor="edit-pace">Learning Pace</Label>
|
| 234 |
+
<Select defaultValue="moderate">
|
| 235 |
+
<SelectTrigger id="edit-pace">
|
| 236 |
+
<SelectValue />
|
| 237 |
+
</SelectTrigger>
|
| 238 |
+
<SelectContent>
|
| 239 |
+
<SelectItem value="slow">Slow & Steady</SelectItem>
|
| 240 |
+
<SelectItem value="moderate">Moderate</SelectItem>
|
| 241 |
+
<SelectItem value="fast">Fast-paced</SelectItem>
|
| 242 |
+
</SelectContent>
|
| 243 |
+
</Select>
|
| 244 |
+
</div>
|
| 245 |
+
</div>
|
| 246 |
+
</div>
|
| 247 |
+
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
{/* Footer */}
|
| 251 |
+
<div className="border-t border-border p-4 flex justify-end gap-2 flex-shrink-0">
|
| 252 |
+
<Button variant="outline" onClick={onClose}>
|
| 253 |
+
Cancel
|
| 254 |
+
</Button>
|
| 255 |
+
<Button onClick={handleSave}>
|
| 256 |
+
Save Changes
|
| 257 |
+
</Button>
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
</DialogContent>
|
| 261 |
+
</Dialog>
|
| 262 |
+
);
|
| 263 |
+
}
|
web/src/components/ReviewBanner.tsx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Button } from './ui/button';
|
| 3 |
+
import { X, BookOpen } from 'lucide-react';
|
| 4 |
+
|
| 5 |
+
interface ReviewBannerProps {
|
| 6 |
+
onReview: () => void;
|
| 7 |
+
onDismiss: () => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export function ReviewBanner({ onReview, onDismiss }: ReviewBannerProps) {
|
| 11 |
+
return (
|
| 12 |
+
<div className="mx-4 my-2 p-4 bg-primary/10">
|
| 13 |
+
<div className="flex items-center justify-between gap-4">
|
| 14 |
+
<div className="flex items-center gap-3 flex-1">
|
| 15 |
+
<div className="flex-shrink-0">
|
| 16 |
+
<BookOpen className="h-5 w-5 text-primary" />
|
| 17 |
+
</div>
|
| 18 |
+
<div className="flex-1">
|
| 19 |
+
<p className="text-sm font-medium text-foreground">
|
| 20 |
+
Time to review! 📚
|
| 21 |
+
</p>
|
| 22 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 23 |
+
You have topics that need your attention. Review them to strengthen your learning.
|
| 24 |
+
</p>
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
<div className="flex items-center gap-2 flex-shrink-0">
|
| 28 |
+
<Button
|
| 29 |
+
size="sm"
|
| 30 |
+
onClick={onReview}
|
| 31 |
+
className="bg-red-500 hover:bg-red-600 text-white"
|
| 32 |
+
>
|
| 33 |
+
Review
|
| 34 |
+
</Button>
|
| 35 |
+
<Button
|
| 36 |
+
variant="ghost"
|
| 37 |
+
size="icon"
|
| 38 |
+
onClick={onDismiss}
|
| 39 |
+
className="h-8 w-8"
|
| 40 |
+
aria-label="Dismiss"
|
| 41 |
+
>
|
| 42 |
+
<X className="h-4 w-4" />
|
| 43 |
+
</Button>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
);
|
| 48 |
+
}
|
| 49 |
+
|
web/src/components/RightPanel.tsx
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } 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 |
+
Sparkles,
|
| 14 |
+
Bookmark,
|
| 15 |
+
Copy
|
| 16 |
+
} from 'lucide-react';
|
| 17 |
+
import { Document, HeadingLevel, Packer, Paragraph, TextRun } from 'docx';
|
| 18 |
+
import type { User, SavedItem } from '../App';
|
| 19 |
+
import { toast } from 'sonner';
|
| 20 |
+
import {
|
| 21 |
+
Dialog,
|
| 22 |
+
DialogContent,
|
| 23 |
+
DialogDescription,
|
| 24 |
+
DialogHeader,
|
| 25 |
+
DialogTitle,
|
| 26 |
+
DialogTrigger,
|
| 27 |
+
DialogFooter,
|
| 28 |
+
} from './ui/dialog';
|
| 29 |
+
|
| 30 |
+
interface RightPanelProps {
|
| 31 |
+
user: User | null;
|
| 32 |
+
onLogin: (user: User) => void;
|
| 33 |
+
onLogout: () => void;
|
| 34 |
+
isLoggedIn: boolean;
|
| 35 |
+
onClose?: () => void;
|
| 36 |
+
exportResult: string;
|
| 37 |
+
setExportResult: (result: string) => void;
|
| 38 |
+
resultType: 'export' | 'quiz' | 'summary' | null;
|
| 39 |
+
setResultType: (type: 'export' | 'quiz' | 'summary' | null) => void;
|
| 40 |
+
onExport: () => void;
|
| 41 |
+
onSummary: () => void;
|
| 42 |
+
onSave: (content: string, type: 'export' | 'quiz' | 'summary') => void;
|
| 43 |
+
savedItems: SavedItem[];
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export function RightPanel({ user, onLogin, onLogout, isLoggedIn, onClose, exportResult, setExportResult, resultType, setResultType, onExport, onSummary, onSave, savedItems }: RightPanelProps) {
|
| 47 |
+
const [showLoginForm, setShowLoginForm] = useState(false);
|
| 48 |
+
const [name, setName] = useState('');
|
| 49 |
+
const [email, setEmail] = useState('');
|
| 50 |
+
const [isExpanded, setIsExpanded] = useState(true);
|
| 51 |
+
const [isDownloading, setIsDownloading] = useState(false);
|
| 52 |
+
const [copied, setCopied] = useState(false);
|
| 53 |
+
|
| 54 |
+
// Check if current result is already saved
|
| 55 |
+
const isSaved = exportResult && resultType
|
| 56 |
+
? savedItems.some(item => item.content === exportResult && item.type === resultType)
|
| 57 |
+
: false;
|
| 58 |
+
|
| 59 |
+
const handleLogin = () => {
|
| 60 |
+
if (!name.trim() || !email.trim()) {
|
| 61 |
+
toast.error('Please fill in all fields');
|
| 62 |
+
return;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
onLogin({ name: name.trim(), email: email.trim() });
|
| 66 |
+
setShowLoginForm(false);
|
| 67 |
+
setName('');
|
| 68 |
+
setEmail('');
|
| 69 |
+
toast.success(`Welcome, ${name}!`);
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const handleLogout = () => {
|
| 73 |
+
onLogout();
|
| 74 |
+
setShowLoginForm(false);
|
| 75 |
+
toast.success('Logged out successfully');
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
| 79 |
+
|
| 80 |
+
// Use native event listeners to prevent scroll propagation
|
| 81 |
+
useEffect(() => {
|
| 82 |
+
const container = scrollContainerRef.current;
|
| 83 |
+
if (!container) return;
|
| 84 |
+
|
| 85 |
+
const handleWheel = (e: WheelEvent) => {
|
| 86 |
+
// Always stop propagation to prevent scrolling other panels
|
| 87 |
+
e.stopPropagation();
|
| 88 |
+
e.stopImmediatePropagation();
|
| 89 |
+
|
| 90 |
+
// Only prevent default if we're at the boundaries
|
| 91 |
+
const { scrollTop, scrollHeight, clientHeight } = container;
|
| 92 |
+
const isScrollable = scrollHeight > clientHeight;
|
| 93 |
+
const isAtTop = scrollTop === 0;
|
| 94 |
+
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1;
|
| 95 |
+
|
| 96 |
+
// If scrolling up at top or down at bottom, prevent default to stop propagation
|
| 97 |
+
if (isScrollable && ((isAtTop && e.deltaY < 0) || (isAtBottom && e.deltaY > 0))) {
|
| 98 |
+
e.preventDefault();
|
| 99 |
+
}
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
container.addEventListener('wheel', handleWheel, { passive: false, capture: true });
|
| 103 |
+
|
| 104 |
+
return () => {
|
| 105 |
+
container.removeEventListener('wheel', handleWheel, { capture: true });
|
| 106 |
+
};
|
| 107 |
+
}, []);
|
| 108 |
+
|
| 109 |
+
const downloadBlob = (blob: Blob, filename: string) => {
|
| 110 |
+
const url = URL.createObjectURL(blob);
|
| 111 |
+
const a = document.createElement('a');
|
| 112 |
+
a.href = url;
|
| 113 |
+
a.download = filename;
|
| 114 |
+
document.body.appendChild(a);
|
| 115 |
+
a.click();
|
| 116 |
+
a.remove();
|
| 117 |
+
URL.revokeObjectURL(url);
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
const formatDateStamp = () => {
|
| 121 |
+
const d = new Date();
|
| 122 |
+
const yyyy = d.getFullYear();
|
| 123 |
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
| 124 |
+
const dd = String(d.getDate()).padStart(2, '0');
|
| 125 |
+
return `${yyyy}-${mm}-${dd}`;
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
const getDefaultFilenameBase = () => {
|
| 129 |
+
const kind =
|
| 130 |
+
resultType === 'export' ? 'export' :
|
| 131 |
+
resultType === 'summary' ? 'summary' :
|
| 132 |
+
'result';
|
| 133 |
+
return `clare-${kind}-${formatDateStamp()}`;
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
const handleDownloadMd = async () => {
|
| 137 |
+
if (!exportResult) return;
|
| 138 |
+
try {
|
| 139 |
+
setIsDownloading(true);
|
| 140 |
+
toast.message('Preparing .md…');
|
| 141 |
+
const blob = new Blob([exportResult], { type: 'text/markdown;charset=utf-8' });
|
| 142 |
+
downloadBlob(blob, `${getDefaultFilenameBase()}.md`);
|
| 143 |
+
toast.success('Downloaded .md');
|
| 144 |
+
} catch (e) {
|
| 145 |
+
console.error(e);
|
| 146 |
+
toast.error('Failed to download .md');
|
| 147 |
+
} finally {
|
| 148 |
+
setIsDownloading(false);
|
| 149 |
+
}
|
| 150 |
+
};
|
| 151 |
+
|
| 152 |
+
const handleDownloadDocx = async () => {
|
| 153 |
+
if (!exportResult) return;
|
| 154 |
+
try {
|
| 155 |
+
setIsDownloading(true);
|
| 156 |
+
toast.message('Preparing .docx…');
|
| 157 |
+
|
| 158 |
+
const lines = exportResult.split('\n');
|
| 159 |
+
const paragraphs: Paragraph[] = lines.map((line) => {
|
| 160 |
+
const trimmed = line.trim();
|
| 161 |
+
if (!trimmed) return new Paragraph({ text: '' });
|
| 162 |
+
|
| 163 |
+
// Basic markdown-ish heading support
|
| 164 |
+
if (trimmed.startsWith('### ')) {
|
| 165 |
+
return new Paragraph({ text: trimmed.replace(/^###\s+/, ''), heading: HeadingLevel.HEADING_3 });
|
| 166 |
+
}
|
| 167 |
+
if (trimmed.startsWith('## ')) {
|
| 168 |
+
return new Paragraph({ text: trimmed.replace(/^##\s+/, ''), heading: HeadingLevel.HEADING_2 });
|
| 169 |
+
}
|
| 170 |
+
if (trimmed.startsWith('# ')) {
|
| 171 |
+
return new Paragraph({ text: trimmed.replace(/^#\s+/, ''), heading: HeadingLevel.HEADING_1 });
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
return new Paragraph({ children: [new TextRun({ text: line })] });
|
| 175 |
+
});
|
| 176 |
+
|
| 177 |
+
const doc = new Document({
|
| 178 |
+
sections: [{ properties: {}, children: paragraphs }],
|
| 179 |
+
});
|
| 180 |
+
|
| 181 |
+
const blob = await Packer.toBlob(doc);
|
| 182 |
+
downloadBlob(blob, `${getDefaultFilenameBase()}.docx`);
|
| 183 |
+
toast.success('Downloaded .docx');
|
| 184 |
+
} catch (e) {
|
| 185 |
+
console.error(e);
|
| 186 |
+
toast.error('Failed to download .docx');
|
| 187 |
+
} finally {
|
| 188 |
+
setIsDownloading(false);
|
| 189 |
+
}
|
| 190 |
+
};
|
| 191 |
+
|
| 192 |
+
return (
|
| 193 |
+
<div
|
| 194 |
+
ref={scrollContainerRef}
|
| 195 |
+
className="flex-1 overflow-auto overscroll-contain flex flex-col"
|
| 196 |
+
style={{ overscrollBehavior: 'contain' }}
|
| 197 |
+
>
|
| 198 |
+
<div className="p-4 space-y-4">
|
| 199 |
+
{isExpanded && (
|
| 200 |
+
<>
|
| 201 |
+
{/* Actions Section with Results */}
|
| 202 |
+
<div className="space-y-3">
|
| 203 |
+
<h3 className="text-base font-medium">Export / Summarize Conversation</h3>
|
| 204 |
+
<Card className="p-4 bg-muted/30">
|
| 205 |
+
<div className="flex flex-col gap-3">
|
| 206 |
+
<Button
|
| 207 |
+
variant="outline"
|
| 208 |
+
className="w-full h-12 rounded-lg justify-start gap-3"
|
| 209 |
+
onClick={onExport}
|
| 210 |
+
disabled={!isLoggedIn}
|
| 211 |
+
>
|
| 212 |
+
<Download className="h-5 w-5" />
|
| 213 |
+
<span>Export</span>
|
| 214 |
+
</Button>
|
| 215 |
+
<Button
|
| 216 |
+
variant="outline"
|
| 217 |
+
className="w-full h-12 rounded-lg justify-start gap-3"
|
| 218 |
+
onClick={onSummary}
|
| 219 |
+
disabled={!isLoggedIn}
|
| 220 |
+
>
|
| 221 |
+
<Sparkles className="h-5 w-5" />
|
| 222 |
+
<span>Summarize</span>
|
| 223 |
+
</Button>
|
| 224 |
+
|
| 225 |
+
{/* Results - Expanded from buttons */}
|
| 226 |
+
{exportResult && (
|
| 227 |
+
<>
|
| 228 |
+
<Separator className="my-2" />
|
| 229 |
+
<div className="space-y-3">
|
| 230 |
+
<div className="flex items-center justify-between gap-2">
|
| 231 |
+
<h4 className="text-base font-bold">
|
| 232 |
+
{resultType === 'export' && 'Exported Conversation'}
|
| 233 |
+
{resultType === 'quiz' && 'Micro-Quiz'}
|
| 234 |
+
{resultType === 'summary' && 'Summarization'}
|
| 235 |
+
</h4>
|
| 236 |
+
<div className="flex items-center gap-2">
|
| 237 |
+
<Button
|
| 238 |
+
variant="outline"
|
| 239 |
+
size="sm"
|
| 240 |
+
disabled={isDownloading}
|
| 241 |
+
onClick={handleDownloadMd}
|
| 242 |
+
title="Download as .md"
|
| 243 |
+
className="h-7 px-2 text-xs gap-1.5"
|
| 244 |
+
>
|
| 245 |
+
<Download className="h-3 w-3" />
|
| 246 |
+
.md
|
| 247 |
+
</Button>
|
| 248 |
+
<Button
|
| 249 |
+
variant="outline"
|
| 250 |
+
size="sm"
|
| 251 |
+
disabled={isDownloading}
|
| 252 |
+
onClick={handleDownloadDocx}
|
| 253 |
+
title="Download as .docx"
|
| 254 |
+
className="h-7 px-2 text-xs gap-1.5"
|
| 255 |
+
>
|
| 256 |
+
<Download className="h-3 w-3" />
|
| 257 |
+
.docx
|
| 258 |
+
</Button>
|
| 259 |
+
<Button
|
| 260 |
+
variant="outline"
|
| 261 |
+
size="sm"
|
| 262 |
+
onClick={async () => {
|
| 263 |
+
await navigator.clipboard.writeText(exportResult);
|
| 264 |
+
setCopied(true);
|
| 265 |
+
toast.success('Copied to clipboard!');
|
| 266 |
+
setTimeout(() => setCopied(false), 2000);
|
| 267 |
+
}}
|
| 268 |
+
disabled={isDownloading}
|
| 269 |
+
className="h-7 px-2 text-xs gap-1.5"
|
| 270 |
+
title="Copy"
|
| 271 |
+
>
|
| 272 |
+
<Copy className="h-3 w-3" />
|
| 273 |
+
</Button>
|
| 274 |
+
{resultType && (
|
| 275 |
+
<Button
|
| 276 |
+
variant="outline"
|
| 277 |
+
size="sm"
|
| 278 |
+
onClick={() => {
|
| 279 |
+
if (resultType) {
|
| 280 |
+
onSave(exportResult, resultType);
|
| 281 |
+
}
|
| 282 |
+
}}
|
| 283 |
+
disabled={isDownloading || !resultType}
|
| 284 |
+
className={`h-7 px-2 text-xs gap-1.5 ${isSaved ? 'bg-red-50 dark:bg-red-950/20 border-red-300 dark:border-red-800' : ''}`}
|
| 285 |
+
title={isSaved ? 'Unsave' : 'Save for later'}
|
| 286 |
+
>
|
| 287 |
+
<Bookmark className={`h-3 w-3 ${isSaved ? 'fill-red-600 text-red-600' : ''}`} />
|
| 288 |
+
</Button>
|
| 289 |
+
)}
|
| 290 |
+
</div>
|
| 291 |
+
</div>
|
| 292 |
+
<div className={`text-sm whitespace-pre-wrap text-foreground p-3 rounded-lg ${
|
| 293 |
+
isSaved ? 'bg-red-50/50 dark:bg-red-950/10' : ''
|
| 294 |
+
}`}>
|
| 295 |
+
{exportResult}
|
| 296 |
+
</div>
|
| 297 |
+
</div>
|
| 298 |
+
</>
|
| 299 |
+
)}
|
| 300 |
+
</div>
|
| 301 |
+
</Card>
|
| 302 |
+
</div>
|
| 303 |
+
</>
|
| 304 |
+
)}
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
);
|
| 308 |
+
}
|
web/src/components/SmartReview.tsx
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Brain, AlertTriangle, AlertCircle, CheckCircle, ChevronDown, ChevronRight, Info } from 'lucide-react';
|
| 3 |
+
import { Badge } from './ui/badge';
|
| 4 |
+
import { Button } from './ui/button';
|
| 5 |
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
| 6 |
+
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
| 7 |
+
|
| 8 |
+
interface ReviewItem {
|
| 9 |
+
id: string;
|
| 10 |
+
title: string;
|
| 11 |
+
schedule: string;
|
| 12 |
+
status: 'urgent' | 'review' | 'stable';
|
| 13 |
+
weight: number;
|
| 14 |
+
lastReviewed: string;
|
| 15 |
+
memoryRetention: number;
|
| 16 |
+
previousQuestion: string;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
interface SmartReviewProps {
|
| 20 |
+
onReviewTopic?: (item: ReviewItem) => void;
|
| 21 |
+
onReviewAll?: () => void;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function SmartReview({ onReviewTopic, onReviewAll }: SmartReviewProps = {}) {
|
| 25 |
+
// Initialize with the first item of W-4 (red zone) expanded by default
|
| 26 |
+
const [expandedItems, setExpandedItems] = useState<string[]>(['w4-1']);
|
| 27 |
+
const [selectedCategory, setSelectedCategory] = useState<string>('W-4'); // Default to red zone
|
| 28 |
+
|
| 29 |
+
const reviewData = [
|
| 30 |
+
{
|
| 31 |
+
label: 'W-4',
|
| 32 |
+
percentage: 35,
|
| 33 |
+
color: 'bg-red-500',
|
| 34 |
+
textColor: 'text-red-500',
|
| 35 |
+
icon: AlertTriangle,
|
| 36 |
+
description: 'Higher forgetting risk',
|
| 37 |
+
items: [
|
| 38 |
+
{
|
| 39 |
+
id: 'w4-1',
|
| 40 |
+
title: 'Main Concept of Lab 3',
|
| 41 |
+
schedule: 'T+7',
|
| 42 |
+
status: 'urgent' as const,
|
| 43 |
+
weight: 35,
|
| 44 |
+
lastReviewed: '7 days ago',
|
| 45 |
+
memoryRetention: 25,
|
| 46 |
+
previousQuestion: 'I\'ve read the instructions for Lab 3, but what is the main concept we\'re supposed to be learning here?'
|
| 47 |
+
}
|
| 48 |
+
]
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
label: 'W-2',
|
| 52 |
+
percentage: 20,
|
| 53 |
+
color: 'bg-orange-500',
|
| 54 |
+
textColor: 'text-orange-500',
|
| 55 |
+
icon: AlertCircle,
|
| 56 |
+
description: 'Medium forgetting risk',
|
| 57 |
+
items: [
|
| 58 |
+
{
|
| 59 |
+
id: 'w2-1',
|
| 60 |
+
title: 'Effective Prompt Engineering',
|
| 61 |
+
schedule: 'T+14',
|
| 62 |
+
status: 'review' as const,
|
| 63 |
+
weight: 20,
|
| 64 |
+
lastReviewed: '3 days ago',
|
| 65 |
+
memoryRetention: 60,
|
| 66 |
+
previousQuestion: 'I understand what prompt engineering is, but what specifically makes a prompt effective versus ineffective?'
|
| 67 |
+
}
|
| 68 |
+
]
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
label: 'W-1',
|
| 72 |
+
percentage: 12,
|
| 73 |
+
color: 'bg-green-500',
|
| 74 |
+
textColor: 'text-green-500',
|
| 75 |
+
icon: CheckCircle,
|
| 76 |
+
description: 'Recently learned',
|
| 77 |
+
items: [
|
| 78 |
+
{
|
| 79 |
+
id: 'w1-1',
|
| 80 |
+
title: 'Objective LLM Evaluation',
|
| 81 |
+
schedule: 'T+7',
|
| 82 |
+
status: 'stable' as const,
|
| 83 |
+
weight: 12,
|
| 84 |
+
lastReviewed: '1 day ago',
|
| 85 |
+
memoryRetention: 90,
|
| 86 |
+
previousQuestion: 'How can we objectively evaluate an LLM\'s performance when the output quality seems so subjective?'
|
| 87 |
+
}
|
| 88 |
+
]
|
| 89 |
+
},
|
| 90 |
+
];
|
| 91 |
+
|
| 92 |
+
const totalPercentage = reviewData.reduce((sum, item) => sum + item.percentage, 0);
|
| 93 |
+
const selectedData = reviewData.find(item => item.label === selectedCategory);
|
| 94 |
+
|
| 95 |
+
const toggleItem = (itemId: string) => {
|
| 96 |
+
setExpandedItems(prev =>
|
| 97 |
+
prev.includes(itemId)
|
| 98 |
+
? prev.filter(id => id !== itemId)
|
| 99 |
+
: [...prev, itemId]
|
| 100 |
+
);
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
// When category changes, automatically expand the first item of that category
|
| 104 |
+
const handleCategoryChange = (categoryLabel: string) => {
|
| 105 |
+
setSelectedCategory(categoryLabel);
|
| 106 |
+
const categoryData = reviewData.find(item => item.label === categoryLabel);
|
| 107 |
+
if (categoryData && categoryData.items.length > 0) {
|
| 108 |
+
// Expand only the first item of the selected category
|
| 109 |
+
setExpandedItems([categoryData.items[0].id]);
|
| 110 |
+
}
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
const getStatusBadge = (status: 'urgent' | 'review' | 'stable') => {
|
| 114 |
+
const configs = {
|
| 115 |
+
urgent: { label: 'URGENT', className: 'bg-red-500 text-white hover:bg-red-600' },
|
| 116 |
+
review: { label: 'DUE', className: 'bg-orange-500 text-white hover:bg-orange-600' },
|
| 117 |
+
stable: { label: 'STABLE', className: 'bg-green-500 text-white hover:bg-green-600' },
|
| 118 |
+
};
|
| 119 |
+
return configs[status];
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
const getButtonColorClass = (colorClass: string) => {
|
| 123 |
+
// Extract color from bg-red-500, bg-orange-500, bg-green-500
|
| 124 |
+
if (colorClass.includes('red')) {
|
| 125 |
+
return 'bg-red-500 hover:bg-red-600';
|
| 126 |
+
} else if (colorClass.includes('orange')) {
|
| 127 |
+
return 'bg-orange-500 hover:bg-orange-600';
|
| 128 |
+
} else if (colorClass.includes('green')) {
|
| 129 |
+
return 'bg-green-500 hover:bg-green-600';
|
| 130 |
+
}
|
| 131 |
+
return 'bg-red-500 hover:bg-red-600'; // default
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
return (
|
| 135 |
+
<div className="space-y-4">
|
| 136 |
+
<div className="flex flex-col gap-1">
|
| 137 |
+
<div className="flex items-center gap-2">
|
| 138 |
+
<Brain className="h-5 w-5 text-red-500" />
|
| 139 |
+
<h3>Current Review Distribution</h3>
|
| 140 |
+
<Tooltip>
|
| 141 |
+
<TooltipTrigger asChild>
|
| 142 |
+
<button className="text-muted-foreground hover:text-foreground transition-colors">
|
| 143 |
+
<Info className="h-3 w-3" />
|
| 144 |
+
</button>
|
| 145 |
+
</TooltipTrigger>
|
| 146 |
+
<TooltipContent side="right" className="p-2" style={{ maxWidth: '170px', whiteSpace: 'normal', wordBreak: 'break-word' }}>
|
| 147 |
+
<p className="text-[10px] leading-relaxed text-left" style={{ whiteSpace: 'normal' }}>
|
| 148 |
+
Based on the forgetting curve, Clare has selected topics you might be forgetting from your learning history and interaction patterns. Higher weights indicate higher forgetting risk.
|
| 149 |
+
</p>
|
| 150 |
+
</TooltipContent>
|
| 151 |
+
</Tooltip>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
{/* Combined Progress Bar - Clickable */}
|
| 156 |
+
<div className="space-y-2">
|
| 157 |
+
<div className="flex items-center gap-3">
|
| 158 |
+
{reviewData.map((item) => (
|
| 159 |
+
<button
|
| 160 |
+
key={item.label}
|
| 161 |
+
className={`flex flex-col gap-1.5 pt-2 px-1 pb-0 rounded-lg transition-all duration-200 hover:brightness-110 focus:outline-none cursor-pointer ${
|
| 162 |
+
selectedCategory === item.label ? 'bg-muted/80' : 'bg-transparent hover:bg-muted/40'
|
| 163 |
+
}`}
|
| 164 |
+
onClick={() => handleCategoryChange(item.label)}
|
| 165 |
+
title={`Click to view ${item.label} items`}
|
| 166 |
+
style={{ flex: item.percentage }}
|
| 167 |
+
>
|
| 168 |
+
<div className="flex items-center gap-1 justify-center whitespace-nowrap">
|
| 169 |
+
<span className="text-[10px]">{item.label}:</span>
|
| 170 |
+
<span className={`text-[10px] font-medium ${item.textColor}`}>{item.percentage}%</span>
|
| 171 |
+
</div>
|
| 172 |
+
<div className={`h-2 ${item.color} rounded-full mb-2`} />
|
| 173 |
+
</button>
|
| 174 |
+
))}
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
|
| 178 |
+
{/* Selected Category Expandable Items */}
|
| 179 |
+
{selectedData && (
|
| 180 |
+
<div className="space-y-2">
|
| 181 |
+
{/* Expandable Review Items */}
|
| 182 |
+
<div className="space-y-2">
|
| 183 |
+
{selectedData.items.map((reviewItem) => (
|
| 184 |
+
<Collapsible
|
| 185 |
+
key={reviewItem.id}
|
| 186 |
+
open={expandedItems.includes(reviewItem.id)}
|
| 187 |
+
onOpenChange={() => toggleItem(reviewItem.id)}
|
| 188 |
+
>
|
| 189 |
+
<CollapsibleTrigger asChild>
|
| 190 |
+
<Button
|
| 191 |
+
variant="ghost"
|
| 192 |
+
className="w-full justify-between text-left h-auto p-2 hover:bg-muted/50"
|
| 193 |
+
>
|
| 194 |
+
<span className="text-sm">Review: {reviewItem.title}</span>
|
| 195 |
+
{expandedItems.includes(reviewItem.id) ? (
|
| 196 |
+
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
| 197 |
+
) : (
|
| 198 |
+
<ChevronRight className="h-4 w-4 flex-shrink-0" />
|
| 199 |
+
)}
|
| 200 |
+
</Button>
|
| 201 |
+
</CollapsibleTrigger>
|
| 202 |
+
<CollapsibleContent>
|
| 203 |
+
<div className="p-3 pl-4">
|
| 204 |
+
<div className="flex items-center gap-2 flex-wrap">
|
| 205 |
+
<Badge variant="outline" className="text-xs">
|
| 206 |
+
{reviewItem.schedule}
|
| 207 |
+
</Badge>
|
| 208 |
+
<Badge className={`text-xs ${getStatusBadge(reviewItem.status).className}`}>
|
| 209 |
+
{getStatusBadge(reviewItem.status).label}
|
| 210 |
+
</Badge>
|
| 211 |
+
<span className="text-xs text-muted-foreground">
|
| 212 |
+
Weight: {reviewItem.weight}% | Last: {reviewItem.lastReviewed}
|
| 213 |
+
</span>
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
+
{/* Empty line for spacing */}
|
| 217 |
+
<div className="h-4"></div>
|
| 218 |
+
|
| 219 |
+
<div className="space-y-2">
|
| 220 |
+
{/* Titles row - aligned horizontally */}
|
| 221 |
+
<div className="flex gap-4 items-baseline">
|
| 222 |
+
<div className="flex-shrink-0">
|
| 223 |
+
<span className="text-xs text-muted-foreground leading-none">Memory Retention</span>
|
| 224 |
+
</div>
|
| 225 |
+
<div className="flex-1 min-w-0 pl-2">
|
| 226 |
+
<span className="text-xs text-muted-foreground leading-none">You previously asked:</span>
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
|
| 230 |
+
{/* Content row */}
|
| 231 |
+
<div className="flex gap-4 items-start">
|
| 232 |
+
{/* Left: Memory Retention Pie Chart */}
|
| 233 |
+
<div className="flex-shrink-0 relative w-24 h-24">
|
| 234 |
+
<svg className="w-full h-full" viewBox="0 0 100 100" style={{ transform: 'rotate(-90deg)' }}>
|
| 235 |
+
{/* Background circle */}
|
| 236 |
+
<circle
|
| 237 |
+
cx="50"
|
| 238 |
+
cy="50"
|
| 239 |
+
r="45"
|
| 240 |
+
fill="none"
|
| 241 |
+
stroke="currentColor"
|
| 242 |
+
strokeWidth="8"
|
| 243 |
+
className="text-muted"
|
| 244 |
+
/>
|
| 245 |
+
{/* Filled arc */}
|
| 246 |
+
<circle
|
| 247 |
+
cx="50"
|
| 248 |
+
cy="50"
|
| 249 |
+
r="45"
|
| 250 |
+
fill="none"
|
| 251 |
+
stroke="currentColor"
|
| 252 |
+
strokeWidth="8"
|
| 253 |
+
strokeDasharray={`${2 * Math.PI * 45}`}
|
| 254 |
+
strokeDashoffset={`${2 * Math.PI * 45 * (1 - reviewItem.memoryRetention / 100)}`}
|
| 255 |
+
strokeLinecap="round"
|
| 256 |
+
className={selectedData.color.replace('bg-', 'text-')}
|
| 257 |
+
/>
|
| 258 |
+
</svg>
|
| 259 |
+
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
| 260 |
+
<span className={`text-sm font-medium ${selectedData.textColor}`}>
|
| 261 |
+
{reviewItem.memoryRetention}%
|
| 262 |
+
</span>
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
|
| 266 |
+
{/* Right: Previous Question and Review Button */}
|
| 267 |
+
<div className="flex-1 h-24 flex flex-col justify-between min-w-0 pl-2">
|
| 268 |
+
<div className="flex-shrink-0">
|
| 269 |
+
<p className="text-xs bg-muted/50 p-2 rounded italic m-0">
|
| 270 |
+
"{reviewItem.previousQuestion}"
|
| 271 |
+
</p>
|
| 272 |
+
</div>
|
| 273 |
+
|
| 274 |
+
<div className="flex-shrink-0 flex gap-2">
|
| 275 |
+
<Button
|
| 276 |
+
className={`flex-1 ${getButtonColorClass(selectedData.color)} text-white`}
|
| 277 |
+
size="sm"
|
| 278 |
+
onClick={() => onReviewTopic?.(reviewItem)}
|
| 279 |
+
>
|
| 280 |
+
Review this topic
|
| 281 |
+
</Button>
|
| 282 |
+
<Button
|
| 283 |
+
variant="outline"
|
| 284 |
+
className="flex-1"
|
| 285 |
+
size="sm"
|
| 286 |
+
onClick={() => onReviewAll?.()}
|
| 287 |
+
>
|
| 288 |
+
Review All
|
| 289 |
+
</Button>
|
| 290 |
+
</div>
|
| 291 |
+
</div>
|
| 292 |
+
</div>
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
</CollapsibleContent>
|
| 296 |
+
</Collapsible>
|
| 297 |
+
))}
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
)}
|
| 301 |
+
|
| 302 |
+
</div>
|
| 303 |
+
);
|
| 304 |
+
}
|
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,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
| 5 |
+
import { XIcon } from "lucide-react";
|
| 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 |
+
overlayClassName,
|
| 55 |
+
overlayStyle,
|
| 56 |
+
style,
|
| 57 |
+
...props
|
| 58 |
+
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
| 59 |
+
overlayClassName?: string;
|
| 60 |
+
overlayStyle?: React.CSSProperties;
|
| 61 |
+
}) {
|
| 62 |
+
return (
|
| 63 |
+
<DialogPortal data-slot="dialog-portal">
|
| 64 |
+
<DialogOverlay className={overlayClassName} style={overlayStyle} />
|
| 65 |
+
<DialogPrimitive.Content
|
| 66 |
+
data-slot="dialog-content"
|
| 67 |
+
className={cn(
|
| 68 |
+
"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-[51] 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 pointer-events-auto",
|
| 69 |
+
className,
|
| 70 |
+
)}
|
| 71 |
+
style={style}
|
| 72 |
+
{...props}
|
| 73 |
+
>
|
| 74 |
+
{children}
|
| 75 |
+
<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">
|
| 76 |
+
<XIcon />
|
| 77 |
+
<span className="sr-only">Close</span>
|
| 78 |
+
</DialogPrimitive.Close>
|
| 79 |
+
</DialogPrimitive.Content>
|
| 80 |
+
</DialogPortal>
|
| 81 |
+
);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 85 |
+
return (
|
| 86 |
+
<div
|
| 87 |
+
data-slot="dialog-header"
|
| 88 |
+
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
| 89 |
+
{...props}
|
| 90 |
+
/>
|
| 91 |
+
);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
| 95 |
+
return (
|
| 96 |
+
<div
|
| 97 |
+
data-slot="dialog-footer"
|
| 98 |
+
className={cn(
|
| 99 |
+
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
| 100 |
+
className,
|
| 101 |
+
)}
|
| 102 |
+
{...props}
|
| 103 |
+
/>
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function DialogTitle({
|
| 108 |
+
className,
|
| 109 |
+
...props
|
| 110 |
+
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
| 111 |
+
return (
|
| 112 |
+
<DialogPrimitive.Title
|
| 113 |
+
data-slot="dialog-title"
|
| 114 |
+
className={cn("text-lg leading-none font-semibold", className)}
|
| 115 |
+
{...props}
|
| 116 |
+
/>
|
| 117 |
+
);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function DialogDescription({
|
| 121 |
+
className,
|
| 122 |
+
...props
|
| 123 |
+
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
| 124 |
+
return (
|
| 125 |
+
<DialogPrimitive.Description
|
| 126 |
+
data-slot="dialog-description"
|
| 127 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 128 |
+
{...props}
|
| 129 |
+
/>
|
| 130 |
+
);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
export {
|
| 134 |
+
Dialog,
|
| 135 |
+
DialogClose,
|
| 136 |
+
DialogContent,
|
| 137 |
+
DialogDescription,
|
| 138 |
+
DialogFooter,
|
| 139 |
+
DialogHeader,
|
| 140 |
+
DialogOverlay,
|
| 141 |
+
DialogPortal,
|
| 142 |
+
DialogTitle,
|
| 143 |
+
DialogTrigger,
|
| 144 |
+
};
|
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";
|
| 5 |
+
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
| 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-[100] 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 };
|