diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..207dfb96fcb65af009baa430e7d071f7ab160053 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +# Ignore node modules and build output +node_modules +**/node_modules + +# Logs +npm-debug.log +*.log + +# Git +.git +.gitignore + +# dotenv files +.env +*.env + +# IDEs +.vscode + +# local uploads +server/uploads diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..9a3101a1344627c48e42fd2d40c407761c650a40 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# Multi-stage build for ChatApp + +# ----- Build Stage ----- +FROM node:18 AS builder +WORKDIR /app + +# Install root dependencies and workspaces +COPY package.json package-lock.json* ./ +COPY client/package.json ./client/package.json +COPY server/package.json ./server/package.json +RUN npm install + +# Copy source code +COPY . . + +# Build client and server +RUN npm run build + +# Remove development dependencies to reduce size +RUN npm prune --omit=dev --workspaces + +# ----- Production Stage ----- +FROM node:18-alpine AS runner +WORKDIR /app + +# Copy built application from builder +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/client/package.json ./client/package.json +COPY --from=builder /app/server/package.json ./server/package.json +COPY --from=builder /app/client/dist ./client/dist +COPY --from=builder /app/server/dist ./server/dist +COPY --from=builder /app/client/node_modules ./client/node_modules +COPY --from=builder /app/server/node_modules ./server/node_modules + +ENV NODE_ENV=production +ENV PORT=3001 + +EXPOSE 3001 + +CMD ["node", "server/dist/index.js"] diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..f5acbb2ffd8e2749fce70729c7ec7f8cadfcd4ae --- /dev/null +++ b/client/.env.example @@ -0,0 +1,4 @@ +VITE_API_URL=http://localhost:3001 +VITE_SUPABASE_URL=your_supabase_url +VITE_SUPABASE_ANON_KEY=your_supabase_anon_key +VITE_SOCKET_URL=http://localhost:3001 diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000000000000000000000000000000000000..5f1fde10f751a93afaa66dae3e1808e0209ed9bb --- /dev/null +++ b/client/index.html @@ -0,0 +1,13 @@ + + + + + + + ChatApp - 现代化聊天应用 + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000000000000000000000000000000000000..98c02faa7c675735f9ae2c2b952396f8881679fa --- /dev/null +++ b/client/package.json @@ -0,0 +1,57 @@ +{ + "name": "chat-app-client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview", + "test": "vitest" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.1", + "socket.io-client": "^4.7.4", + "zustand": "^4.4.7", + "@supabase/supabase-js": "^2.38.4", + "axios": "^1.6.2", + "lucide-react": "^0.294.0", + "clsx": "^2.0.0", + "tailwind-merge": "^2.0.0", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-separator": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toast": "^1.2.4", + "class-variance-authority": "^0.7.0", + "react-hook-form": "^7.48.2", + "@hookform/resolvers": "^3.3.2", + "zod": "^3.22.4", + "date-fns": "^2.30.0", + "emoji-picker-react": "^4.5.16" + }, + "devDependencies": { + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@typescript-eslint/eslint-plugin": "^6.10.0", + "@typescript-eslint/parser": "^6.10.0", + "@vitejs/plugin-react": "^4.1.1", + "autoprefixer": "^10.4.16", + "eslint": "^8.53.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.4", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.2.2", + "vite": "^5.0.0", + "vitest": "^0.34.6" + } +} diff --git a/client/postcss.config.js b/client/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..2e7af2b7f1a6f391da1631d93968a9d487ba977d --- /dev/null +++ b/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0cd6713d43c049f54aec6f01a26280398c445ae3 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,89 @@ +import { Routes, Route, Navigate } from 'react-router-dom' +import { useEffect } from 'react' +import { useAuthStore } from './store/authStore' +import { Toaster } from './components/ui/toaster' + +// Pages +import LoginPage from './pages/LoginPage' +import RegisterPage from './pages/RegisterPage' +import ChatPage from './pages/ChatPage' +import AdminPage from './pages/AdminPage' +import ProfilePage from './pages/ProfilePage' + +// Components +import ProtectedRoute from './components/ProtectedRoute' +import LoadingSpinner from './components/LoadingSpinner' + +function App() { + const { user, loading, checkAuth } = useAuthStore() + + useEffect(() => { + checkAuth() + }, [checkAuth]) + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+ + {/* Public routes */} + : } + /> + : } + /> + + {/* Protected routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Default redirect */} + } + /> + + {/* 404 fallback */} + } + /> + + + +
+ ) +} + +export default App diff --git a/client/src/components/LoadingSpinner.tsx b/client/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d915c3e5480f8018e35f071eabd9c592ad4d2d4e --- /dev/null +++ b/client/src/components/LoadingSpinner.tsx @@ -0,0 +1,24 @@ +import { cn } from '@/lib/utils' + +interface LoadingSpinnerProps { + size?: 'sm' | 'md' | 'lg' + className?: string +} + +export default function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) { + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-6 h-6', + lg: 'w-8 h-8' + } + + return ( +
+ ) +} diff --git a/client/src/components/ProtectedRoute.tsx b/client/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7c8545d1c83db19942ee7e295a44c108e286d8c8 --- /dev/null +++ b/client/src/components/ProtectedRoute.tsx @@ -0,0 +1,31 @@ +import { ReactNode } from 'react' +import { Navigate } from 'react-router-dom' +import { useAuthStore } from '@/store/authStore' +import LoadingSpinner from './LoadingSpinner' + +interface ProtectedRouteProps { + children: ReactNode + requireAdmin?: boolean +} + +export default function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) { + const { user, loading } = useAuthStore() + + if (loading) { + return ( +
+ +
+ ) + } + + if (!user) { + return + } + + if (requireAdmin && !user.isAdmin) { + return + } + + return <>{children} +} diff --git a/client/src/components/chat/ChatSidebar.tsx b/client/src/components/chat/ChatSidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d5d9570e823f5073ff0d041ae076d9e65a770337 --- /dev/null +++ b/client/src/components/chat/ChatSidebar.tsx @@ -0,0 +1,176 @@ +import { useState } from 'react' +import { useChatStore } from '@/store/chatStore' +import { useAuthStore } from '@/store/authStore' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Badge } from '@/components/ui/badge' +import { ScrollArea } from '@/components/ui/scroll-area' +// import { Separator } from '@/components/ui/separator' +import { + Search, + Plus, + Settings, + MessageSquare, + Users, + // MoreVertical +} from 'lucide-react' +import { formatTime, getInitials } from '@/lib/utils' + +export default function ChatSidebar() { + const { user } = useAuthStore() + const { chats, currentChat, selectChat, loading } = useChatStore() + const [searchQuery, setSearchQuery] = useState('') + + const filteredChats = chats.filter(chat => { + if (!searchQuery) return true + + const searchLower = searchQuery.toLowerCase() + + // Search by chat name + if (chat.name?.toLowerCase().includes(searchLower)) return true + + // Search by participant names (for direct chats) + if (chat.type === 'direct') { + const otherParticipant = chat.participants.find(p => p.userId !== user?.id) + if (otherParticipant?.user.displayName?.toLowerCase().includes(searchLower)) return true + } + + return false + }) + + const getChatDisplayName = (chat: any) => { + if (chat.type === 'group') { + return chat.name || 'Unnamed Group' + } else { + const otherParticipant = chat.participants.find((p: any) => p.userId !== user?.id) + return otherParticipant?.user.displayName || 'Unknown User' + } + } + + const getChatAvatar = (chat: any) => { + if (chat.type === 'group') { + return chat.avatar + } else { + const otherParticipant = chat.participants.find((p: any) => p.userId !== user?.id) + return otherParticipant?.user.avatar + } + } + + return ( +
+ {/* Header */} +
+
+

Chats

+
+ + +
+
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+
+ + {/* Chat List */} + +
+ {loading ? ( +
+
Loading chats...
+
+ ) : filteredChats.length === 0 ? ( +
+ +

No chats found

+

+ {searchQuery ? 'Try a different search term' : 'Start a new conversation'} +

+ +
+ ) : ( + filteredChats.map((chat) => ( +
selectChat(chat.id)} + className={` + flex items-center p-3 rounded-lg cursor-pointer transition-colors + hover:bg-accent hover:text-accent-foreground + ${currentChat?.id === chat.id ? 'bg-accent text-accent-foreground' : ''} + `} + > +
+ + + + {chat.type === 'group' ? ( + + ) : ( + getInitials(getChatDisplayName(chat)) + )} + + + {/* Online indicator for direct chats */} + {chat.type === 'direct' && ( +
+ )} +
+ +
+
+

+ {getChatDisplayName(chat)} +

+
+ {chat.lastMessage && ( + + {formatTime(chat.lastMessage.createdAt)} + + )} + {chat.unreadCount > 0 && ( + + {chat.unreadCount > 99 ? '99+' : chat.unreadCount} + + )} +
+
+ + {chat.lastMessage && ( +

+ {chat.lastMessage.type === 'text' + ? chat.lastMessage.content + : `📎 ${chat.lastMessage.type}` + } +

+ )} + + {chat.type === 'group' && ( +

+ {chat.participants.length} members +

+ )} +
+
+ )) + )} +
+ +
+ ) +} diff --git a/client/src/components/chat/ChatWindow.tsx b/client/src/components/chat/ChatWindow.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d0d465d5ae4b6421a61e90632e200b4b4c10a1e4 --- /dev/null +++ b/client/src/components/chat/ChatWindow.tsx @@ -0,0 +1,255 @@ +import { useState, useRef, useEffect } from 'react' +import { useChatStore } from '@/store/chatStore' +import { useAuthStore } from '@/store/authStore' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { ScrollArea } from '@/components/ui/scroll-area' +// import { Separator } from '@/components/ui/separator' +import { + Send, + Paperclip, + Smile, + Phone, + Video, + MoreVertical, + Users, + Info +} from 'lucide-react' +import { formatTime, getInitials } from '@/lib/utils' + +export default function ChatWindow() { + const { user } = useAuthStore() + const { currentChat, messages, sendMessage, loading } = useChatStore() + const [messageText, setMessageText] = useState('') + // const [isTyping, setIsTyping] = useState(false) + const messagesEndRef = useRef(null) + const inputRef = useRef(null) + + const chatMessages = currentChat ? messages[currentChat.id] || [] : [] + + useEffect(() => { + scrollToBottom() + }, [chatMessages]) + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + const handleSendMessage = async () => { + if (!messageText.trim() || !currentChat) return + + try { + await sendMessage(currentChat.id, messageText.trim()) + setMessageText('') + inputRef.current?.focus() + } catch (error) { + console.error('Failed to send message:', error) + } + } + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + const getChatDisplayName = () => { + if (!currentChat) return '' + + if (currentChat.type === 'group') { + return currentChat.name || 'Unnamed Group' + } else { + const otherParticipant = currentChat.participants.find(p => p.userId !== user?.id) + return otherParticipant?.user.displayName || 'Unknown User' + } + } + + const getChatAvatar = () => { + if (!currentChat) return undefined + + if (currentChat.type === 'group') { + return currentChat.avatar + } else { + const otherParticipant = currentChat.participants.find(p => p.userId !== user?.id) + return otherParticipant?.user.avatar + } + } + + const isMessageFromCurrentUser = (message: any) => { + return message.senderId === user?.id + } + + if (!currentChat) { + return ( +
+
+
💬
+

Welcome to ChatApp

+

+ Select a chat to start messaging +

+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+ + + + {currentChat.type === 'group' ? ( + + ) : ( + getInitials(getChatDisplayName()) + )} + + + +
+

{getChatDisplayName()}

+

+ {currentChat.type === 'group' + ? `${currentChat.participants.length} members` + : 'Online' + } +

+
+
+ +
+ + + + +
+
+ + {/* Messages */} + +
+ {loading ? ( +
+
Loading messages...
+
+ ) : chatMessages.length === 0 ? ( +
+
+
👋
+

+ No messages yet. Start the conversation! +

+
+
+ ) : ( + chatMessages.map((message, index) => { + const isFromCurrentUser = isMessageFromCurrentUser(message) + const showAvatar = !isFromCurrentUser && ( + index === 0 || + chatMessages[index - 1]?.senderId !== message.senderId + ) + + return ( +
+
+ {showAvatar && !isFromCurrentUser && ( + + + + {getInitials(message.sender?.displayName || 'U')} + + + )} + +
+
+ {!isFromCurrentUser && showAvatar && currentChat.type === 'group' && ( +

+ {message.sender?.displayName} +

+ )} + +

{message.content}

+ +
+ + {formatTime(message.createdAt)} + + {isFromCurrentUser && ( + + {/* message delivery status could be rendered here */} + + )} +
+
+
+
+
+ ) + }) + )} +
+
+ + + {/* Message Input */} +
+
+ + +
+ setMessageText(e.target.value)} + onKeyPress={handleKeyPress} + className="pr-10" + /> + +
+ + +
+
+
+ ) +} diff --git a/client/src/components/ui/alert.tsx b/client/src/components/ui/alert.tsx new file mode 100644 index 0000000000000000000000000000000000000000..41fa7e0561a3fdb5f986c1213a35e563de740e96 --- /dev/null +++ b/client/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/client/src/components/ui/avatar.tsx b/client/src/components/ui/avatar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..991f56ecb117e96284bf0f6cad3b14ea2fdf5264 --- /dev/null +++ b/client/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/client/src/components/ui/badge.tsx b/client/src/components/ui/badge.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f000e3ef5176395b067dfc3f3e1256a80c450015 --- /dev/null +++ b/client/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0ba4277355fadde682ecc8399ee656838dc74049 --- /dev/null +++ b/client/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/client/src/components/ui/card.tsx b/client/src/components/ui/card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3964e8c2ec07e4140aded2bec57cce918a515536 --- /dev/null +++ b/client/src/components/ui/card.tsx @@ -0,0 +1,71 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/client/src/components/ui/input.tsx b/client/src/components/ui/input.tsx new file mode 100644 index 0000000000000000000000000000000000000000..677d05fd6c1c88366e8d0cdcdc9dcdea723a2ebb --- /dev/null +++ b/client/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/client/src/components/ui/label.tsx b/client/src/components/ui/label.tsx new file mode 100644 index 0000000000000000000000000000000000000000..683faa793819982d64e21cb2939666fd6d4a7b13 --- /dev/null +++ b/client/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/client/src/components/ui/scroll-area.tsx b/client/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cf253cf17056ce04827219643e484ea99c77cf6b --- /dev/null +++ b/client/src/components/ui/scroll-area.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/client/src/components/ui/separator.tsx b/client/src/components/ui/separator.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6d7f12265ba0338704f013930ce4d52c56527dd1 --- /dev/null +++ b/client/src/components/ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/client/src/components/ui/tabs.tsx b/client/src/components/ui/tabs.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f57fffdb5a07cd21b12f8c014513475a6980a469 --- /dev/null +++ b/client/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/client/src/components/ui/textarea.tsx b/client/src/components/ui/textarea.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9f9a6dc56193b728feda6d9b1a3d9302b8880f0b --- /dev/null +++ b/client/src/components/ui/textarea.tsx @@ -0,0 +1,24 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface TextareaProps + extends React.TextareaHTMLAttributes {} + +const Textarea = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +