Upload 60 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +21 -0
- Dockerfile +40 -0
- client/.env.example +4 -0
- client/index.html +13 -0
- client/package.json +57 -0
- client/postcss.config.js +6 -0
- client/src/App.tsx +89 -0
- client/src/components/LoadingSpinner.tsx +24 -0
- client/src/components/ProtectedRoute.tsx +31 -0
- client/src/components/chat/ChatSidebar.tsx +176 -0
- client/src/components/chat/ChatWindow.tsx +255 -0
- client/src/components/ui/alert.tsx +59 -0
- client/src/components/ui/avatar.tsx +48 -0
- client/src/components/ui/badge.tsx +36 -0
- client/src/components/ui/button.tsx +56 -0
- client/src/components/ui/card.tsx +71 -0
- client/src/components/ui/input.tsx +25 -0
- client/src/components/ui/label.tsx +24 -0
- client/src/components/ui/scroll-area.tsx +46 -0
- client/src/components/ui/separator.tsx +29 -0
- client/src/components/ui/tabs.tsx +53 -0
- client/src/components/ui/textarea.tsx +24 -0
- client/src/components/ui/toaster.tsx +135 -0
- client/src/index.css +123 -0
- client/src/lib/utils.ts +190 -0
- client/src/main.tsx +13 -0
- client/src/pages/AdminPage.tsx +304 -0
- client/src/pages/ChatPage.tsx +59 -0
- client/src/pages/LoginPage.tsx +109 -0
- client/src/pages/ProfilePage.tsx +173 -0
- client/src/pages/RegisterPage.tsx +204 -0
- client/src/services/api.ts +182 -0
- client/src/services/authService.ts +62 -0
- client/src/services/chatService.ts +136 -0
- client/src/services/socketService.ts +198 -0
- client/src/store/authStore.ts +130 -0
- client/src/store/chatStore.ts +285 -0
- client/src/vite-env.d.ts +10 -0
- client/tailwind.config.js +76 -0
- client/tsconfig.json +31 -0
- client/tsconfig.node.json +10 -0
- client/vite.config.ts +27 -0
- package.json +41 -0
- server/.env.example +50 -0
- server/package.json +70 -0
- server/prisma/schema.prisma +209 -0
- server/src/config/database.ts +69 -0
- server/src/index.ts +157 -0
- server/src/middleware/auth.ts +158 -0
- server/src/middleware/errorHandler.ts +104 -0
.dockerignore
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Ignore node modules and build output
|
2 |
+
node_modules
|
3 |
+
**/node_modules
|
4 |
+
|
5 |
+
# Logs
|
6 |
+
npm-debug.log
|
7 |
+
*.log
|
8 |
+
|
9 |
+
# Git
|
10 |
+
.git
|
11 |
+
.gitignore
|
12 |
+
|
13 |
+
# dotenv files
|
14 |
+
.env
|
15 |
+
*.env
|
16 |
+
|
17 |
+
# IDEs
|
18 |
+
.vscode
|
19 |
+
|
20 |
+
# local uploads
|
21 |
+
server/uploads
|
Dockerfile
ADDED
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Multi-stage build for ChatApp
|
2 |
+
|
3 |
+
# ----- Build Stage -----
|
4 |
+
FROM node:18 AS builder
|
5 |
+
WORKDIR /app
|
6 |
+
|
7 |
+
# Install root dependencies and workspaces
|
8 |
+
COPY package.json package-lock.json* ./
|
9 |
+
COPY client/package.json ./client/package.json
|
10 |
+
COPY server/package.json ./server/package.json
|
11 |
+
RUN npm install
|
12 |
+
|
13 |
+
# Copy source code
|
14 |
+
COPY . .
|
15 |
+
|
16 |
+
# Build client and server
|
17 |
+
RUN npm run build
|
18 |
+
|
19 |
+
# Remove development dependencies to reduce size
|
20 |
+
RUN npm prune --omit=dev --workspaces
|
21 |
+
|
22 |
+
# ----- Production Stage -----
|
23 |
+
FROM node:18-alpine AS runner
|
24 |
+
WORKDIR /app
|
25 |
+
|
26 |
+
# Copy built application from builder
|
27 |
+
COPY --from=builder /app/package.json ./
|
28 |
+
COPY --from=builder /app/client/package.json ./client/package.json
|
29 |
+
COPY --from=builder /app/server/package.json ./server/package.json
|
30 |
+
COPY --from=builder /app/client/dist ./client/dist
|
31 |
+
COPY --from=builder /app/server/dist ./server/dist
|
32 |
+
COPY --from=builder /app/client/node_modules ./client/node_modules
|
33 |
+
COPY --from=builder /app/server/node_modules ./server/node_modules
|
34 |
+
|
35 |
+
ENV NODE_ENV=production
|
36 |
+
ENV PORT=3001
|
37 |
+
|
38 |
+
EXPOSE 3001
|
39 |
+
|
40 |
+
CMD ["node", "server/dist/index.js"]
|
client/.env.example
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
VITE_API_URL=http://localhost:3001
|
2 |
+
VITE_SUPABASE_URL=your_supabase_url
|
3 |
+
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
|
4 |
+
VITE_SOCKET_URL=http://localhost:3001
|
client/index.html
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!doctype html>
|
2 |
+
<html lang="zh-CN">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8" />
|
5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7 |
+
<title>ChatApp - 现代化聊天应用</title>
|
8 |
+
</head>
|
9 |
+
<body>
|
10 |
+
<div id="root"></div>
|
11 |
+
<script type="module" src="/src/main.tsx"></script>
|
12 |
+
</body>
|
13 |
+
</html>
|
client/package.json
ADDED
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "chat-app-client",
|
3 |
+
"private": true,
|
4 |
+
"version": "0.0.0",
|
5 |
+
"type": "module",
|
6 |
+
"scripts": {
|
7 |
+
"dev": "vite",
|
8 |
+
"build": "tsc && vite build",
|
9 |
+
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
10 |
+
"preview": "vite preview",
|
11 |
+
"test": "vitest"
|
12 |
+
},
|
13 |
+
"dependencies": {
|
14 |
+
"react": "^18.2.0",
|
15 |
+
"react-dom": "^18.2.0",
|
16 |
+
"react-router-dom": "^6.20.1",
|
17 |
+
"socket.io-client": "^4.7.4",
|
18 |
+
"zustand": "^4.4.7",
|
19 |
+
"@supabase/supabase-js": "^2.38.4",
|
20 |
+
"axios": "^1.6.2",
|
21 |
+
"lucide-react": "^0.294.0",
|
22 |
+
"clsx": "^2.0.0",
|
23 |
+
"tailwind-merge": "^2.0.0",
|
24 |
+
"@radix-ui/react-avatar": "^1.1.10",
|
25 |
+
"@radix-ui/react-dialog": "^1.1.4",
|
26 |
+
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
27 |
+
"@radix-ui/react-label": "^2.1.1",
|
28 |
+
"@radix-ui/react-slot": "^1.2.3",
|
29 |
+
"@radix-ui/react-scroll-area": "^1.2.2",
|
30 |
+
"@radix-ui/react-separator": "^1.1.1",
|
31 |
+
"@radix-ui/react-tabs": "^1.1.3",
|
32 |
+
"@radix-ui/react-toast": "^1.2.4",
|
33 |
+
"class-variance-authority": "^0.7.0",
|
34 |
+
"react-hook-form": "^7.48.2",
|
35 |
+
"@hookform/resolvers": "^3.3.2",
|
36 |
+
"zod": "^3.22.4",
|
37 |
+
"date-fns": "^2.30.0",
|
38 |
+
"emoji-picker-react": "^4.5.16"
|
39 |
+
},
|
40 |
+
"devDependencies": {
|
41 |
+
"@types/react": "^18.2.37",
|
42 |
+
"@types/react-dom": "^18.2.15",
|
43 |
+
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
44 |
+
"@typescript-eslint/parser": "^6.10.0",
|
45 |
+
"@vitejs/plugin-react": "^4.1.1",
|
46 |
+
"autoprefixer": "^10.4.16",
|
47 |
+
"eslint": "^8.53.0",
|
48 |
+
"eslint-plugin-react-hooks": "^4.6.0",
|
49 |
+
"eslint-plugin-react-refresh": "^0.4.4",
|
50 |
+
"postcss": "^8.4.31",
|
51 |
+
"tailwindcss": "^3.3.5",
|
52 |
+
"tailwindcss-animate": "^1.0.7",
|
53 |
+
"typescript": "^5.2.2",
|
54 |
+
"vite": "^5.0.0",
|
55 |
+
"vitest": "^0.34.6"
|
56 |
+
}
|
57 |
+
}
|
client/postcss.config.js
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export default {
|
2 |
+
plugins: {
|
3 |
+
tailwindcss: {},
|
4 |
+
autoprefixer: {},
|
5 |
+
},
|
6 |
+
}
|
client/src/App.tsx
ADDED
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Routes, Route, Navigate } from 'react-router-dom'
|
2 |
+
import { useEffect } from 'react'
|
3 |
+
import { useAuthStore } from './store/authStore'
|
4 |
+
import { Toaster } from './components/ui/toaster'
|
5 |
+
|
6 |
+
// Pages
|
7 |
+
import LoginPage from './pages/LoginPage'
|
8 |
+
import RegisterPage from './pages/RegisterPage'
|
9 |
+
import ChatPage from './pages/ChatPage'
|
10 |
+
import AdminPage from './pages/AdminPage'
|
11 |
+
import ProfilePage from './pages/ProfilePage'
|
12 |
+
|
13 |
+
// Components
|
14 |
+
import ProtectedRoute from './components/ProtectedRoute'
|
15 |
+
import LoadingSpinner from './components/LoadingSpinner'
|
16 |
+
|
17 |
+
function App() {
|
18 |
+
const { user, loading, checkAuth } = useAuthStore()
|
19 |
+
|
20 |
+
useEffect(() => {
|
21 |
+
checkAuth()
|
22 |
+
}, [checkAuth])
|
23 |
+
|
24 |
+
if (loading) {
|
25 |
+
return (
|
26 |
+
<div className="min-h-screen flex items-center justify-center">
|
27 |
+
<LoadingSpinner size="lg" />
|
28 |
+
</div>
|
29 |
+
)
|
30 |
+
}
|
31 |
+
|
32 |
+
return (
|
33 |
+
<div className="min-h-screen bg-background">
|
34 |
+
<Routes>
|
35 |
+
{/* Public routes */}
|
36 |
+
<Route
|
37 |
+
path="/login"
|
38 |
+
element={user ? <Navigate to="/chat" replace /> : <LoginPage />}
|
39 |
+
/>
|
40 |
+
<Route
|
41 |
+
path="/register"
|
42 |
+
element={user ? <Navigate to="/chat" replace /> : <RegisterPage />}
|
43 |
+
/>
|
44 |
+
|
45 |
+
{/* Protected routes */}
|
46 |
+
<Route
|
47 |
+
path="/chat/*"
|
48 |
+
element={
|
49 |
+
<ProtectedRoute>
|
50 |
+
<ChatPage />
|
51 |
+
</ProtectedRoute>
|
52 |
+
}
|
53 |
+
/>
|
54 |
+
<Route
|
55 |
+
path="/profile"
|
56 |
+
element={
|
57 |
+
<ProtectedRoute>
|
58 |
+
<ProfilePage />
|
59 |
+
</ProtectedRoute>
|
60 |
+
}
|
61 |
+
/>
|
62 |
+
<Route
|
63 |
+
path="/admin/*"
|
64 |
+
element={
|
65 |
+
<ProtectedRoute requireAdmin>
|
66 |
+
<AdminPage />
|
67 |
+
</ProtectedRoute>
|
68 |
+
}
|
69 |
+
/>
|
70 |
+
|
71 |
+
{/* Default redirect */}
|
72 |
+
<Route
|
73 |
+
path="/"
|
74 |
+
element={<Navigate to={user ? "/chat" : "/login"} replace />}
|
75 |
+
/>
|
76 |
+
|
77 |
+
{/* 404 fallback */}
|
78 |
+
<Route
|
79 |
+
path="*"
|
80 |
+
element={<Navigate to={user ? "/chat" : "/login"} replace />}
|
81 |
+
/>
|
82 |
+
</Routes>
|
83 |
+
|
84 |
+
<Toaster />
|
85 |
+
</div>
|
86 |
+
)
|
87 |
+
}
|
88 |
+
|
89 |
+
export default App
|
client/src/components/LoadingSpinner.tsx
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { cn } from '@/lib/utils'
|
2 |
+
|
3 |
+
interface LoadingSpinnerProps {
|
4 |
+
size?: 'sm' | 'md' | 'lg'
|
5 |
+
className?: string
|
6 |
+
}
|
7 |
+
|
8 |
+
export default function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
|
9 |
+
const sizeClasses = {
|
10 |
+
sm: 'w-4 h-4',
|
11 |
+
md: 'w-6 h-6',
|
12 |
+
lg: 'w-8 h-8'
|
13 |
+
}
|
14 |
+
|
15 |
+
return (
|
16 |
+
<div
|
17 |
+
className={cn(
|
18 |
+
'animate-spin rounded-full border-2 border-gray-300 border-t-blue-600',
|
19 |
+
sizeClasses[size],
|
20 |
+
className
|
21 |
+
)}
|
22 |
+
/>
|
23 |
+
)
|
24 |
+
}
|
client/src/components/ProtectedRoute.tsx
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ReactNode } from 'react'
|
2 |
+
import { Navigate } from 'react-router-dom'
|
3 |
+
import { useAuthStore } from '@/store/authStore'
|
4 |
+
import LoadingSpinner from './LoadingSpinner'
|
5 |
+
|
6 |
+
interface ProtectedRouteProps {
|
7 |
+
children: ReactNode
|
8 |
+
requireAdmin?: boolean
|
9 |
+
}
|
10 |
+
|
11 |
+
export default function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) {
|
12 |
+
const { user, loading } = useAuthStore()
|
13 |
+
|
14 |
+
if (loading) {
|
15 |
+
return (
|
16 |
+
<div className="min-h-screen flex items-center justify-center">
|
17 |
+
<LoadingSpinner size="lg" />
|
18 |
+
</div>
|
19 |
+
)
|
20 |
+
}
|
21 |
+
|
22 |
+
if (!user) {
|
23 |
+
return <Navigate to="/login" replace />
|
24 |
+
}
|
25 |
+
|
26 |
+
if (requireAdmin && !user.isAdmin) {
|
27 |
+
return <Navigate to="/chat" replace />
|
28 |
+
}
|
29 |
+
|
30 |
+
return <>{children}</>
|
31 |
+
}
|
client/src/components/chat/ChatSidebar.tsx
ADDED
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState } from 'react'
|
2 |
+
import { useChatStore } from '@/store/chatStore'
|
3 |
+
import { useAuthStore } from '@/store/authStore'
|
4 |
+
import { Button } from '@/components/ui/button'
|
5 |
+
import { Input } from '@/components/ui/input'
|
6 |
+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
7 |
+
import { Badge } from '@/components/ui/badge'
|
8 |
+
import { ScrollArea } from '@/components/ui/scroll-area'
|
9 |
+
// import { Separator } from '@/components/ui/separator'
|
10 |
+
import {
|
11 |
+
Search,
|
12 |
+
Plus,
|
13 |
+
Settings,
|
14 |
+
MessageSquare,
|
15 |
+
Users,
|
16 |
+
// MoreVertical
|
17 |
+
} from 'lucide-react'
|
18 |
+
import { formatTime, getInitials } from '@/lib/utils'
|
19 |
+
|
20 |
+
export default function ChatSidebar() {
|
21 |
+
const { user } = useAuthStore()
|
22 |
+
const { chats, currentChat, selectChat, loading } = useChatStore()
|
23 |
+
const [searchQuery, setSearchQuery] = useState('')
|
24 |
+
|
25 |
+
const filteredChats = chats.filter(chat => {
|
26 |
+
if (!searchQuery) return true
|
27 |
+
|
28 |
+
const searchLower = searchQuery.toLowerCase()
|
29 |
+
|
30 |
+
// Search by chat name
|
31 |
+
if (chat.name?.toLowerCase().includes(searchLower)) return true
|
32 |
+
|
33 |
+
// Search by participant names (for direct chats)
|
34 |
+
if (chat.type === 'direct') {
|
35 |
+
const otherParticipant = chat.participants.find(p => p.userId !== user?.id)
|
36 |
+
if (otherParticipant?.user.displayName?.toLowerCase().includes(searchLower)) return true
|
37 |
+
}
|
38 |
+
|
39 |
+
return false
|
40 |
+
})
|
41 |
+
|
42 |
+
const getChatDisplayName = (chat: any) => {
|
43 |
+
if (chat.type === 'group') {
|
44 |
+
return chat.name || 'Unnamed Group'
|
45 |
+
} else {
|
46 |
+
const otherParticipant = chat.participants.find((p: any) => p.userId !== user?.id)
|
47 |
+
return otherParticipant?.user.displayName || 'Unknown User'
|
48 |
+
}
|
49 |
+
}
|
50 |
+
|
51 |
+
const getChatAvatar = (chat: any) => {
|
52 |
+
if (chat.type === 'group') {
|
53 |
+
return chat.avatar
|
54 |
+
} else {
|
55 |
+
const otherParticipant = chat.participants.find((p: any) => p.userId !== user?.id)
|
56 |
+
return otherParticipant?.user.avatar
|
57 |
+
}
|
58 |
+
}
|
59 |
+
|
60 |
+
return (
|
61 |
+
<div className="flex flex-col h-full bg-background">
|
62 |
+
{/* Header */}
|
63 |
+
<div className="p-4 border-b border-border">
|
64 |
+
<div className="flex items-center justify-between mb-4">
|
65 |
+
<h1 className="text-xl font-semibold">Chats</h1>
|
66 |
+
<div className="flex items-center space-x-2">
|
67 |
+
<Button variant="ghost" size="icon">
|
68 |
+
<Plus className="w-4 h-4" />
|
69 |
+
</Button>
|
70 |
+
<Button variant="ghost" size="icon">
|
71 |
+
<Settings className="w-4 h-4" />
|
72 |
+
</Button>
|
73 |
+
</div>
|
74 |
+
</div>
|
75 |
+
|
76 |
+
{/* Search */}
|
77 |
+
<div className="relative">
|
78 |
+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
79 |
+
<Input
|
80 |
+
placeholder="Search chats..."
|
81 |
+
value={searchQuery}
|
82 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
83 |
+
className="pl-10"
|
84 |
+
/>
|
85 |
+
</div>
|
86 |
+
</div>
|
87 |
+
|
88 |
+
{/* Chat List */}
|
89 |
+
<ScrollArea className="flex-1">
|
90 |
+
<div className="p-2">
|
91 |
+
{loading ? (
|
92 |
+
<div className="flex items-center justify-center py-8">
|
93 |
+
<div className="text-muted-foreground">Loading chats...</div>
|
94 |
+
</div>
|
95 |
+
) : filteredChats.length === 0 ? (
|
96 |
+
<div className="flex flex-col items-center justify-center py-8 text-center">
|
97 |
+
<MessageSquare className="w-12 h-12 text-muted-foreground mb-4" />
|
98 |
+
<h3 className="font-medium mb-2">No chats found</h3>
|
99 |
+
<p className="text-sm text-muted-foreground mb-4">
|
100 |
+
{searchQuery ? 'Try a different search term' : 'Start a new conversation'}
|
101 |
+
</p>
|
102 |
+
<Button size="sm">
|
103 |
+
<Plus className="w-4 h-4 mr-2" />
|
104 |
+
New Chat
|
105 |
+
</Button>
|
106 |
+
</div>
|
107 |
+
) : (
|
108 |
+
filteredChats.map((chat) => (
|
109 |
+
<div
|
110 |
+
key={chat.id}
|
111 |
+
onClick={() => selectChat(chat.id)}
|
112 |
+
className={`
|
113 |
+
flex items-center p-3 rounded-lg cursor-pointer transition-colors
|
114 |
+
hover:bg-accent hover:text-accent-foreground
|
115 |
+
${currentChat?.id === chat.id ? 'bg-accent text-accent-foreground' : ''}
|
116 |
+
`}
|
117 |
+
>
|
118 |
+
<div className="relative">
|
119 |
+
<Avatar className="w-12 h-12">
|
120 |
+
<AvatarImage src={getChatAvatar(chat)} />
|
121 |
+
<AvatarFallback>
|
122 |
+
{chat.type === 'group' ? (
|
123 |
+
<Users className="w-6 h-6" />
|
124 |
+
) : (
|
125 |
+
getInitials(getChatDisplayName(chat))
|
126 |
+
)}
|
127 |
+
</AvatarFallback>
|
128 |
+
</Avatar>
|
129 |
+
{/* Online indicator for direct chats */}
|
130 |
+
{chat.type === 'direct' && (
|
131 |
+
<div className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 border-2 border-background rounded-full" />
|
132 |
+
)}
|
133 |
+
</div>
|
134 |
+
|
135 |
+
<div className="flex-1 ml-3 min-w-0">
|
136 |
+
<div className="flex items-center justify-between">
|
137 |
+
<h3 className="font-medium truncate">
|
138 |
+
{getChatDisplayName(chat)}
|
139 |
+
</h3>
|
140 |
+
<div className="flex items-center space-x-2">
|
141 |
+
{chat.lastMessage && (
|
142 |
+
<span className="text-xs text-muted-foreground">
|
143 |
+
{formatTime(chat.lastMessage.createdAt)}
|
144 |
+
</span>
|
145 |
+
)}
|
146 |
+
{chat.unreadCount > 0 && (
|
147 |
+
<Badge variant="default" className="text-xs">
|
148 |
+
{chat.unreadCount > 99 ? '99+' : chat.unreadCount}
|
149 |
+
</Badge>
|
150 |
+
)}
|
151 |
+
</div>
|
152 |
+
</div>
|
153 |
+
|
154 |
+
{chat.lastMessage && (
|
155 |
+
<p className="text-sm text-muted-foreground truncate mt-1">
|
156 |
+
{chat.lastMessage.type === 'text'
|
157 |
+
? chat.lastMessage.content
|
158 |
+
: `📎 ${chat.lastMessage.type}`
|
159 |
+
}
|
160 |
+
</p>
|
161 |
+
)}
|
162 |
+
|
163 |
+
{chat.type === 'group' && (
|
164 |
+
<p className="text-xs text-muted-foreground mt-1">
|
165 |
+
{chat.participants.length} members
|
166 |
+
</p>
|
167 |
+
)}
|
168 |
+
</div>
|
169 |
+
</div>
|
170 |
+
))
|
171 |
+
)}
|
172 |
+
</div>
|
173 |
+
</ScrollArea>
|
174 |
+
</div>
|
175 |
+
)
|
176 |
+
}
|
client/src/components/chat/ChatWindow.tsx
ADDED
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useRef, useEffect } from 'react'
|
2 |
+
import { useChatStore } from '@/store/chatStore'
|
3 |
+
import { useAuthStore } from '@/store/authStore'
|
4 |
+
import { Button } from '@/components/ui/button'
|
5 |
+
import { Input } from '@/components/ui/input'
|
6 |
+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
7 |
+
import { ScrollArea } from '@/components/ui/scroll-area'
|
8 |
+
// import { Separator } from '@/components/ui/separator'
|
9 |
+
import {
|
10 |
+
Send,
|
11 |
+
Paperclip,
|
12 |
+
Smile,
|
13 |
+
Phone,
|
14 |
+
Video,
|
15 |
+
MoreVertical,
|
16 |
+
Users,
|
17 |
+
Info
|
18 |
+
} from 'lucide-react'
|
19 |
+
import { formatTime, getInitials } from '@/lib/utils'
|
20 |
+
|
21 |
+
export default function ChatWindow() {
|
22 |
+
const { user } = useAuthStore()
|
23 |
+
const { currentChat, messages, sendMessage, loading } = useChatStore()
|
24 |
+
const [messageText, setMessageText] = useState('')
|
25 |
+
// const [isTyping, setIsTyping] = useState(false)
|
26 |
+
const messagesEndRef = useRef<HTMLDivElement>(null)
|
27 |
+
const inputRef = useRef<HTMLInputElement>(null)
|
28 |
+
|
29 |
+
const chatMessages = currentChat ? messages[currentChat.id] || [] : []
|
30 |
+
|
31 |
+
useEffect(() => {
|
32 |
+
scrollToBottom()
|
33 |
+
}, [chatMessages])
|
34 |
+
|
35 |
+
const scrollToBottom = () => {
|
36 |
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
37 |
+
}
|
38 |
+
|
39 |
+
const handleSendMessage = async () => {
|
40 |
+
if (!messageText.trim() || !currentChat) return
|
41 |
+
|
42 |
+
try {
|
43 |
+
await sendMessage(currentChat.id, messageText.trim())
|
44 |
+
setMessageText('')
|
45 |
+
inputRef.current?.focus()
|
46 |
+
} catch (error) {
|
47 |
+
console.error('Failed to send message:', error)
|
48 |
+
}
|
49 |
+
}
|
50 |
+
|
51 |
+
const handleKeyPress = (e: React.KeyboardEvent) => {
|
52 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
53 |
+
e.preventDefault()
|
54 |
+
handleSendMessage()
|
55 |
+
}
|
56 |
+
}
|
57 |
+
|
58 |
+
const getChatDisplayName = () => {
|
59 |
+
if (!currentChat) return ''
|
60 |
+
|
61 |
+
if (currentChat.type === 'group') {
|
62 |
+
return currentChat.name || 'Unnamed Group'
|
63 |
+
} else {
|
64 |
+
const otherParticipant = currentChat.participants.find(p => p.userId !== user?.id)
|
65 |
+
return otherParticipant?.user.displayName || 'Unknown User'
|
66 |
+
}
|
67 |
+
}
|
68 |
+
|
69 |
+
const getChatAvatar = () => {
|
70 |
+
if (!currentChat) return undefined
|
71 |
+
|
72 |
+
if (currentChat.type === 'group') {
|
73 |
+
return currentChat.avatar
|
74 |
+
} else {
|
75 |
+
const otherParticipant = currentChat.participants.find(p => p.userId !== user?.id)
|
76 |
+
return otherParticipant?.user.avatar
|
77 |
+
}
|
78 |
+
}
|
79 |
+
|
80 |
+
const isMessageFromCurrentUser = (message: any) => {
|
81 |
+
return message.senderId === user?.id
|
82 |
+
}
|
83 |
+
|
84 |
+
if (!currentChat) {
|
85 |
+
return (
|
86 |
+
<div className="flex-1 flex items-center justify-center">
|
87 |
+
<div className="text-center">
|
88 |
+
<div className="text-6xl mb-4">💬</div>
|
89 |
+
<h2 className="text-2xl font-semibold mb-2">Welcome to ChatApp</h2>
|
90 |
+
<p className="text-muted-foreground">
|
91 |
+
Select a chat to start messaging
|
92 |
+
</p>
|
93 |
+
</div>
|
94 |
+
</div>
|
95 |
+
)
|
96 |
+
}
|
97 |
+
|
98 |
+
return (
|
99 |
+
<div className="flex flex-col h-full">
|
100 |
+
{/* Header */}
|
101 |
+
<div className="flex items-center justify-between p-4 border-b border-border">
|
102 |
+
<div className="flex items-center space-x-3">
|
103 |
+
<Avatar className="w-10 h-10">
|
104 |
+
<AvatarImage src={getChatAvatar()} />
|
105 |
+
<AvatarFallback>
|
106 |
+
{currentChat.type === 'group' ? (
|
107 |
+
<Users className="w-5 h-5" />
|
108 |
+
) : (
|
109 |
+
getInitials(getChatDisplayName())
|
110 |
+
)}
|
111 |
+
</AvatarFallback>
|
112 |
+
</Avatar>
|
113 |
+
|
114 |
+
<div>
|
115 |
+
<h2 className="font-semibold">{getChatDisplayName()}</h2>
|
116 |
+
<p className="text-sm text-muted-foreground">
|
117 |
+
{currentChat.type === 'group'
|
118 |
+
? `${currentChat.participants.length} members`
|
119 |
+
: 'Online'
|
120 |
+
}
|
121 |
+
</p>
|
122 |
+
</div>
|
123 |
+
</div>
|
124 |
+
|
125 |
+
<div className="flex items-center space-x-2">
|
126 |
+
<Button variant="ghost" size="icon">
|
127 |
+
<Phone className="w-4 h-4" />
|
128 |
+
</Button>
|
129 |
+
<Button variant="ghost" size="icon">
|
130 |
+
<Video className="w-4 h-4" />
|
131 |
+
</Button>
|
132 |
+
<Button variant="ghost" size="icon">
|
133 |
+
<Info className="w-4 h-4" />
|
134 |
+
</Button>
|
135 |
+
<Button variant="ghost" size="icon">
|
136 |
+
<MoreVertical className="w-4 h-4" />
|
137 |
+
</Button>
|
138 |
+
</div>
|
139 |
+
</div>
|
140 |
+
|
141 |
+
{/* Messages */}
|
142 |
+
<ScrollArea className="flex-1 p-4">
|
143 |
+
<div className="space-y-4">
|
144 |
+
{loading ? (
|
145 |
+
<div className="flex items-center justify-center py-8">
|
146 |
+
<div className="text-muted-foreground">Loading messages...</div>
|
147 |
+
</div>
|
148 |
+
) : chatMessages.length === 0 ? (
|
149 |
+
<div className="flex items-center justify-center py-8">
|
150 |
+
<div className="text-center">
|
151 |
+
<div className="text-4xl mb-4">👋</div>
|
152 |
+
<p className="text-muted-foreground">
|
153 |
+
No messages yet. Start the conversation!
|
154 |
+
</p>
|
155 |
+
</div>
|
156 |
+
</div>
|
157 |
+
) : (
|
158 |
+
chatMessages.map((message, index) => {
|
159 |
+
const isFromCurrentUser = isMessageFromCurrentUser(message)
|
160 |
+
const showAvatar = !isFromCurrentUser && (
|
161 |
+
index === 0 ||
|
162 |
+
chatMessages[index - 1]?.senderId !== message.senderId
|
163 |
+
)
|
164 |
+
|
165 |
+
return (
|
166 |
+
<div
|
167 |
+
key={message.id}
|
168 |
+
className={`flex ${isFromCurrentUser ? 'justify-end' : 'justify-start'}`}
|
169 |
+
>
|
170 |
+
<div className={`flex max-w-[70%] ${isFromCurrentUser ? 'flex-row-reverse' : 'flex-row'}`}>
|
171 |
+
{showAvatar && !isFromCurrentUser && (
|
172 |
+
<Avatar className="w-8 h-8 mr-2">
|
173 |
+
<AvatarImage src={message.sender?.avatar} />
|
174 |
+
<AvatarFallback>
|
175 |
+
{getInitials(message.sender?.displayName || 'U')}
|
176 |
+
</AvatarFallback>
|
177 |
+
</Avatar>
|
178 |
+
)}
|
179 |
+
|
180 |
+
<div className={`${!showAvatar && !isFromCurrentUser ? 'ml-10' : ''}`}>
|
181 |
+
<div
|
182 |
+
className={`
|
183 |
+
px-4 py-2 rounded-lg
|
184 |
+
${isFromCurrentUser
|
185 |
+
? 'bg-primary text-primary-foreground'
|
186 |
+
: 'bg-muted'
|
187 |
+
}
|
188 |
+
`}
|
189 |
+
>
|
190 |
+
{!isFromCurrentUser && showAvatar && currentChat.type === 'group' && (
|
191 |
+
<p className="text-xs font-medium mb-1 opacity-70">
|
192 |
+
{message.sender?.displayName}
|
193 |
+
</p>
|
194 |
+
)}
|
195 |
+
|
196 |
+
<p className="text-sm">{message.content}</p>
|
197 |
+
|
198 |
+
<div className={`flex items-center justify-end mt-1 space-x-1`}>
|
199 |
+
<span className="text-xs opacity-70">
|
200 |
+
{formatTime(message.createdAt)}
|
201 |
+
</span>
|
202 |
+
{isFromCurrentUser && (
|
203 |
+
<span className="text-xs opacity-70">
|
204 |
+
{/* message delivery status could be rendered here */}
|
205 |
+
</span>
|
206 |
+
)}
|
207 |
+
</div>
|
208 |
+
</div>
|
209 |
+
</div>
|
210 |
+
</div>
|
211 |
+
</div>
|
212 |
+
)
|
213 |
+
})
|
214 |
+
)}
|
215 |
+
<div ref={messagesEndRef} />
|
216 |
+
</div>
|
217 |
+
</ScrollArea>
|
218 |
+
|
219 |
+
{/* Message Input */}
|
220 |
+
<div className="p-4 border-t border-border">
|
221 |
+
<div className="flex items-center space-x-2">
|
222 |
+
<Button variant="ghost" size="icon">
|
223 |
+
<Paperclip className="w-4 h-4" />
|
224 |
+
</Button>
|
225 |
+
|
226 |
+
<div className="flex-1 relative">
|
227 |
+
<Input
|
228 |
+
ref={inputRef}
|
229 |
+
placeholder="Type a message..."
|
230 |
+
value={messageText}
|
231 |
+
onChange={(e) => setMessageText(e.target.value)}
|
232 |
+
onKeyPress={handleKeyPress}
|
233 |
+
className="pr-10"
|
234 |
+
/>
|
235 |
+
<Button
|
236 |
+
variant="ghost"
|
237 |
+
size="icon"
|
238 |
+
className="absolute right-1 top-1/2 transform -translate-y-1/2"
|
239 |
+
>
|
240 |
+
<Smile className="w-4 h-4" />
|
241 |
+
</Button>
|
242 |
+
</div>
|
243 |
+
|
244 |
+
<Button
|
245 |
+
onClick={handleSendMessage}
|
246 |
+
disabled={!messageText.trim()}
|
247 |
+
size="icon"
|
248 |
+
>
|
249 |
+
<Send className="w-4 h-4" />
|
250 |
+
</Button>
|
251 |
+
</div>
|
252 |
+
</div>
|
253 |
+
</div>
|
254 |
+
)
|
255 |
+
}
|
client/src/components/ui/alert.tsx
ADDED
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
3 |
+
|
4 |
+
import { cn } from "@/lib/utils"
|
5 |
+
|
6 |
+
const alertVariants = cva(
|
7 |
+
"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",
|
8 |
+
{
|
9 |
+
variants: {
|
10 |
+
variant: {
|
11 |
+
default: "bg-background text-foreground",
|
12 |
+
destructive:
|
13 |
+
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
14 |
+
},
|
15 |
+
},
|
16 |
+
defaultVariants: {
|
17 |
+
variant: "default",
|
18 |
+
},
|
19 |
+
}
|
20 |
+
)
|
21 |
+
|
22 |
+
const Alert = React.forwardRef<
|
23 |
+
HTMLDivElement,
|
24 |
+
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
25 |
+
>(({ className, variant, ...props }, ref) => (
|
26 |
+
<div
|
27 |
+
ref={ref}
|
28 |
+
role="alert"
|
29 |
+
className={cn(alertVariants({ variant }), className)}
|
30 |
+
{...props}
|
31 |
+
/>
|
32 |
+
))
|
33 |
+
Alert.displayName = "Alert"
|
34 |
+
|
35 |
+
const AlertTitle = React.forwardRef<
|
36 |
+
HTMLParagraphElement,
|
37 |
+
React.HTMLAttributes<HTMLHeadingElement>
|
38 |
+
>(({ className, ...props }, ref) => (
|
39 |
+
<h5
|
40 |
+
ref={ref}
|
41 |
+
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
42 |
+
{...props}
|
43 |
+
/>
|
44 |
+
))
|
45 |
+
AlertTitle.displayName = "AlertTitle"
|
46 |
+
|
47 |
+
const AlertDescription = React.forwardRef<
|
48 |
+
HTMLParagraphElement,
|
49 |
+
React.HTMLAttributes<HTMLParagraphElement>
|
50 |
+
>(({ className, ...props }, ref) => (
|
51 |
+
<div
|
52 |
+
ref={ref}
|
53 |
+
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
54 |
+
{...props}
|
55 |
+
/>
|
56 |
+
))
|
57 |
+
AlertDescription.displayName = "AlertDescription"
|
58 |
+
|
59 |
+
export { Alert, AlertTitle, AlertDescription }
|
client/src/components/ui/avatar.tsx
ADDED
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
3 |
+
|
4 |
+
import { cn } from "@/lib/utils"
|
5 |
+
|
6 |
+
const Avatar = React.forwardRef<
|
7 |
+
React.ElementRef<typeof AvatarPrimitive.Root>,
|
8 |
+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
9 |
+
>(({ className, ...props }, ref) => (
|
10 |
+
<AvatarPrimitive.Root
|
11 |
+
ref={ref}
|
12 |
+
className={cn(
|
13 |
+
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
14 |
+
className
|
15 |
+
)}
|
16 |
+
{...props}
|
17 |
+
/>
|
18 |
+
))
|
19 |
+
Avatar.displayName = AvatarPrimitive.Root.displayName
|
20 |
+
|
21 |
+
const AvatarImage = React.forwardRef<
|
22 |
+
React.ElementRef<typeof AvatarPrimitive.Image>,
|
23 |
+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
24 |
+
>(({ className, ...props }, ref) => (
|
25 |
+
<AvatarPrimitive.Image
|
26 |
+
ref={ref}
|
27 |
+
className={cn("aspect-square h-full w-full", className)}
|
28 |
+
{...props}
|
29 |
+
/>
|
30 |
+
))
|
31 |
+
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
32 |
+
|
33 |
+
const AvatarFallback = React.forwardRef<
|
34 |
+
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
35 |
+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
36 |
+
>(({ className, ...props }, ref) => (
|
37 |
+
<AvatarPrimitive.Fallback
|
38 |
+
ref={ref}
|
39 |
+
className={cn(
|
40 |
+
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
41 |
+
className
|
42 |
+
)}
|
43 |
+
{...props}
|
44 |
+
/>
|
45 |
+
))
|
46 |
+
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
47 |
+
|
48 |
+
export { Avatar, AvatarImage, AvatarFallback }
|
client/src/components/ui/badge.tsx
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
3 |
+
|
4 |
+
import { cn } from "@/lib/utils"
|
5 |
+
|
6 |
+
const badgeVariants = cva(
|
7 |
+
"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",
|
8 |
+
{
|
9 |
+
variants: {
|
10 |
+
variant: {
|
11 |
+
default:
|
12 |
+
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
13 |
+
secondary:
|
14 |
+
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
15 |
+
destructive:
|
16 |
+
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
17 |
+
outline: "text-foreground",
|
18 |
+
},
|
19 |
+
},
|
20 |
+
defaultVariants: {
|
21 |
+
variant: "default",
|
22 |
+
},
|
23 |
+
}
|
24 |
+
)
|
25 |
+
|
26 |
+
export interface BadgeProps
|
27 |
+
extends React.HTMLAttributes<HTMLDivElement>,
|
28 |
+
VariantProps<typeof badgeVariants> {}
|
29 |
+
|
30 |
+
function Badge({ className, variant, ...props }: BadgeProps) {
|
31 |
+
return (
|
32 |
+
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
33 |
+
)
|
34 |
+
}
|
35 |
+
|
36 |
+
export { Badge, badgeVariants }
|
client/src/components/ui/button.tsx
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import { Slot } from "@radix-ui/react-slot"
|
3 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
4 |
+
|
5 |
+
import { cn } from "@/lib/utils"
|
6 |
+
|
7 |
+
const buttonVariants = cva(
|
8 |
+
"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",
|
9 |
+
{
|
10 |
+
variants: {
|
11 |
+
variant: {
|
12 |
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
13 |
+
destructive:
|
14 |
+
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
15 |
+
outline:
|
16 |
+
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
17 |
+
secondary:
|
18 |
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
19 |
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
20 |
+
link: "text-primary underline-offset-4 hover:underline",
|
21 |
+
},
|
22 |
+
size: {
|
23 |
+
default: "h-10 px-4 py-2",
|
24 |
+
sm: "h-9 rounded-md px-3",
|
25 |
+
lg: "h-11 rounded-md px-8",
|
26 |
+
icon: "h-10 w-10",
|
27 |
+
},
|
28 |
+
},
|
29 |
+
defaultVariants: {
|
30 |
+
variant: "default",
|
31 |
+
size: "default",
|
32 |
+
},
|
33 |
+
}
|
34 |
+
)
|
35 |
+
|
36 |
+
export interface ButtonProps
|
37 |
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
38 |
+
VariantProps<typeof buttonVariants> {
|
39 |
+
asChild?: boolean
|
40 |
+
}
|
41 |
+
|
42 |
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
43 |
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
44 |
+
const Comp = asChild ? Slot : "button"
|
45 |
+
return (
|
46 |
+
<Comp
|
47 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
48 |
+
ref={ref}
|
49 |
+
{...props}
|
50 |
+
/>
|
51 |
+
)
|
52 |
+
}
|
53 |
+
)
|
54 |
+
Button.displayName = "Button"
|
55 |
+
|
56 |
+
export { Button, buttonVariants }
|
client/src/components/ui/card.tsx
ADDED
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
|
3 |
+
import { cn } from "@/lib/utils"
|
4 |
+
|
5 |
+
const Card = React.forwardRef<
|
6 |
+
HTMLDivElement,
|
7 |
+
React.HTMLAttributes<HTMLDivElement>
|
8 |
+
>(({ className, ...props }, ref) => (
|
9 |
+
<div
|
10 |
+
ref={ref}
|
11 |
+
className={cn(
|
12 |
+
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
13 |
+
className
|
14 |
+
)}
|
15 |
+
{...props}
|
16 |
+
/>
|
17 |
+
))
|
18 |
+
Card.displayName = "Card"
|
19 |
+
|
20 |
+
const CardHeader = React.forwardRef<
|
21 |
+
HTMLDivElement,
|
22 |
+
React.HTMLAttributes<HTMLDivElement>
|
23 |
+
>(({ className, ...props }, ref) => (
|
24 |
+
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
25 |
+
))
|
26 |
+
CardHeader.displayName = "CardHeader"
|
27 |
+
|
28 |
+
const CardTitle = React.forwardRef<
|
29 |
+
HTMLParagraphElement,
|
30 |
+
React.HTMLAttributes<HTMLHeadingElement>
|
31 |
+
>(({ className, ...props }, ref) => (
|
32 |
+
<h3
|
33 |
+
ref={ref}
|
34 |
+
className={cn(
|
35 |
+
"text-2xl font-semibold leading-none tracking-tight",
|
36 |
+
className
|
37 |
+
)}
|
38 |
+
{...props}
|
39 |
+
/>
|
40 |
+
))
|
41 |
+
CardTitle.displayName = "CardTitle"
|
42 |
+
|
43 |
+
const CardDescription = React.forwardRef<
|
44 |
+
HTMLParagraphElement,
|
45 |
+
React.HTMLAttributes<HTMLParagraphElement>
|
46 |
+
>(({ className, ...props }, ref) => (
|
47 |
+
<p
|
48 |
+
ref={ref}
|
49 |
+
className={cn("text-sm text-muted-foreground", className)}
|
50 |
+
{...props}
|
51 |
+
/>
|
52 |
+
))
|
53 |
+
CardDescription.displayName = "CardDescription"
|
54 |
+
|
55 |
+
const CardContent = React.forwardRef<
|
56 |
+
HTMLDivElement,
|
57 |
+
React.HTMLAttributes<HTMLDivElement>
|
58 |
+
>(({ className, ...props }, ref) => (
|
59 |
+
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
60 |
+
))
|
61 |
+
CardContent.displayName = "CardContent"
|
62 |
+
|
63 |
+
const CardFooter = React.forwardRef<
|
64 |
+
HTMLDivElement,
|
65 |
+
React.HTMLAttributes<HTMLDivElement>
|
66 |
+
>(({ className, ...props }, ref) => (
|
67 |
+
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
68 |
+
))
|
69 |
+
CardFooter.displayName = "CardFooter"
|
70 |
+
|
71 |
+
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
client/src/components/ui/input.tsx
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
|
3 |
+
import { cn } from "@/lib/utils"
|
4 |
+
|
5 |
+
export interface InputProps
|
6 |
+
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
7 |
+
|
8 |
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
9 |
+
({ className, type, ...props }, ref) => {
|
10 |
+
return (
|
11 |
+
<input
|
12 |
+
type={type}
|
13 |
+
className={cn(
|
14 |
+
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
15 |
+
className
|
16 |
+
)}
|
17 |
+
ref={ref}
|
18 |
+
{...props}
|
19 |
+
/>
|
20 |
+
)
|
21 |
+
}
|
22 |
+
)
|
23 |
+
Input.displayName = "Input"
|
24 |
+
|
25 |
+
export { Input }
|
client/src/components/ui/label.tsx
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import * as LabelPrimitive from "@radix-ui/react-label"
|
3 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
4 |
+
|
5 |
+
import { cn } from "@/lib/utils"
|
6 |
+
|
7 |
+
const labelVariants = cva(
|
8 |
+
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
9 |
+
)
|
10 |
+
|
11 |
+
const Label = React.forwardRef<
|
12 |
+
React.ElementRef<typeof LabelPrimitive.Root>,
|
13 |
+
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
14 |
+
VariantProps<typeof labelVariants>
|
15 |
+
>(({ className, ...props }, ref) => (
|
16 |
+
<LabelPrimitive.Root
|
17 |
+
ref={ref}
|
18 |
+
className={cn(labelVariants(), className)}
|
19 |
+
{...props}
|
20 |
+
/>
|
21 |
+
))
|
22 |
+
Label.displayName = LabelPrimitive.Root.displayName
|
23 |
+
|
24 |
+
export { Label }
|
client/src/components/ui/scroll-area.tsx
ADDED
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
3 |
+
|
4 |
+
import { cn } from "@/lib/utils"
|
5 |
+
|
6 |
+
const ScrollArea = React.forwardRef<
|
7 |
+
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
8 |
+
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
9 |
+
>(({ className, children, ...props }, ref) => (
|
10 |
+
<ScrollAreaPrimitive.Root
|
11 |
+
ref={ref}
|
12 |
+
className={cn("relative overflow-hidden", className)}
|
13 |
+
{...props}
|
14 |
+
>
|
15 |
+
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
16 |
+
{children}
|
17 |
+
</ScrollAreaPrimitive.Viewport>
|
18 |
+
<ScrollBar />
|
19 |
+
<ScrollAreaPrimitive.Corner />
|
20 |
+
</ScrollAreaPrimitive.Root>
|
21 |
+
))
|
22 |
+
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
23 |
+
|
24 |
+
const ScrollBar = React.forwardRef<
|
25 |
+
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
26 |
+
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
27 |
+
>(({ className, orientation = "vertical", ...props }, ref) => (
|
28 |
+
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
29 |
+
ref={ref}
|
30 |
+
orientation={orientation}
|
31 |
+
className={cn(
|
32 |
+
"flex touch-none select-none transition-colors",
|
33 |
+
orientation === "vertical" &&
|
34 |
+
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
35 |
+
orientation === "horizontal" &&
|
36 |
+
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
37 |
+
className
|
38 |
+
)}
|
39 |
+
{...props}
|
40 |
+
>
|
41 |
+
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
42 |
+
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
43 |
+
))
|
44 |
+
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
45 |
+
|
46 |
+
export { ScrollArea, ScrollBar }
|
client/src/components/ui/separator.tsx
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
3 |
+
|
4 |
+
import { cn } from "@/lib/utils"
|
5 |
+
|
6 |
+
const Separator = React.forwardRef<
|
7 |
+
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
8 |
+
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
9 |
+
>(
|
10 |
+
(
|
11 |
+
{ className, orientation = "horizontal", decorative = true, ...props },
|
12 |
+
ref
|
13 |
+
) => (
|
14 |
+
<SeparatorPrimitive.Root
|
15 |
+
ref={ref}
|
16 |
+
decorative={decorative}
|
17 |
+
orientation={orientation}
|
18 |
+
className={cn(
|
19 |
+
"shrink-0 bg-border",
|
20 |
+
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
21 |
+
className
|
22 |
+
)}
|
23 |
+
{...props}
|
24 |
+
/>
|
25 |
+
)
|
26 |
+
)
|
27 |
+
Separator.displayName = SeparatorPrimitive.Root.displayName
|
28 |
+
|
29 |
+
export { Separator }
|
client/src/components/ui/tabs.tsx
ADDED
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
3 |
+
|
4 |
+
import { cn } from "@/lib/utils"
|
5 |
+
|
6 |
+
const Tabs = TabsPrimitive.Root
|
7 |
+
|
8 |
+
const TabsList = React.forwardRef<
|
9 |
+
React.ElementRef<typeof TabsPrimitive.List>,
|
10 |
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
11 |
+
>(({ className, ...props }, ref) => (
|
12 |
+
<TabsPrimitive.List
|
13 |
+
ref={ref}
|
14 |
+
className={cn(
|
15 |
+
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
16 |
+
className
|
17 |
+
)}
|
18 |
+
{...props}
|
19 |
+
/>
|
20 |
+
))
|
21 |
+
TabsList.displayName = TabsPrimitive.List.displayName
|
22 |
+
|
23 |
+
const TabsTrigger = React.forwardRef<
|
24 |
+
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
25 |
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
26 |
+
>(({ className, ...props }, ref) => (
|
27 |
+
<TabsPrimitive.Trigger
|
28 |
+
ref={ref}
|
29 |
+
className={cn(
|
30 |
+
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
31 |
+
className
|
32 |
+
)}
|
33 |
+
{...props}
|
34 |
+
/>
|
35 |
+
))
|
36 |
+
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
37 |
+
|
38 |
+
const TabsContent = React.forwardRef<
|
39 |
+
React.ElementRef<typeof TabsPrimitive.Content>,
|
40 |
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
41 |
+
>(({ className, ...props }, ref) => (
|
42 |
+
<TabsPrimitive.Content
|
43 |
+
ref={ref}
|
44 |
+
className={cn(
|
45 |
+
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
46 |
+
className
|
47 |
+
)}
|
48 |
+
{...props}
|
49 |
+
/>
|
50 |
+
))
|
51 |
+
TabsContent.displayName = TabsPrimitive.Content.displayName
|
52 |
+
|
53 |
+
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
client/src/components/ui/textarea.tsx
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
|
3 |
+
import { cn } from "@/lib/utils"
|
4 |
+
|
5 |
+
export interface TextareaProps
|
6 |
+
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
7 |
+
|
8 |
+
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
9 |
+
({ className, ...props }, ref) => {
|
10 |
+
return (
|
11 |
+
<textarea
|
12 |
+
className={cn(
|
13 |
+
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
14 |
+
className
|
15 |
+
)}
|
16 |
+
ref={ref}
|
17 |
+
{...props}
|
18 |
+
/>
|
19 |
+
)
|
20 |
+
}
|
21 |
+
)
|
22 |
+
Textarea.displayName = "Textarea"
|
23 |
+
|
24 |
+
export { Textarea }
|
client/src/components/ui/toaster.tsx
ADDED
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import * as ToastPrimitives from "@radix-ui/react-toast"
|
3 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
4 |
+
import { X } from "lucide-react"
|
5 |
+
|
6 |
+
import { cn } from "@/lib/utils"
|
7 |
+
|
8 |
+
const ToastProvider = ToastPrimitives.Provider
|
9 |
+
|
10 |
+
const ToastViewport = React.forwardRef<
|
11 |
+
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
12 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
13 |
+
>(({ className, ...props }, ref) => (
|
14 |
+
<ToastPrimitives.Viewport
|
15 |
+
ref={ref}
|
16 |
+
className={cn(
|
17 |
+
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
18 |
+
className
|
19 |
+
)}
|
20 |
+
{...props}
|
21 |
+
/>
|
22 |
+
))
|
23 |
+
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
24 |
+
|
25 |
+
const toastVariants = cva(
|
26 |
+
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
27 |
+
{
|
28 |
+
variants: {
|
29 |
+
variant: {
|
30 |
+
default: "border bg-background text-foreground",
|
31 |
+
destructive:
|
32 |
+
"destructive border-destructive bg-destructive text-destructive-foreground",
|
33 |
+
},
|
34 |
+
},
|
35 |
+
defaultVariants: {
|
36 |
+
variant: "default",
|
37 |
+
},
|
38 |
+
}
|
39 |
+
)
|
40 |
+
|
41 |
+
const Toast = React.forwardRef<
|
42 |
+
React.ElementRef<typeof ToastPrimitives.Root>,
|
43 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
44 |
+
VariantProps<typeof toastVariants>
|
45 |
+
>(({ className, variant, ...props }, ref) => {
|
46 |
+
return (
|
47 |
+
<ToastPrimitives.Root
|
48 |
+
ref={ref}
|
49 |
+
className={cn(toastVariants({ variant }), className)}
|
50 |
+
{...props}
|
51 |
+
/>
|
52 |
+
)
|
53 |
+
})
|
54 |
+
Toast.displayName = ToastPrimitives.Root.displayName
|
55 |
+
|
56 |
+
const ToastAction = React.forwardRef<
|
57 |
+
React.ElementRef<typeof ToastPrimitives.Action>,
|
58 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
59 |
+
>(({ className, ...props }, ref) => (
|
60 |
+
<ToastPrimitives.Action
|
61 |
+
ref={ref}
|
62 |
+
className={cn(
|
63 |
+
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
64 |
+
className
|
65 |
+
)}
|
66 |
+
{...props}
|
67 |
+
/>
|
68 |
+
))
|
69 |
+
ToastAction.displayName = ToastPrimitives.Action.displayName
|
70 |
+
|
71 |
+
const ToastClose = React.forwardRef<
|
72 |
+
React.ElementRef<typeof ToastPrimitives.Close>,
|
73 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
74 |
+
>(({ className, ...props }, ref) => (
|
75 |
+
<ToastPrimitives.Close
|
76 |
+
ref={ref}
|
77 |
+
className={cn(
|
78 |
+
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
79 |
+
className
|
80 |
+
)}
|
81 |
+
toast-close=""
|
82 |
+
{...props}
|
83 |
+
>
|
84 |
+
<X className="h-4 w-4" />
|
85 |
+
</ToastPrimitives.Close>
|
86 |
+
))
|
87 |
+
ToastClose.displayName = ToastPrimitives.Close.displayName
|
88 |
+
|
89 |
+
const ToastTitle = React.forwardRef<
|
90 |
+
React.ElementRef<typeof ToastPrimitives.Title>,
|
91 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
92 |
+
>(({ className, ...props }, ref) => (
|
93 |
+
<ToastPrimitives.Title
|
94 |
+
ref={ref}
|
95 |
+
className={cn("text-sm font-semibold", className)}
|
96 |
+
{...props}
|
97 |
+
/>
|
98 |
+
))
|
99 |
+
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
100 |
+
|
101 |
+
const ToastDescription = React.forwardRef<
|
102 |
+
React.ElementRef<typeof ToastPrimitives.Description>,
|
103 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
104 |
+
>(({ className, ...props }, ref) => (
|
105 |
+
<ToastPrimitives.Description
|
106 |
+
ref={ref}
|
107 |
+
className={cn("text-sm opacity-90", className)}
|
108 |
+
{...props}
|
109 |
+
/>
|
110 |
+
))
|
111 |
+
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
112 |
+
|
113 |
+
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
114 |
+
|
115 |
+
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
116 |
+
|
117 |
+
export {
|
118 |
+
type ToastProps,
|
119 |
+
type ToastActionElement,
|
120 |
+
ToastProvider,
|
121 |
+
ToastViewport,
|
122 |
+
Toast,
|
123 |
+
ToastTitle,
|
124 |
+
ToastDescription,
|
125 |
+
ToastClose,
|
126 |
+
ToastAction,
|
127 |
+
}
|
128 |
+
|
129 |
+
export function Toaster() {
|
130 |
+
return (
|
131 |
+
<ToastProvider>
|
132 |
+
<ToastViewport />
|
133 |
+
</ToastProvider>
|
134 |
+
)
|
135 |
+
}
|
client/src/index.css
ADDED
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
@tailwind base;
|
2 |
+
@tailwind components;
|
3 |
+
@tailwind utilities;
|
4 |
+
|
5 |
+
@layer base {
|
6 |
+
:root {
|
7 |
+
--background: 0 0% 100%;
|
8 |
+
--foreground: 222.2 84% 4.9%;
|
9 |
+
--card: 0 0% 100%;
|
10 |
+
--card-foreground: 222.2 84% 4.9%;
|
11 |
+
--popover: 0 0% 100%;
|
12 |
+
--popover-foreground: 222.2 84% 4.9%;
|
13 |
+
--primary: 221.2 83.2% 53.3%;
|
14 |
+
--primary-foreground: 210 40% 98%;
|
15 |
+
--secondary: 210 40% 96%;
|
16 |
+
--secondary-foreground: 222.2 84% 4.9%;
|
17 |
+
--muted: 210 40% 96%;
|
18 |
+
--muted-foreground: 215.4 16.3% 46.9%;
|
19 |
+
--accent: 210 40% 96%;
|
20 |
+
--accent-foreground: 222.2 84% 4.9%;
|
21 |
+
--destructive: 0 84.2% 60.2%;
|
22 |
+
--destructive-foreground: 210 40% 98%;
|
23 |
+
--border: 214.3 31.8% 91.4%;
|
24 |
+
--input: 214.3 31.8% 91.4%;
|
25 |
+
--ring: 221.2 83.2% 53.3%;
|
26 |
+
--radius: 0.5rem;
|
27 |
+
}
|
28 |
+
|
29 |
+
.dark {
|
30 |
+
--background: 222.2 84% 4.9%;
|
31 |
+
--foreground: 210 40% 98%;
|
32 |
+
--card: 222.2 84% 4.9%;
|
33 |
+
--card-foreground: 210 40% 98%;
|
34 |
+
--popover: 222.2 84% 4.9%;
|
35 |
+
--popover-foreground: 210 40% 98%;
|
36 |
+
--primary: 217.2 91.2% 59.8%;
|
37 |
+
--primary-foreground: 222.2 84% 4.9%;
|
38 |
+
--secondary: 217.2 32.6% 17.5%;
|
39 |
+
--secondary-foreground: 210 40% 98%;
|
40 |
+
--muted: 217.2 32.6% 17.5%;
|
41 |
+
--muted-foreground: 215 20.2% 65.1%;
|
42 |
+
--accent: 217.2 32.6% 17.5%;
|
43 |
+
--accent-foreground: 210 40% 98%;
|
44 |
+
--destructive: 0 62.8% 30.6%;
|
45 |
+
--destructive-foreground: 210 40% 98%;
|
46 |
+
--border: 217.2 32.6% 17.5%;
|
47 |
+
--input: 217.2 32.6% 17.5%;
|
48 |
+
--ring: 224.3 76.3% 94.1%;
|
49 |
+
}
|
50 |
+
}
|
51 |
+
|
52 |
+
@layer base {
|
53 |
+
* {
|
54 |
+
@apply border-border;
|
55 |
+
}
|
56 |
+
body {
|
57 |
+
@apply bg-background text-foreground;
|
58 |
+
}
|
59 |
+
}
|
60 |
+
|
61 |
+
/* Custom scrollbar */
|
62 |
+
::-webkit-scrollbar {
|
63 |
+
width: 6px;
|
64 |
+
}
|
65 |
+
|
66 |
+
::-webkit-scrollbar-track {
|
67 |
+
background: transparent;
|
68 |
+
}
|
69 |
+
|
70 |
+
::-webkit-scrollbar-thumb {
|
71 |
+
background: hsl(var(--muted-foreground) / 0.3);
|
72 |
+
border-radius: 3px;
|
73 |
+
}
|
74 |
+
|
75 |
+
::-webkit-scrollbar-thumb:hover {
|
76 |
+
background: hsl(var(--muted-foreground) / 0.5);
|
77 |
+
}
|
78 |
+
|
79 |
+
/* Chat message animations */
|
80 |
+
.message-enter {
|
81 |
+
opacity: 0;
|
82 |
+
transform: translateY(10px);
|
83 |
+
}
|
84 |
+
|
85 |
+
.message-enter-active {
|
86 |
+
opacity: 1;
|
87 |
+
transform: translateY(0);
|
88 |
+
transition: opacity 200ms, transform 200ms;
|
89 |
+
}
|
90 |
+
|
91 |
+
/* Typing indicator animation */
|
92 |
+
.typing-dots {
|
93 |
+
display: inline-block;
|
94 |
+
}
|
95 |
+
|
96 |
+
.typing-dots span {
|
97 |
+
display: inline-block;
|
98 |
+
width: 4px;
|
99 |
+
height: 4px;
|
100 |
+
border-radius: 50%;
|
101 |
+
background-color: hsl(var(--muted-foreground));
|
102 |
+
margin: 0 1px;
|
103 |
+
animation: typing 1.4s infinite ease-in-out;
|
104 |
+
}
|
105 |
+
|
106 |
+
.typing-dots span:nth-child(1) {
|
107 |
+
animation-delay: -0.32s;
|
108 |
+
}
|
109 |
+
|
110 |
+
.typing-dots span:nth-child(2) {
|
111 |
+
animation-delay: -0.16s;
|
112 |
+
}
|
113 |
+
|
114 |
+
@keyframes typing {
|
115 |
+
0%, 80%, 100% {
|
116 |
+
transform: scale(0.8);
|
117 |
+
opacity: 0.5;
|
118 |
+
}
|
119 |
+
40% {
|
120 |
+
transform: scale(1);
|
121 |
+
opacity: 1;
|
122 |
+
}
|
123 |
+
}
|
client/src/lib/utils.ts
ADDED
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { type ClassValue, clsx } from "clsx"
|
2 |
+
import { twMerge } from "tailwind-merge"
|
3 |
+
|
4 |
+
export function cn(...inputs: ClassValue[]) {
|
5 |
+
return twMerge(clsx(inputs))
|
6 |
+
}
|
7 |
+
|
8 |
+
export function formatDate(date: Date | string): string {
|
9 |
+
const d = new Date(date)
|
10 |
+
const now = new Date()
|
11 |
+
const diff = now.getTime() - d.getTime()
|
12 |
+
|
13 |
+
// Less than 1 minute
|
14 |
+
if (diff < 60000) {
|
15 |
+
return 'Just now'
|
16 |
+
}
|
17 |
+
|
18 |
+
// Less than 1 hour
|
19 |
+
if (diff < 3600000) {
|
20 |
+
const minutes = Math.floor(diff / 60000)
|
21 |
+
return `${minutes}m ago`
|
22 |
+
}
|
23 |
+
|
24 |
+
// Less than 24 hours
|
25 |
+
if (diff < 86400000) {
|
26 |
+
const hours = Math.floor(diff / 3600000)
|
27 |
+
return `${hours}h ago`
|
28 |
+
}
|
29 |
+
|
30 |
+
// Less than 7 days
|
31 |
+
if (diff < 604800000) {
|
32 |
+
const days = Math.floor(diff / 86400000)
|
33 |
+
return `${days}d ago`
|
34 |
+
}
|
35 |
+
|
36 |
+
// More than 7 days
|
37 |
+
return d.toLocaleDateString()
|
38 |
+
}
|
39 |
+
|
40 |
+
export function formatTime(date: Date | string): string {
|
41 |
+
const d = new Date(date)
|
42 |
+
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
43 |
+
}
|
44 |
+
|
45 |
+
export function formatFileSize(bytes: number): string {
|
46 |
+
if (bytes === 0) return '0 Bytes'
|
47 |
+
|
48 |
+
const k = 1024
|
49 |
+
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
50 |
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
51 |
+
|
52 |
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
53 |
+
}
|
54 |
+
|
55 |
+
export function getFileIcon(mimeType: string): string {
|
56 |
+
if (mimeType.startsWith('image/')) return '🖼️'
|
57 |
+
if (mimeType.startsWith('video/')) return '🎥'
|
58 |
+
if (mimeType.startsWith('audio/')) return '🎵'
|
59 |
+
if (mimeType.includes('pdf')) return '📄'
|
60 |
+
if (mimeType.includes('word')) return '📝'
|
61 |
+
if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return '📊'
|
62 |
+
if (mimeType.includes('powerpoint') || mimeType.includes('presentation')) return '📽️'
|
63 |
+
if (mimeType.includes('zip') || mimeType.includes('rar') || mimeType.includes('archive')) return '📦'
|
64 |
+
return '📎'
|
65 |
+
}
|
66 |
+
|
67 |
+
export function generateAvatar(name: string): string {
|
68 |
+
const colors = [
|
69 |
+
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
|
70 |
+
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9'
|
71 |
+
]
|
72 |
+
|
73 |
+
const initials = name
|
74 |
+
.split(' ')
|
75 |
+
.map(word => word[0])
|
76 |
+
.join('')
|
77 |
+
.toUpperCase()
|
78 |
+
.slice(0, 2)
|
79 |
+
|
80 |
+
const colorIndex = name.charCodeAt(0) % colors.length
|
81 |
+
const color = colors[colorIndex]
|
82 |
+
|
83 |
+
return `data:image/svg+xml,${encodeURIComponent(`
|
84 |
+
<svg width="40" height="40" xmlns="http://www.w3.org/2000/svg">
|
85 |
+
<circle cx="20" cy="20" r="20" fill="${color}"/>
|
86 |
+
<text x="20" y="25" text-anchor="middle" fill="white" font-family="Arial" font-size="14" font-weight="bold">
|
87 |
+
${initials}
|
88 |
+
</text>
|
89 |
+
</svg>
|
90 |
+
`)}`
|
91 |
+
}
|
92 |
+
|
93 |
+
export function debounce<T extends (...args: any[]) => any>(
|
94 |
+
func: T,
|
95 |
+
wait: number
|
96 |
+
): (...args: Parameters<T>) => void {
|
97 |
+
let timeout: NodeJS.Timeout | null = null
|
98 |
+
|
99 |
+
return (...args: Parameters<T>) => {
|
100 |
+
if (timeout) {
|
101 |
+
clearTimeout(timeout)
|
102 |
+
}
|
103 |
+
|
104 |
+
timeout = setTimeout(() => {
|
105 |
+
func(...args)
|
106 |
+
}, wait)
|
107 |
+
}
|
108 |
+
}
|
109 |
+
|
110 |
+
export function throttle<T extends (...args: any[]) => any>(
|
111 |
+
func: T,
|
112 |
+
limit: number
|
113 |
+
): (...args: Parameters<T>) => void {
|
114 |
+
let inThrottle: boolean
|
115 |
+
|
116 |
+
return (...args: Parameters<T>) => {
|
117 |
+
if (!inThrottle) {
|
118 |
+
func(...args)
|
119 |
+
inThrottle = true
|
120 |
+
setTimeout(() => inThrottle = false, limit)
|
121 |
+
}
|
122 |
+
}
|
123 |
+
}
|
124 |
+
|
125 |
+
export function isValidEmail(email: string): boolean {
|
126 |
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
127 |
+
return emailRegex.test(email)
|
128 |
+
}
|
129 |
+
|
130 |
+
export function isValidUsername(username: string): boolean {
|
131 |
+
const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/
|
132 |
+
return usernameRegex.test(username)
|
133 |
+
}
|
134 |
+
|
135 |
+
export function truncateText(text: string, maxLength: number): string {
|
136 |
+
if (text.length <= maxLength) return text
|
137 |
+
return text.slice(0, maxLength) + '...'
|
138 |
+
}
|
139 |
+
|
140 |
+
export function copyToClipboard(text: string): Promise<void> {
|
141 |
+
if (navigator.clipboard) {
|
142 |
+
return navigator.clipboard.writeText(text)
|
143 |
+
} else {
|
144 |
+
// Fallback for older browsers
|
145 |
+
const textArea = document.createElement('textarea')
|
146 |
+
textArea.value = text
|
147 |
+
document.body.appendChild(textArea)
|
148 |
+
textArea.focus()
|
149 |
+
textArea.select()
|
150 |
+
|
151 |
+
try {
|
152 |
+
document.execCommand('copy')
|
153 |
+
return Promise.resolve()
|
154 |
+
} catch (err) {
|
155 |
+
return Promise.reject(err)
|
156 |
+
} finally {
|
157 |
+
document.body.removeChild(textArea)
|
158 |
+
}
|
159 |
+
}
|
160 |
+
}
|
161 |
+
|
162 |
+
export function downloadFile(url: string, filename: string): void {
|
163 |
+
const link = document.createElement('a')
|
164 |
+
link.href = url
|
165 |
+
link.download = filename
|
166 |
+
document.body.appendChild(link)
|
167 |
+
link.click()
|
168 |
+
document.body.removeChild(link)
|
169 |
+
}
|
170 |
+
|
171 |
+
export function isImageFile(file: File): boolean {
|
172 |
+
return file.type.startsWith('image/')
|
173 |
+
}
|
174 |
+
|
175 |
+
export function isVideoFile(file: File): boolean {
|
176 |
+
return file.type.startsWith('video/')
|
177 |
+
}
|
178 |
+
|
179 |
+
export function isAudioFile(file: File): boolean {
|
180 |
+
return file.type.startsWith('audio/')
|
181 |
+
}
|
182 |
+
|
183 |
+
export function getInitials(name: string): string {
|
184 |
+
return name
|
185 |
+
.split(' ')
|
186 |
+
.map(word => word[0])
|
187 |
+
.join('')
|
188 |
+
.toUpperCase()
|
189 |
+
.slice(0, 2)
|
190 |
+
}
|
client/src/main.tsx
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react'
|
2 |
+
import ReactDOM from 'react-dom/client'
|
3 |
+
import { BrowserRouter } from 'react-router-dom'
|
4 |
+
import App from './App.tsx'
|
5 |
+
import './index.css'
|
6 |
+
|
7 |
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
8 |
+
<React.StrictMode>
|
9 |
+
<BrowserRouter>
|
10 |
+
<App />
|
11 |
+
</BrowserRouter>
|
12 |
+
</React.StrictMode>,
|
13 |
+
)
|
client/src/pages/AdminPage.tsx
ADDED
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useEffect } from 'react'
|
2 |
+
import { Routes, Route, Link, useLocation } from 'react-router-dom'
|
3 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
4 |
+
// The following components are currently unused but kept for future
|
5 |
+
// admin interface enhancements.
|
6 |
+
// import { Button } from '@/components/ui/button'
|
7 |
+
// import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
8 |
+
// import { Badge } from '@/components/ui/badge'
|
9 |
+
import {
|
10 |
+
Users,
|
11 |
+
MessageSquare,
|
12 |
+
Settings,
|
13 |
+
BarChart3,
|
14 |
+
Shield,
|
15 |
+
Activity,
|
16 |
+
Database,
|
17 |
+
Server
|
18 |
+
} from 'lucide-react'
|
19 |
+
|
20 |
+
// Mock data - replace with real API calls
|
21 |
+
const mockStats = {
|
22 |
+
totalUsers: 1234,
|
23 |
+
activeUsers: 567,
|
24 |
+
totalChats: 890,
|
25 |
+
totalMessages: 12345,
|
26 |
+
storageUsed: 2.5, // GB
|
27 |
+
serverUptime: 99.9 // %
|
28 |
+
}
|
29 |
+
|
30 |
+
export default function AdminPage() {
|
31 |
+
// Stats state will be updated once API integration is implemented
|
32 |
+
const [stats] = useState(mockStats)
|
33 |
+
const location = useLocation()
|
34 |
+
|
35 |
+
useEffect(() => {
|
36 |
+
// Load admin stats
|
37 |
+
// TODO: Implement API call
|
38 |
+
}, [])
|
39 |
+
|
40 |
+
return (
|
41 |
+
<div className="min-h-screen bg-background">
|
42 |
+
<div className="border-b">
|
43 |
+
<div className="flex h-16 items-center px-6">
|
44 |
+
<Shield className="w-6 h-6 mr-2" />
|
45 |
+
<h1 className="text-xl font-semibold">Admin Panel</h1>
|
46 |
+
</div>
|
47 |
+
</div>
|
48 |
+
|
49 |
+
<div className="flex">
|
50 |
+
{/* Sidebar */}
|
51 |
+
<div className="w-64 border-r min-h-screen p-4">
|
52 |
+
<nav className="space-y-2">
|
53 |
+
<Link
|
54 |
+
to="/admin"
|
55 |
+
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium ${
|
56 |
+
location.pathname === '/admin'
|
57 |
+
? 'bg-primary text-primary-foreground'
|
58 |
+
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
59 |
+
}`}
|
60 |
+
>
|
61 |
+
<BarChart3 className="w-4 h-4" />
|
62 |
+
<span>Dashboard</span>
|
63 |
+
</Link>
|
64 |
+
<Link
|
65 |
+
to="/admin/users"
|
66 |
+
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium ${
|
67 |
+
location.pathname === '/admin/users'
|
68 |
+
? 'bg-primary text-primary-foreground'
|
69 |
+
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
70 |
+
}`}
|
71 |
+
>
|
72 |
+
<Users className="w-4 h-4" />
|
73 |
+
<span>Users</span>
|
74 |
+
</Link>
|
75 |
+
<Link
|
76 |
+
to="/admin/chats"
|
77 |
+
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium ${
|
78 |
+
location.pathname === '/admin/chats'
|
79 |
+
? 'bg-primary text-primary-foreground'
|
80 |
+
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
81 |
+
}`}
|
82 |
+
>
|
83 |
+
<MessageSquare className="w-4 h-4" />
|
84 |
+
<span>Chats</span>
|
85 |
+
</Link>
|
86 |
+
<Link
|
87 |
+
to="/admin/settings"
|
88 |
+
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium ${
|
89 |
+
location.pathname === '/admin/settings'
|
90 |
+
? 'bg-primary text-primary-foreground'
|
91 |
+
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
92 |
+
}`}
|
93 |
+
>
|
94 |
+
<Settings className="w-4 h-4" />
|
95 |
+
<span>Settings</span>
|
96 |
+
</Link>
|
97 |
+
</nav>
|
98 |
+
</div>
|
99 |
+
|
100 |
+
{/* Main content */}
|
101 |
+
<div className="flex-1 p-6">
|
102 |
+
<Routes>
|
103 |
+
<Route path="/" element={<AdminDashboard stats={stats} />} />
|
104 |
+
<Route path="/users" element={<AdminUsers />} />
|
105 |
+
<Route path="/chats" element={<AdminChats />} />
|
106 |
+
<Route path="/settings" element={<AdminSettings />} />
|
107 |
+
</Routes>
|
108 |
+
</div>
|
109 |
+
</div>
|
110 |
+
</div>
|
111 |
+
)
|
112 |
+
}
|
113 |
+
|
114 |
+
function AdminDashboard({ stats }: { stats: typeof mockStats }) {
|
115 |
+
return (
|
116 |
+
<div className="space-y-6">
|
117 |
+
<div>
|
118 |
+
<h2 className="text-2xl font-bold">Dashboard</h2>
|
119 |
+
<p className="text-muted-foreground">
|
120 |
+
Overview of your ChatApp instance
|
121 |
+
</p>
|
122 |
+
</div>
|
123 |
+
|
124 |
+
{/* Stats Cards */}
|
125 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
126 |
+
<Card>
|
127 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
128 |
+
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
129 |
+
<Users className="h-4 w-4 text-muted-foreground" />
|
130 |
+
</CardHeader>
|
131 |
+
<CardContent>
|
132 |
+
<div className="text-2xl font-bold">{stats.totalUsers.toLocaleString()}</div>
|
133 |
+
<p className="text-xs text-muted-foreground">
|
134 |
+
+12% from last month
|
135 |
+
</p>
|
136 |
+
</CardContent>
|
137 |
+
</Card>
|
138 |
+
|
139 |
+
<Card>
|
140 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
141 |
+
<CardTitle className="text-sm font-medium">Active Users</CardTitle>
|
142 |
+
<Activity className="h-4 w-4 text-muted-foreground" />
|
143 |
+
</CardHeader>
|
144 |
+
<CardContent>
|
145 |
+
<div className="text-2xl font-bold">{stats.activeUsers.toLocaleString()}</div>
|
146 |
+
<p className="text-xs text-muted-foreground">
|
147 |
+
Currently online
|
148 |
+
</p>
|
149 |
+
</CardContent>
|
150 |
+
</Card>
|
151 |
+
|
152 |
+
<Card>
|
153 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
154 |
+
<CardTitle className="text-sm font-medium">Total Messages</CardTitle>
|
155 |
+
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
156 |
+
</CardHeader>
|
157 |
+
<CardContent>
|
158 |
+
<div className="text-2xl font-bold">{stats.totalMessages.toLocaleString()}</div>
|
159 |
+
<p className="text-xs text-muted-foreground">
|
160 |
+
+5% from last week
|
161 |
+
</p>
|
162 |
+
</CardContent>
|
163 |
+
</Card>
|
164 |
+
|
165 |
+
<Card>
|
166 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
167 |
+
<CardTitle className="text-sm font-medium">Storage Used</CardTitle>
|
168 |
+
<Database className="h-4 w-4 text-muted-foreground" />
|
169 |
+
</CardHeader>
|
170 |
+
<CardContent>
|
171 |
+
<div className="text-2xl font-bold">{stats.storageUsed} GB</div>
|
172 |
+
<p className="text-xs text-muted-foreground">
|
173 |
+
Of 10 GB available
|
174 |
+
</p>
|
175 |
+
</CardContent>
|
176 |
+
</Card>
|
177 |
+
|
178 |
+
<Card>
|
179 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
180 |
+
<CardTitle className="text-sm font-medium">Server Uptime</CardTitle>
|
181 |
+
<Server className="h-4 w-4 text-muted-foreground" />
|
182 |
+
</CardHeader>
|
183 |
+
<CardContent>
|
184 |
+
<div className="text-2xl font-bold">{stats.serverUptime}%</div>
|
185 |
+
<p className="text-xs text-muted-foreground">
|
186 |
+
Last 30 days
|
187 |
+
</p>
|
188 |
+
</CardContent>
|
189 |
+
</Card>
|
190 |
+
|
191 |
+
<Card>
|
192 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
193 |
+
<CardTitle className="text-sm font-medium">Total Chats</CardTitle>
|
194 |
+
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
195 |
+
</CardHeader>
|
196 |
+
<CardContent>
|
197 |
+
<div className="text-2xl font-bold">{stats.totalChats.toLocaleString()}</div>
|
198 |
+
<p className="text-xs text-muted-foreground">
|
199 |
+
Groups and direct messages
|
200 |
+
</p>
|
201 |
+
</CardContent>
|
202 |
+
</Card>
|
203 |
+
</div>
|
204 |
+
|
205 |
+
{/* Recent Activity */}
|
206 |
+
<Card>
|
207 |
+
<CardHeader>
|
208 |
+
<CardTitle>Recent Activity</CardTitle>
|
209 |
+
<CardDescription>
|
210 |
+
Latest events in your ChatApp instance
|
211 |
+
</CardDescription>
|
212 |
+
</CardHeader>
|
213 |
+
<CardContent>
|
214 |
+
<div className="space-y-4">
|
215 |
+
<div className="flex items-center space-x-4">
|
216 |
+
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
217 |
+
<div className="flex-1">
|
218 |
+
<p className="text-sm">New user registered: john_doe</p>
|
219 |
+
<p className="text-xs text-muted-foreground">2 minutes ago</p>
|
220 |
+
</div>
|
221 |
+
</div>
|
222 |
+
<div className="flex items-center space-x-4">
|
223 |
+
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
224 |
+
<div className="flex-1">
|
225 |
+
<p className="text-sm">New group created: "Project Team"</p>
|
226 |
+
<p className="text-xs text-muted-foreground">5 minutes ago</p>
|
227 |
+
</div>
|
228 |
+
</div>
|
229 |
+
<div className="flex items-center space-x-4">
|
230 |
+
<div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
|
231 |
+
<div className="flex-1">
|
232 |
+
<p className="text-sm">Server maintenance completed</p>
|
233 |
+
<p className="text-xs text-muted-foreground">1 hour ago</p>
|
234 |
+
</div>
|
235 |
+
</div>
|
236 |
+
</div>
|
237 |
+
</CardContent>
|
238 |
+
</Card>
|
239 |
+
</div>
|
240 |
+
)
|
241 |
+
}
|
242 |
+
|
243 |
+
function AdminUsers() {
|
244 |
+
return (
|
245 |
+
<div className="space-y-6">
|
246 |
+
<div>
|
247 |
+
<h2 className="text-2xl font-bold">User Management</h2>
|
248 |
+
<p className="text-muted-foreground">
|
249 |
+
Manage users and their permissions
|
250 |
+
</p>
|
251 |
+
</div>
|
252 |
+
|
253 |
+
<Card>
|
254 |
+
<CardContent className="p-6">
|
255 |
+
<p className="text-center text-muted-foreground">
|
256 |
+
User management interface coming soon...
|
257 |
+
</p>
|
258 |
+
</CardContent>
|
259 |
+
</Card>
|
260 |
+
</div>
|
261 |
+
)
|
262 |
+
}
|
263 |
+
|
264 |
+
function AdminChats() {
|
265 |
+
return (
|
266 |
+
<div className="space-y-6">
|
267 |
+
<div>
|
268 |
+
<h2 className="text-2xl font-bold">Chat Management</h2>
|
269 |
+
<p className="text-muted-foreground">
|
270 |
+
Monitor and manage chats and groups
|
271 |
+
</p>
|
272 |
+
</div>
|
273 |
+
|
274 |
+
<Card>
|
275 |
+
<CardContent className="p-6">
|
276 |
+
<p className="text-center text-muted-foreground">
|
277 |
+
Chat management interface coming soon...
|
278 |
+
</p>
|
279 |
+
</CardContent>
|
280 |
+
</Card>
|
281 |
+
</div>
|
282 |
+
)
|
283 |
+
}
|
284 |
+
|
285 |
+
function AdminSettings() {
|
286 |
+
return (
|
287 |
+
<div className="space-y-6">
|
288 |
+
<div>
|
289 |
+
<h2 className="text-2xl font-bold">System Settings</h2>
|
290 |
+
<p className="text-muted-foreground">
|
291 |
+
Configure your ChatApp instance
|
292 |
+
</p>
|
293 |
+
</div>
|
294 |
+
|
295 |
+
<Card>
|
296 |
+
<CardContent className="p-6">
|
297 |
+
<p className="text-center text-muted-foreground">
|
298 |
+
Settings interface coming soon...
|
299 |
+
</p>
|
300 |
+
</CardContent>
|
301 |
+
</Card>
|
302 |
+
</div>
|
303 |
+
)
|
304 |
+
}
|
client/src/pages/ChatPage.tsx
ADDED
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useEffect } from 'react'
|
2 |
+
import { useAuthStore } from '@/store/authStore'
|
3 |
+
import { useChatStore } from '@/store/chatStore'
|
4 |
+
import { socketService } from '@/services/socketService'
|
5 |
+
import ChatSidebar from '@/components/chat/ChatSidebar'
|
6 |
+
import ChatWindow from '@/components/chat/ChatWindow'
|
7 |
+
import { Separator } from '@/components/ui/separator'
|
8 |
+
|
9 |
+
export default function ChatPage() {
|
10 |
+
const { user, token } = useAuthStore()
|
11 |
+
const { loadChats, currentChat } = useChatStore()
|
12 |
+
|
13 |
+
useEffect(() => {
|
14 |
+
if (token && user) {
|
15 |
+
// Connect to socket
|
16 |
+
socketService.connect(token).then(() => {
|
17 |
+
console.log('Socket connected successfully')
|
18 |
+
}).catch((error) => {
|
19 |
+
console.error('Socket connection failed:', error)
|
20 |
+
})
|
21 |
+
|
22 |
+
// Load initial data
|
23 |
+
loadChats()
|
24 |
+
|
25 |
+
// Cleanup on unmount
|
26 |
+
return () => {
|
27 |
+
socketService.disconnect()
|
28 |
+
}
|
29 |
+
}
|
30 |
+
}, [token, user, loadChats])
|
31 |
+
|
32 |
+
return (
|
33 |
+
<div className="flex h-screen bg-background">
|
34 |
+
{/* Sidebar */}
|
35 |
+
<div className="w-80 border-r border-border">
|
36 |
+
<ChatSidebar />
|
37 |
+
</div>
|
38 |
+
|
39 |
+
<Separator orientation="vertical" />
|
40 |
+
|
41 |
+
{/* Main chat area */}
|
42 |
+
<div className="flex-1 flex flex-col">
|
43 |
+
{currentChat ? (
|
44 |
+
<ChatWindow />
|
45 |
+
) : (
|
46 |
+
<div className="flex-1 flex items-center justify-center">
|
47 |
+
<div className="text-center">
|
48 |
+
<div className="text-6xl mb-4">💬</div>
|
49 |
+
<h2 className="text-2xl font-semibold mb-2">Welcome to ChatApp</h2>
|
50 |
+
<p className="text-muted-foreground">
|
51 |
+
Select a chat to start messaging
|
52 |
+
</p>
|
53 |
+
</div>
|
54 |
+
</div>
|
55 |
+
)}
|
56 |
+
</div>
|
57 |
+
</div>
|
58 |
+
)
|
59 |
+
}
|
client/src/pages/LoginPage.tsx
ADDED
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState } from 'react'
|
2 |
+
import { Link, useNavigate } from 'react-router-dom'
|
3 |
+
import { useAuthStore } from '@/store/authStore'
|
4 |
+
import { Button } from '@/components/ui/button'
|
5 |
+
import { Input } from '@/components/ui/input'
|
6 |
+
import { Label } from '@/components/ui/label'
|
7 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
8 |
+
import { Alert, AlertDescription } from '@/components/ui/alert'
|
9 |
+
import LoadingSpinner from '@/components/LoadingSpinner'
|
10 |
+
import { MessageCircle } from 'lucide-react'
|
11 |
+
|
12 |
+
export default function LoginPage() {
|
13 |
+
const [email, setEmail] = useState('')
|
14 |
+
const [password, setPassword] = useState('')
|
15 |
+
const { login, loading, error, clearError } = useAuthStore()
|
16 |
+
const navigate = useNavigate()
|
17 |
+
|
18 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
19 |
+
e.preventDefault()
|
20 |
+
clearError()
|
21 |
+
|
22 |
+
try {
|
23 |
+
await login(email, password)
|
24 |
+
navigate('/chat')
|
25 |
+
} catch (error) {
|
26 |
+
// Error is handled by the store
|
27 |
+
}
|
28 |
+
}
|
29 |
+
|
30 |
+
return (
|
31 |
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
32 |
+
<Card className="w-full max-w-md">
|
33 |
+
<CardHeader className="text-center">
|
34 |
+
<div className="flex justify-center mb-4">
|
35 |
+
<div className="p-3 bg-blue-600 rounded-full">
|
36 |
+
<MessageCircle className="w-8 h-8 text-white" />
|
37 |
+
</div>
|
38 |
+
</div>
|
39 |
+
<CardTitle className="text-2xl font-bold">Welcome Back</CardTitle>
|
40 |
+
<CardDescription>
|
41 |
+
Sign in to your ChatApp account
|
42 |
+
</CardDescription>
|
43 |
+
</CardHeader>
|
44 |
+
<CardContent>
|
45 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
46 |
+
{error && (
|
47 |
+
<Alert variant="destructive">
|
48 |
+
<AlertDescription>{error}</AlertDescription>
|
49 |
+
</Alert>
|
50 |
+
)}
|
51 |
+
|
52 |
+
<div className="space-y-2">
|
53 |
+
<Label htmlFor="email">Email</Label>
|
54 |
+
<Input
|
55 |
+
id="email"
|
56 |
+
type="email"
|
57 |
+
placeholder="Enter your email"
|
58 |
+
value={email}
|
59 |
+
onChange={(e) => setEmail(e.target.value)}
|
60 |
+
required
|
61 |
+
disabled={loading}
|
62 |
+
/>
|
63 |
+
</div>
|
64 |
+
|
65 |
+
<div className="space-y-2">
|
66 |
+
<Label htmlFor="password">Password</Label>
|
67 |
+
<Input
|
68 |
+
id="password"
|
69 |
+
type="password"
|
70 |
+
placeholder="Enter your password"
|
71 |
+
value={password}
|
72 |
+
onChange={(e) => setPassword(e.target.value)}
|
73 |
+
required
|
74 |
+
disabled={loading}
|
75 |
+
/>
|
76 |
+
</div>
|
77 |
+
|
78 |
+
<Button
|
79 |
+
type="submit"
|
80 |
+
className="w-full"
|
81 |
+
disabled={loading}
|
82 |
+
>
|
83 |
+
{loading ? (
|
84 |
+
<>
|
85 |
+
<LoadingSpinner size="sm" className="mr-2" />
|
86 |
+
Signing in...
|
87 |
+
</>
|
88 |
+
) : (
|
89 |
+
'Sign In'
|
90 |
+
)}
|
91 |
+
</Button>
|
92 |
+
</form>
|
93 |
+
|
94 |
+
<div className="mt-6 text-center">
|
95 |
+
<p className="text-sm text-gray-600">
|
96 |
+
Don't have an account?{' '}
|
97 |
+
<Link
|
98 |
+
to="/register"
|
99 |
+
className="text-blue-600 hover:text-blue-500 font-medium"
|
100 |
+
>
|
101 |
+
Sign up
|
102 |
+
</Link>
|
103 |
+
</p>
|
104 |
+
</div>
|
105 |
+
</CardContent>
|
106 |
+
</Card>
|
107 |
+
</div>
|
108 |
+
)
|
109 |
+
}
|
client/src/pages/ProfilePage.tsx
ADDED
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState } from 'react'
|
2 |
+
import { useAuthStore } from '@/store/authStore'
|
3 |
+
import { Button } from '@/components/ui/button'
|
4 |
+
import { Input } from '@/components/ui/input'
|
5 |
+
import { Label } from '@/components/ui/label'
|
6 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
7 |
+
import { Textarea } from '@/components/ui/textarea'
|
8 |
+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
9 |
+
import { getInitials } from '@/lib/utils'
|
10 |
+
import { User, Settings } from 'lucide-react'
|
11 |
+
|
12 |
+
export default function ProfilePage() {
|
13 |
+
const { user, updateUser } = useAuthStore()
|
14 |
+
const [isEditing, setIsEditing] = useState(false)
|
15 |
+
const [formData, setFormData] = useState({
|
16 |
+
displayName: user?.displayName || '',
|
17 |
+
bio: user?.bio || '',
|
18 |
+
})
|
19 |
+
|
20 |
+
const handleSave = () => {
|
21 |
+
if (user) {
|
22 |
+
updateUser(formData)
|
23 |
+
setIsEditing(false)
|
24 |
+
}
|
25 |
+
}
|
26 |
+
|
27 |
+
const handleCancel = () => {
|
28 |
+
setFormData({
|
29 |
+
displayName: user?.displayName || '',
|
30 |
+
bio: user?.bio || '',
|
31 |
+
})
|
32 |
+
setIsEditing(false)
|
33 |
+
}
|
34 |
+
|
35 |
+
if (!user) return null
|
36 |
+
|
37 |
+
return (
|
38 |
+
<div className="min-h-screen bg-background p-6">
|
39 |
+
<div className="max-w-2xl mx-auto space-y-6">
|
40 |
+
<div className="flex items-center space-x-4">
|
41 |
+
<Button variant="ghost" size="icon">
|
42 |
+
<User className="w-5 h-5" />
|
43 |
+
</Button>
|
44 |
+
<h1 className="text-3xl font-bold">Profile</h1>
|
45 |
+
</div>
|
46 |
+
|
47 |
+
<Card>
|
48 |
+
<CardHeader>
|
49 |
+
<CardTitle>Personal Information</CardTitle>
|
50 |
+
<CardDescription>
|
51 |
+
Manage your account details and preferences
|
52 |
+
</CardDescription>
|
53 |
+
</CardHeader>
|
54 |
+
<CardContent className="space-y-6">
|
55 |
+
<div className="flex items-center space-x-4">
|
56 |
+
<Avatar className="w-20 h-20">
|
57 |
+
<AvatarImage src={user.avatar} />
|
58 |
+
<AvatarFallback className="text-lg">
|
59 |
+
{getInitials(user.displayName)}
|
60 |
+
</AvatarFallback>
|
61 |
+
</Avatar>
|
62 |
+
<div>
|
63 |
+
<Button variant="outline" size="sm">
|
64 |
+
Change Avatar
|
65 |
+
</Button>
|
66 |
+
<p className="text-sm text-muted-foreground mt-1">
|
67 |
+
JPG, PNG or GIF. Max size 2MB.
|
68 |
+
</p>
|
69 |
+
</div>
|
70 |
+
</div>
|
71 |
+
|
72 |
+
<div className="grid grid-cols-2 gap-4">
|
73 |
+
<div className="space-y-2">
|
74 |
+
<Label>Email</Label>
|
75 |
+
<Input value={user.email} disabled />
|
76 |
+
</div>
|
77 |
+
<div className="space-y-2">
|
78 |
+
<Label>Username</Label>
|
79 |
+
<Input value={user.username} disabled />
|
80 |
+
</div>
|
81 |
+
</div>
|
82 |
+
|
83 |
+
<div className="space-y-2">
|
84 |
+
<Label htmlFor="displayName">Display Name</Label>
|
85 |
+
<Input
|
86 |
+
id="displayName"
|
87 |
+
value={formData.displayName}
|
88 |
+
onChange={(e) => setFormData(prev => ({ ...prev, displayName: e.target.value }))}
|
89 |
+
disabled={!isEditing}
|
90 |
+
/>
|
91 |
+
</div>
|
92 |
+
|
93 |
+
<div className="space-y-2">
|
94 |
+
<Label htmlFor="bio">Bio</Label>
|
95 |
+
<Textarea
|
96 |
+
id="bio"
|
97 |
+
placeholder="Tell us about yourself..."
|
98 |
+
value={formData.bio}
|
99 |
+
onChange={(e) => setFormData(prev => ({ ...prev, bio: e.target.value }))}
|
100 |
+
disabled={!isEditing}
|
101 |
+
rows={3}
|
102 |
+
/>
|
103 |
+
</div>
|
104 |
+
|
105 |
+
<div className="flex justify-end space-x-2">
|
106 |
+
{isEditing ? (
|
107 |
+
<>
|
108 |
+
<Button variant="outline" onClick={handleCancel}>
|
109 |
+
Cancel
|
110 |
+
</Button>
|
111 |
+
<Button onClick={handleSave}>
|
112 |
+
Save Changes
|
113 |
+
</Button>
|
114 |
+
</>
|
115 |
+
) : (
|
116 |
+
<Button onClick={() => setIsEditing(true)}>
|
117 |
+
Edit Profile
|
118 |
+
</Button>
|
119 |
+
)}
|
120 |
+
</div>
|
121 |
+
</CardContent>
|
122 |
+
</Card>
|
123 |
+
|
124 |
+
<Card>
|
125 |
+
<CardHeader>
|
126 |
+
<CardTitle>Account Settings</CardTitle>
|
127 |
+
<CardDescription>
|
128 |
+
Security and privacy settings
|
129 |
+
</CardDescription>
|
130 |
+
</CardHeader>
|
131 |
+
<CardContent className="space-y-4">
|
132 |
+
<div className="flex items-center justify-between">
|
133 |
+
<div>
|
134 |
+
<h4 className="font-medium">Change Password</h4>
|
135 |
+
<p className="text-sm text-muted-foreground">
|
136 |
+
Update your password to keep your account secure
|
137 |
+
</p>
|
138 |
+
</div>
|
139 |
+
<Button variant="outline">
|
140 |
+
Change Password
|
141 |
+
</Button>
|
142 |
+
</div>
|
143 |
+
|
144 |
+
<div className="flex items-center justify-between">
|
145 |
+
<div>
|
146 |
+
<h4 className="font-medium">Two-Factor Authentication</h4>
|
147 |
+
<p className="text-sm text-muted-foreground">
|
148 |
+
Add an extra layer of security to your account
|
149 |
+
</p>
|
150 |
+
</div>
|
151 |
+
<Button variant="outline">
|
152 |
+
Enable 2FA
|
153 |
+
</Button>
|
154 |
+
</div>
|
155 |
+
|
156 |
+
<div className="flex items-center justify-between">
|
157 |
+
<div>
|
158 |
+
<h4 className="font-medium">Privacy Settings</h4>
|
159 |
+
<p className="text-sm text-muted-foreground">
|
160 |
+
Control who can see your information
|
161 |
+
</p>
|
162 |
+
</div>
|
163 |
+
<Button variant="outline">
|
164 |
+
<Settings className="w-4 h-4 mr-2" />
|
165 |
+
Manage
|
166 |
+
</Button>
|
167 |
+
</div>
|
168 |
+
</CardContent>
|
169 |
+
</Card>
|
170 |
+
</div>
|
171 |
+
</div>
|
172 |
+
)
|
173 |
+
}
|
client/src/pages/RegisterPage.tsx
ADDED
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState } from 'react'
|
2 |
+
import { Link, useNavigate } from 'react-router-dom'
|
3 |
+
import { useAuthStore } from '@/store/authStore'
|
4 |
+
import { Button } from '@/components/ui/button'
|
5 |
+
import { Input } from '@/components/ui/input'
|
6 |
+
import { Label } from '@/components/ui/label'
|
7 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
8 |
+
import { Alert, AlertDescription } from '@/components/ui/alert'
|
9 |
+
import LoadingSpinner from '@/components/LoadingSpinner'
|
10 |
+
import { MessageCircle } from 'lucide-react'
|
11 |
+
|
12 |
+
export default function RegisterPage() {
|
13 |
+
const [formData, setFormData] = useState({
|
14 |
+
email: '',
|
15 |
+
username: '',
|
16 |
+
displayName: '',
|
17 |
+
password: '',
|
18 |
+
confirmPassword: ''
|
19 |
+
})
|
20 |
+
const [validationError, setValidationError] = useState('')
|
21 |
+
const { register, loading, error, clearError } = useAuthStore()
|
22 |
+
const navigate = useNavigate()
|
23 |
+
|
24 |
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
25 |
+
const { name, value } = e.target
|
26 |
+
setFormData(prev => ({
|
27 |
+
...prev,
|
28 |
+
[name]: value
|
29 |
+
}))
|
30 |
+
setValidationError('')
|
31 |
+
}
|
32 |
+
|
33 |
+
const validateForm = () => {
|
34 |
+
if (formData.password !== formData.confirmPassword) {
|
35 |
+
setValidationError('Passwords do not match')
|
36 |
+
return false
|
37 |
+
}
|
38 |
+
|
39 |
+
if (formData.password.length < 6) {
|
40 |
+
setValidationError('Password must be at least 6 characters long')
|
41 |
+
return false
|
42 |
+
}
|
43 |
+
|
44 |
+
if (formData.username.length < 3) {
|
45 |
+
setValidationError('Username must be at least 3 characters long')
|
46 |
+
return false
|
47 |
+
}
|
48 |
+
|
49 |
+
if (!/^[a-zA-Z0-9_]+$/.test(formData.username)) {
|
50 |
+
setValidationError('Username can only contain letters, numbers, and underscores')
|
51 |
+
return false
|
52 |
+
}
|
53 |
+
|
54 |
+
return true
|
55 |
+
}
|
56 |
+
|
57 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
58 |
+
e.preventDefault()
|
59 |
+
clearError()
|
60 |
+
setValidationError('')
|
61 |
+
|
62 |
+
if (!validateForm()) {
|
63 |
+
return
|
64 |
+
}
|
65 |
+
|
66 |
+
try {
|
67 |
+
await register({
|
68 |
+
email: formData.email,
|
69 |
+
username: formData.username,
|
70 |
+
displayName: formData.displayName,
|
71 |
+
password: formData.password
|
72 |
+
})
|
73 |
+
navigate('/chat')
|
74 |
+
} catch (error) {
|
75 |
+
// Error is handled by the store
|
76 |
+
}
|
77 |
+
}
|
78 |
+
|
79 |
+
const displayError = validationError || error
|
80 |
+
|
81 |
+
return (
|
82 |
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
83 |
+
<Card className="w-full max-w-md">
|
84 |
+
<CardHeader className="text-center">
|
85 |
+
<div className="flex justify-center mb-4">
|
86 |
+
<div className="p-3 bg-blue-600 rounded-full">
|
87 |
+
<MessageCircle className="w-8 h-8 text-white" />
|
88 |
+
</div>
|
89 |
+
</div>
|
90 |
+
<CardTitle className="text-2xl font-bold">Create Account</CardTitle>
|
91 |
+
<CardDescription>
|
92 |
+
Join ChatApp and start messaging
|
93 |
+
</CardDescription>
|
94 |
+
</CardHeader>
|
95 |
+
<CardContent>
|
96 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
97 |
+
{displayError && (
|
98 |
+
<Alert variant="destructive">
|
99 |
+
<AlertDescription>{displayError}</AlertDescription>
|
100 |
+
</Alert>
|
101 |
+
)}
|
102 |
+
|
103 |
+
<div className="space-y-2">
|
104 |
+
<Label htmlFor="email">Email</Label>
|
105 |
+
<Input
|
106 |
+
id="email"
|
107 |
+
name="email"
|
108 |
+
type="email"
|
109 |
+
placeholder="Enter your email"
|
110 |
+
value={formData.email}
|
111 |
+
onChange={handleChange}
|
112 |
+
required
|
113 |
+
disabled={loading}
|
114 |
+
/>
|
115 |
+
</div>
|
116 |
+
|
117 |
+
<div className="space-y-2">
|
118 |
+
<Label htmlFor="username">Username</Label>
|
119 |
+
<Input
|
120 |
+
id="username"
|
121 |
+
name="username"
|
122 |
+
type="text"
|
123 |
+
placeholder="Choose a username"
|
124 |
+
value={formData.username}
|
125 |
+
onChange={handleChange}
|
126 |
+
required
|
127 |
+
disabled={loading}
|
128 |
+
/>
|
129 |
+
</div>
|
130 |
+
|
131 |
+
<div className="space-y-2">
|
132 |
+
<Label htmlFor="displayName">Display Name</Label>
|
133 |
+
<Input
|
134 |
+
id="displayName"
|
135 |
+
name="displayName"
|
136 |
+
type="text"
|
137 |
+
placeholder="Your display name"
|
138 |
+
value={formData.displayName}
|
139 |
+
onChange={handleChange}
|
140 |
+
required
|
141 |
+
disabled={loading}
|
142 |
+
/>
|
143 |
+
</div>
|
144 |
+
|
145 |
+
<div className="space-y-2">
|
146 |
+
<Label htmlFor="password">Password</Label>
|
147 |
+
<Input
|
148 |
+
id="password"
|
149 |
+
name="password"
|
150 |
+
type="password"
|
151 |
+
placeholder="Create a password"
|
152 |
+
value={formData.password}
|
153 |
+
onChange={handleChange}
|
154 |
+
required
|
155 |
+
disabled={loading}
|
156 |
+
/>
|
157 |
+
</div>
|
158 |
+
|
159 |
+
<div className="space-y-2">
|
160 |
+
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
161 |
+
<Input
|
162 |
+
id="confirmPassword"
|
163 |
+
name="confirmPassword"
|
164 |
+
type="password"
|
165 |
+
placeholder="Confirm your password"
|
166 |
+
value={formData.confirmPassword}
|
167 |
+
onChange={handleChange}
|
168 |
+
required
|
169 |
+
disabled={loading}
|
170 |
+
/>
|
171 |
+
</div>
|
172 |
+
|
173 |
+
<Button
|
174 |
+
type="submit"
|
175 |
+
className="w-full"
|
176 |
+
disabled={loading}
|
177 |
+
>
|
178 |
+
{loading ? (
|
179 |
+
<>
|
180 |
+
<LoadingSpinner size="sm" className="mr-2" />
|
181 |
+
Creating account...
|
182 |
+
</>
|
183 |
+
) : (
|
184 |
+
'Create Account'
|
185 |
+
)}
|
186 |
+
</Button>
|
187 |
+
</form>
|
188 |
+
|
189 |
+
<div className="mt-6 text-center">
|
190 |
+
<p className="text-sm text-gray-600">
|
191 |
+
Already have an account?{' '}
|
192 |
+
<Link
|
193 |
+
to="/login"
|
194 |
+
className="text-blue-600 hover:text-blue-500 font-medium"
|
195 |
+
>
|
196 |
+
Sign in
|
197 |
+
</Link>
|
198 |
+
</p>
|
199 |
+
</div>
|
200 |
+
</CardContent>
|
201 |
+
</Card>
|
202 |
+
</div>
|
203 |
+
)
|
204 |
+
}
|
client/src/services/api.ts
ADDED
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
|
2 |
+
import { ApiResponse } from '../../../shared/types'
|
3 |
+
|
4 |
+
class ApiService {
|
5 |
+
private api: AxiosInstance
|
6 |
+
|
7 |
+
constructor() {
|
8 |
+
this.api = axios.create({
|
9 |
+
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001',
|
10 |
+
timeout: 10000,
|
11 |
+
headers: {
|
12 |
+
'Content-Type': 'application/json',
|
13 |
+
},
|
14 |
+
})
|
15 |
+
|
16 |
+
// Request interceptor to add auth token
|
17 |
+
this.api.interceptors.request.use(
|
18 |
+
(config) => {
|
19 |
+
const token = this.getToken()
|
20 |
+
if (token) {
|
21 |
+
config.headers.Authorization = `Bearer ${token}`
|
22 |
+
}
|
23 |
+
return config
|
24 |
+
},
|
25 |
+
(error) => {
|
26 |
+
return Promise.reject(error)
|
27 |
+
}
|
28 |
+
)
|
29 |
+
|
30 |
+
// Response interceptor to handle errors
|
31 |
+
this.api.interceptors.response.use(
|
32 |
+
(response) => {
|
33 |
+
return response
|
34 |
+
},
|
35 |
+
(error) => {
|
36 |
+
if (error.response?.status === 401) {
|
37 |
+
// Token expired or invalid
|
38 |
+
this.clearToken()
|
39 |
+
window.location.href = '/login'
|
40 |
+
}
|
41 |
+
return Promise.reject(this.handleError(error))
|
42 |
+
}
|
43 |
+
)
|
44 |
+
}
|
45 |
+
|
46 |
+
private getToken(): string | null {
|
47 |
+
try {
|
48 |
+
const authStorage = localStorage.getItem('auth-storage')
|
49 |
+
if (authStorage) {
|
50 |
+
const parsed = JSON.parse(authStorage)
|
51 |
+
return parsed.state?.token || null
|
52 |
+
}
|
53 |
+
} catch (error) {
|
54 |
+
console.error('Error getting token:', error)
|
55 |
+
}
|
56 |
+
return null
|
57 |
+
}
|
58 |
+
|
59 |
+
private clearToken(): void {
|
60 |
+
try {
|
61 |
+
localStorage.removeItem('auth-storage')
|
62 |
+
} catch (error) {
|
63 |
+
console.error('Error clearing token:', error)
|
64 |
+
}
|
65 |
+
}
|
66 |
+
|
67 |
+
private handleError(error: any): Error {
|
68 |
+
if (error.response) {
|
69 |
+
// Server responded with error status
|
70 |
+
const message = error.response.data?.message || error.response.data?.error || 'Server error'
|
71 |
+
return new Error(message)
|
72 |
+
} else if (error.request) {
|
73 |
+
// Request was made but no response received
|
74 |
+
return new Error('Network error - please check your connection')
|
75 |
+
} else {
|
76 |
+
// Something else happened
|
77 |
+
return new Error(error.message || 'An unexpected error occurred')
|
78 |
+
}
|
79 |
+
}
|
80 |
+
|
81 |
+
// Generic request methods
|
82 |
+
async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
83 |
+
const response = await this.api.get<ApiResponse<T>>(url, config)
|
84 |
+
if (!response.data.success) {
|
85 |
+
throw new Error(response.data.error || 'Request failed')
|
86 |
+
}
|
87 |
+
return response.data.data!
|
88 |
+
}
|
89 |
+
|
90 |
+
async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
91 |
+
const response = await this.api.post<ApiResponse<T>>(url, data, config)
|
92 |
+
if (!response.data.success) {
|
93 |
+
throw new Error(response.data.error || 'Request failed')
|
94 |
+
}
|
95 |
+
return response.data.data!
|
96 |
+
}
|
97 |
+
|
98 |
+
async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
99 |
+
const response = await this.api.put<ApiResponse<T>>(url, data, config)
|
100 |
+
if (!response.data.success) {
|
101 |
+
throw new Error(response.data.error || 'Request failed')
|
102 |
+
}
|
103 |
+
return response.data.data!
|
104 |
+
}
|
105 |
+
|
106 |
+
async patch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
107 |
+
const response = await this.api.patch<ApiResponse<T>>(url, data, config)
|
108 |
+
if (!response.data.success) {
|
109 |
+
throw new Error(response.data.error || 'Request failed')
|
110 |
+
}
|
111 |
+
return response.data.data!
|
112 |
+
}
|
113 |
+
|
114 |
+
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
115 |
+
const response = await this.api.delete<ApiResponse<T>>(url, config)
|
116 |
+
if (!response.data.success) {
|
117 |
+
throw new Error(response.data.error || 'Request failed')
|
118 |
+
}
|
119 |
+
return response.data.data!
|
120 |
+
}
|
121 |
+
|
122 |
+
// File upload method
|
123 |
+
async upload<T>(url: string, file: File, onProgress?: (progress: number) => void): Promise<T> {
|
124 |
+
const formData = new FormData()
|
125 |
+
formData.append('file', file)
|
126 |
+
|
127 |
+
const config: AxiosRequestConfig = {
|
128 |
+
headers: {
|
129 |
+
'Content-Type': 'multipart/form-data',
|
130 |
+
},
|
131 |
+
onUploadProgress: (progressEvent) => {
|
132 |
+
if (onProgress && progressEvent.total) {
|
133 |
+
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
134 |
+
onProgress(progress)
|
135 |
+
}
|
136 |
+
},
|
137 |
+
}
|
138 |
+
|
139 |
+
const response = await this.api.post<ApiResponse<T>>(url, formData, config)
|
140 |
+
if (!response.data.success) {
|
141 |
+
throw new Error(response.data.error || 'Upload failed')
|
142 |
+
}
|
143 |
+
return response.data.data!
|
144 |
+
}
|
145 |
+
|
146 |
+
// Multiple file upload method
|
147 |
+
async uploadMultiple<T>(
|
148 |
+
url: string,
|
149 |
+
files: File[],
|
150 |
+
onProgress?: (progress: number) => void
|
151 |
+
): Promise<T> {
|
152 |
+
const formData = new FormData()
|
153 |
+
files.forEach((file, index) => {
|
154 |
+
formData.append(`files[${index}]`, file)
|
155 |
+
})
|
156 |
+
|
157 |
+
const config: AxiosRequestConfig = {
|
158 |
+
headers: {
|
159 |
+
'Content-Type': 'multipart/form-data',
|
160 |
+
},
|
161 |
+
onUploadProgress: (progressEvent) => {
|
162 |
+
if (onProgress && progressEvent.total) {
|
163 |
+
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
164 |
+
onProgress(progress)
|
165 |
+
}
|
166 |
+
},
|
167 |
+
}
|
168 |
+
|
169 |
+
const response = await this.api.post<ApiResponse<T>>(url, formData, config)
|
170 |
+
if (!response.data.success) {
|
171 |
+
throw new Error(response.data.error || 'Upload failed')
|
172 |
+
}
|
173 |
+
return response.data.data!
|
174 |
+
}
|
175 |
+
|
176 |
+
// Get raw axios instance for custom requests
|
177 |
+
getAxiosInstance(): AxiosInstance {
|
178 |
+
return this.api
|
179 |
+
}
|
180 |
+
}
|
181 |
+
|
182 |
+
export const apiService = new ApiService()
|
client/src/services/authService.ts
ADDED
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { User, LoginRequest, RegisterRequest } from '../../../shared/types'
|
2 |
+
import { apiService } from './api'
|
3 |
+
|
4 |
+
interface AuthResponse {
|
5 |
+
user: User
|
6 |
+
token: string
|
7 |
+
}
|
8 |
+
|
9 |
+
class AuthService {
|
10 |
+
async login(credentials: LoginRequest): Promise<AuthResponse> {
|
11 |
+
return await apiService.post<AuthResponse>('/api/auth/login', credentials)
|
12 |
+
}
|
13 |
+
|
14 |
+
async register(userData: RegisterRequest): Promise<AuthResponse> {
|
15 |
+
return await apiService.post<AuthResponse>('/api/auth/register', userData)
|
16 |
+
}
|
17 |
+
|
18 |
+
async logout(): Promise<void> {
|
19 |
+
try {
|
20 |
+
await apiService.post('/api/auth/logout')
|
21 |
+
} catch (error) {
|
22 |
+
// Ignore logout errors, clear local storage anyway
|
23 |
+
console.warn('Logout request failed:', error)
|
24 |
+
}
|
25 |
+
}
|
26 |
+
|
27 |
+
async getCurrentUser(): Promise<User> {
|
28 |
+
return await apiService.get<User>('/api/auth/me')
|
29 |
+
}
|
30 |
+
|
31 |
+
async refreshToken(): Promise<AuthResponse> {
|
32 |
+
return await apiService.post<AuthResponse>('/api/auth/refresh')
|
33 |
+
}
|
34 |
+
|
35 |
+
async changePassword(data: {
|
36 |
+
currentPassword: string
|
37 |
+
newPassword: string
|
38 |
+
}): Promise<void> {
|
39 |
+
await apiService.post('/api/auth/change-password', data)
|
40 |
+
}
|
41 |
+
|
42 |
+
async requestPasswordReset(email: string): Promise<void> {
|
43 |
+
await apiService.post('/api/auth/forgot-password', { email })
|
44 |
+
}
|
45 |
+
|
46 |
+
async resetPassword(data: {
|
47 |
+
token: string
|
48 |
+
newPassword: string
|
49 |
+
}): Promise<void> {
|
50 |
+
await apiService.post('/api/auth/reset-password', data)
|
51 |
+
}
|
52 |
+
|
53 |
+
async verifyEmail(token: string): Promise<void> {
|
54 |
+
await apiService.post('/api/auth/verify-email', { token })
|
55 |
+
}
|
56 |
+
|
57 |
+
async resendVerificationEmail(): Promise<void> {
|
58 |
+
await apiService.post('/api/auth/resend-verification')
|
59 |
+
}
|
60 |
+
}
|
61 |
+
|
62 |
+
export const authService = new AuthService()
|
client/src/services/chatService.ts
ADDED
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Chat, Message, CreateChatRequest, SendMessageData, PaginatedResponse } from '../../../shared/types'
|
2 |
+
import { apiService } from './api'
|
3 |
+
|
4 |
+
class ChatService {
|
5 |
+
// Chat management
|
6 |
+
async getChats(): Promise<Chat[]> {
|
7 |
+
return await apiService.get<Chat[]>('/api/chats')
|
8 |
+
}
|
9 |
+
|
10 |
+
async getChat(chatId: string): Promise<Chat> {
|
11 |
+
return await apiService.get<Chat>(`/api/chats/${chatId}`)
|
12 |
+
}
|
13 |
+
|
14 |
+
async createChat(data: CreateChatRequest): Promise<Chat> {
|
15 |
+
return await apiService.post<Chat>('/api/chats', data)
|
16 |
+
}
|
17 |
+
|
18 |
+
async updateChat(chatId: string, data: Partial<Chat>): Promise<Chat> {
|
19 |
+
return await apiService.put<Chat>(`/api/chats/${chatId}`, data)
|
20 |
+
}
|
21 |
+
|
22 |
+
async deleteChat(chatId: string): Promise<void> {
|
23 |
+
await apiService.delete(`/api/chats/${chatId}`)
|
24 |
+
}
|
25 |
+
|
26 |
+
async leaveChat(chatId: string): Promise<void> {
|
27 |
+
await apiService.post(`/api/chats/${chatId}/leave`)
|
28 |
+
}
|
29 |
+
|
30 |
+
// Message management
|
31 |
+
async getMessages(chatId: string, page = 1, limit = 50): Promise<Message[]> {
|
32 |
+
const response = await apiService.get<PaginatedResponse<Message>>(
|
33 |
+
`/api/chats/${chatId}/messages?page=${page}&limit=${limit}`
|
34 |
+
)
|
35 |
+
return response.data
|
36 |
+
}
|
37 |
+
|
38 |
+
async sendMessage(chatId: string, data: Omit<SendMessageData, 'chatId'>): Promise<Message> {
|
39 |
+
if (data.attachments && data.attachments.length > 0) {
|
40 |
+
// Upload files first
|
41 |
+
const uploadedFiles = await this.uploadFiles(data.attachments)
|
42 |
+
return await apiService.post<Message>(`/api/chats/${chatId}/messages`, {
|
43 |
+
content: data.content,
|
44 |
+
type: data.type,
|
45 |
+
attachments: uploadedFiles,
|
46 |
+
replyTo: data.replyTo,
|
47 |
+
})
|
48 |
+
} else {
|
49 |
+
return await apiService.post<Message>(`/api/chats/${chatId}/messages`, {
|
50 |
+
content: data.content,
|
51 |
+
type: data.type,
|
52 |
+
replyTo: data.replyTo,
|
53 |
+
})
|
54 |
+
}
|
55 |
+
}
|
56 |
+
|
57 |
+
async editMessage(messageId: string, content: string): Promise<Message> {
|
58 |
+
return await apiService.put<Message>(`/api/messages/${messageId}`, { content })
|
59 |
+
}
|
60 |
+
|
61 |
+
async deleteMessage(messageId: string): Promise<void> {
|
62 |
+
await apiService.delete(`/api/messages/${messageId}`)
|
63 |
+
}
|
64 |
+
|
65 |
+
async addReaction(messageId: string, emoji: string): Promise<void> {
|
66 |
+
await apiService.post(`/api/messages/${messageId}/reactions`, { emoji })
|
67 |
+
}
|
68 |
+
|
69 |
+
async removeReaction(messageId: string, emoji: string): Promise<void> {
|
70 |
+
await apiService.delete(`/api/messages/${messageId}/reactions/${emoji}`)
|
71 |
+
}
|
72 |
+
|
73 |
+
// Group management
|
74 |
+
async addMember(chatId: string, userId: string): Promise<void> {
|
75 |
+
await apiService.post(`/api/chats/${chatId}/members`, { userId })
|
76 |
+
}
|
77 |
+
|
78 |
+
async removeMember(chatId: string, userId: string): Promise<void> {
|
79 |
+
await apiService.delete(`/api/chats/${chatId}/members/${userId}`)
|
80 |
+
}
|
81 |
+
|
82 |
+
async updateMemberRole(chatId: string, userId: string, role: string): Promise<void> {
|
83 |
+
await apiService.put(`/api/chats/${chatId}/members/${userId}`, { role })
|
84 |
+
}
|
85 |
+
|
86 |
+
async getChatMembers(chatId: string): Promise<any[]> {
|
87 |
+
return await apiService.get(`/api/chats/${chatId}/members`)
|
88 |
+
}
|
89 |
+
|
90 |
+
// File upload
|
91 |
+
async uploadFiles(files: File[]): Promise<any[]> {
|
92 |
+
const uploadPromises = files.map(file => this.uploadFile(file))
|
93 |
+
return await Promise.all(uploadPromises)
|
94 |
+
}
|
95 |
+
|
96 |
+
async uploadFile(file: File): Promise<any> {
|
97 |
+
return await apiService.upload('/api/upload', file)
|
98 |
+
}
|
99 |
+
|
100 |
+
// Search
|
101 |
+
async searchMessages(query: string, chatId?: string): Promise<Message[]> {
|
102 |
+
const params = new URLSearchParams({ q: query })
|
103 |
+
if (chatId) {
|
104 |
+
params.append('chatId', chatId)
|
105 |
+
}
|
106 |
+
return await apiService.get<Message[]>(`/api/search/messages?${params}`)
|
107 |
+
}
|
108 |
+
|
109 |
+
async searchChats(query: string): Promise<Chat[]> {
|
110 |
+
return await apiService.get<Chat[]>(`/api/search/chats?q=${encodeURIComponent(query)}`)
|
111 |
+
}
|
112 |
+
|
113 |
+
// Chat settings
|
114 |
+
async updateChatSettings(chatId: string, settings: any): Promise<void> {
|
115 |
+
await apiService.put(`/api/chats/${chatId}/settings`, settings)
|
116 |
+
}
|
117 |
+
|
118 |
+
async getChatSettings(chatId: string): Promise<any> {
|
119 |
+
return await apiService.get(`/api/chats/${chatId}/settings`)
|
120 |
+
}
|
121 |
+
|
122 |
+
// Notifications
|
123 |
+
async markAsRead(chatId: string): Promise<void> {
|
124 |
+
await apiService.post(`/api/chats/${chatId}/read`)
|
125 |
+
}
|
126 |
+
|
127 |
+
async muteChat(chatId: string, duration?: number): Promise<void> {
|
128 |
+
await apiService.post(`/api/chats/${chatId}/mute`, { duration })
|
129 |
+
}
|
130 |
+
|
131 |
+
async unmuteChat(chatId: string): Promise<void> {
|
132 |
+
await apiService.post(`/api/chats/${chatId}/unmute`)
|
133 |
+
}
|
134 |
+
}
|
135 |
+
|
136 |
+
export const chatService = new ChatService()
|
client/src/services/socketService.ts
ADDED
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { io, Socket } from 'socket.io-client'
|
2 |
+
import { Message, User, Chat } from '../../../shared/types'
|
3 |
+
|
4 |
+
class SocketService {
|
5 |
+
private socket: Socket | null = null
|
6 |
+
private reconnectAttempts = 0
|
7 |
+
private maxReconnectAttempts = 5
|
8 |
+
private reconnectDelay = 1000
|
9 |
+
|
10 |
+
connect(token: string): Promise<void> {
|
11 |
+
return new Promise((resolve, reject) => {
|
12 |
+
if (this.socket?.connected) {
|
13 |
+
resolve()
|
14 |
+
return
|
15 |
+
}
|
16 |
+
|
17 |
+
const socketUrl = import.meta.env.VITE_SOCKET_URL || 'http://localhost:3001'
|
18 |
+
|
19 |
+
this.socket = io(socketUrl, {
|
20 |
+
auth: {
|
21 |
+
token
|
22 |
+
},
|
23 |
+
transports: ['websocket', 'polling'],
|
24 |
+
timeout: 10000,
|
25 |
+
})
|
26 |
+
|
27 |
+
this.socket.on('connect', () => {
|
28 |
+
console.log('Socket connected')
|
29 |
+
this.reconnectAttempts = 0
|
30 |
+
resolve()
|
31 |
+
})
|
32 |
+
|
33 |
+
this.socket.on('connect_error', (error) => {
|
34 |
+
console.error('Socket connection error:', error)
|
35 |
+
reject(error)
|
36 |
+
})
|
37 |
+
|
38 |
+
this.socket.on('disconnect', (reason) => {
|
39 |
+
console.log('Socket disconnected:', reason)
|
40 |
+
if (reason === 'io server disconnect') {
|
41 |
+
// Server disconnected, try to reconnect
|
42 |
+
this.handleReconnect()
|
43 |
+
}
|
44 |
+
})
|
45 |
+
|
46 |
+
this.socket.on('error', (error) => {
|
47 |
+
console.error('Socket error:', error)
|
48 |
+
})
|
49 |
+
|
50 |
+
// Set up event listeners
|
51 |
+
this.setupEventListeners()
|
52 |
+
})
|
53 |
+
}
|
54 |
+
|
55 |
+
disconnect(): void {
|
56 |
+
if (this.socket) {
|
57 |
+
this.socket.disconnect()
|
58 |
+
this.socket = null
|
59 |
+
}
|
60 |
+
}
|
61 |
+
|
62 |
+
private handleReconnect(): void {
|
63 |
+
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
64 |
+
this.reconnectAttempts++
|
65 |
+
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
|
66 |
+
|
67 |
+
setTimeout(() => {
|
68 |
+
console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
|
69 |
+
this.socket?.connect()
|
70 |
+
}, delay)
|
71 |
+
} else {
|
72 |
+
console.error('Max reconnection attempts reached')
|
73 |
+
}
|
74 |
+
}
|
75 |
+
|
76 |
+
private setupEventListeners(): void {
|
77 |
+
if (!this.socket) return
|
78 |
+
|
79 |
+
// Authentication events
|
80 |
+
this.socket.on('authenticated', (user: User) => {
|
81 |
+
console.log('Socket authenticated for user:', user.username)
|
82 |
+
})
|
83 |
+
|
84 |
+
this.socket.on('authentication_error', (error: string) => {
|
85 |
+
console.error('Socket authentication error:', error)
|
86 |
+
})
|
87 |
+
}
|
88 |
+
|
89 |
+
// Message events
|
90 |
+
onMessageReceive(callback: (message: Message) => void): void {
|
91 |
+
this.socket?.on('message:receive', callback)
|
92 |
+
}
|
93 |
+
|
94 |
+
onMessageEdit(callback: (message: Message) => void): void {
|
95 |
+
this.socket?.on('message:edit', callback)
|
96 |
+
}
|
97 |
+
|
98 |
+
onMessageDelete(callback: (messageId: string) => void): void {
|
99 |
+
this.socket?.on('message:delete', callback)
|
100 |
+
}
|
101 |
+
|
102 |
+
onMessageReaction(callback: (data: { messageId: string; reactions: any[] }) => void): void {
|
103 |
+
this.socket?.on('message:reaction', callback)
|
104 |
+
}
|
105 |
+
|
106 |
+
// Typing events
|
107 |
+
onTypingStart(callback: (data: { chatId: string; userId: string; user: User }) => void): void {
|
108 |
+
this.socket?.on('typing:start', callback)
|
109 |
+
}
|
110 |
+
|
111 |
+
onTypingStop(callback: (data: { chatId: string; userId: string }) => void): void {
|
112 |
+
this.socket?.on('typing:stop', callback)
|
113 |
+
}
|
114 |
+
|
115 |
+
sendTypingStart(chatId: string): void {
|
116 |
+
this.socket?.emit('typing:start', { chatId })
|
117 |
+
}
|
118 |
+
|
119 |
+
sendTypingStop(chatId: string): void {
|
120 |
+
this.socket?.emit('typing:stop', { chatId })
|
121 |
+
}
|
122 |
+
|
123 |
+
// User status events
|
124 |
+
onUserOnline(callback: (userId: string) => void): void {
|
125 |
+
this.socket?.on('user:online', callback)
|
126 |
+
}
|
127 |
+
|
128 |
+
onUserOffline(callback: (userId: string) => void): void {
|
129 |
+
this.socket?.on('user:offline', callback)
|
130 |
+
}
|
131 |
+
|
132 |
+
onUserStatusUpdate(callback: (data: { userId: string; isOnline: boolean; lastSeen?: Date }) => void): void {
|
133 |
+
this.socket?.on('user:status', callback)
|
134 |
+
}
|
135 |
+
|
136 |
+
// Chat events
|
137 |
+
onChatUpdate(callback: (chat: Chat) => void): void {
|
138 |
+
this.socket?.on('chat:update', callback)
|
139 |
+
}
|
140 |
+
|
141 |
+
onChatJoin(callback: (data: { chatId: string; user: User }) => void): void {
|
142 |
+
this.socket?.on('chat:join', callback)
|
143 |
+
}
|
144 |
+
|
145 |
+
onChatLeave(callback: (data: { chatId: string; userId: string }) => void): void {
|
146 |
+
this.socket?.on('chat:leave', callback)
|
147 |
+
}
|
148 |
+
|
149 |
+
joinChat(chatId: string): void {
|
150 |
+
this.socket?.emit('chat:join', chatId)
|
151 |
+
}
|
152 |
+
|
153 |
+
leaveChat(chatId: string): void {
|
154 |
+
this.socket?.emit('chat:leave', chatId)
|
155 |
+
}
|
156 |
+
|
157 |
+
// Group events
|
158 |
+
onGroupMemberAdd(callback: (data: { groupId: string; user: User; addedBy: User }) => void): void {
|
159 |
+
this.socket?.on('group:member:add', callback)
|
160 |
+
}
|
161 |
+
|
162 |
+
onGroupMemberRemove(callback: (data: { groupId: string; userId: string; removedBy: User }) => void): void {
|
163 |
+
this.socket?.on('group:member:remove', callback)
|
164 |
+
}
|
165 |
+
|
166 |
+
onGroupUpdate(callback: (group: any) => void): void {
|
167 |
+
this.socket?.on('group:update', callback)
|
168 |
+
}
|
169 |
+
|
170 |
+
// Notification events
|
171 |
+
onNotification(callback: (notification: any) => void): void {
|
172 |
+
this.socket?.on('notification', callback)
|
173 |
+
}
|
174 |
+
|
175 |
+
// Utility methods
|
176 |
+
isConnected(): boolean {
|
177 |
+
return this.socket?.connected || false
|
178 |
+
}
|
179 |
+
|
180 |
+
emit(event: string, data?: any): void {
|
181 |
+
this.socket?.emit(event, data)
|
182 |
+
}
|
183 |
+
|
184 |
+
on(event: string, callback: (...args: any[]) => void): void {
|
185 |
+
this.socket?.on(event, callback)
|
186 |
+
}
|
187 |
+
|
188 |
+
off(event: string, callback?: (...args: any[]) => void): void {
|
189 |
+
this.socket?.off(event, callback)
|
190 |
+
}
|
191 |
+
|
192 |
+
// Remove all listeners for cleanup
|
193 |
+
removeAllListeners(): void {
|
194 |
+
this.socket?.removeAllListeners()
|
195 |
+
}
|
196 |
+
}
|
197 |
+
|
198 |
+
export const socketService = new SocketService()
|
client/src/store/authStore.ts
ADDED
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { create } from 'zustand'
|
2 |
+
import { persist } from 'zustand/middleware'
|
3 |
+
import { User } from '../../../shared/types'
|
4 |
+
import { authService } from '../services/authService'
|
5 |
+
|
6 |
+
interface AuthState {
|
7 |
+
user: User | null
|
8 |
+
token: string | null
|
9 |
+
loading: boolean
|
10 |
+
error: string | null
|
11 |
+
}
|
12 |
+
|
13 |
+
interface AuthActions {
|
14 |
+
login: (email: string, password: string) => Promise<void>
|
15 |
+
register: (data: {
|
16 |
+
email: string
|
17 |
+
username: string
|
18 |
+
password: string
|
19 |
+
displayName: string
|
20 |
+
}) => Promise<void>
|
21 |
+
logout: () => void
|
22 |
+
checkAuth: () => Promise<void>
|
23 |
+
updateUser: (user: Partial<User>) => void
|
24 |
+
clearError: () => void
|
25 |
+
}
|
26 |
+
|
27 |
+
export const useAuthStore = create<AuthState & AuthActions>()(
|
28 |
+
persist(
|
29 |
+
(set, get) => ({
|
30 |
+
// State
|
31 |
+
user: null,
|
32 |
+
token: null,
|
33 |
+
loading: false,
|
34 |
+
error: null,
|
35 |
+
|
36 |
+
// Actions
|
37 |
+
login: async (email: string, password: string) => {
|
38 |
+
set({ loading: true, error: null })
|
39 |
+
try {
|
40 |
+
const response = await authService.login({ email, password })
|
41 |
+
set({
|
42 |
+
user: response.user,
|
43 |
+
token: response.token,
|
44 |
+
loading: false,
|
45 |
+
error: null,
|
46 |
+
})
|
47 |
+
} catch (error: any) {
|
48 |
+
set({
|
49 |
+
loading: false,
|
50 |
+
error: error.message || 'Login failed',
|
51 |
+
})
|
52 |
+
throw error
|
53 |
+
}
|
54 |
+
},
|
55 |
+
|
56 |
+
register: async (data) => {
|
57 |
+
set({ loading: true, error: null })
|
58 |
+
try {
|
59 |
+
const response = await authService.register(data)
|
60 |
+
set({
|
61 |
+
user: response.user,
|
62 |
+
token: response.token,
|
63 |
+
loading: false,
|
64 |
+
error: null,
|
65 |
+
})
|
66 |
+
} catch (error: any) {
|
67 |
+
set({
|
68 |
+
loading: false,
|
69 |
+
error: error.message || 'Registration failed',
|
70 |
+
})
|
71 |
+
throw error
|
72 |
+
}
|
73 |
+
},
|
74 |
+
|
75 |
+
logout: () => {
|
76 |
+
authService.logout()
|
77 |
+
set({
|
78 |
+
user: null,
|
79 |
+
token: null,
|
80 |
+
error: null,
|
81 |
+
})
|
82 |
+
},
|
83 |
+
|
84 |
+
checkAuth: async () => {
|
85 |
+
const { token } = get()
|
86 |
+
if (!token) {
|
87 |
+
set({ loading: false })
|
88 |
+
return
|
89 |
+
}
|
90 |
+
|
91 |
+
set({ loading: true })
|
92 |
+
try {
|
93 |
+
const user = await authService.getCurrentUser()
|
94 |
+
set({
|
95 |
+
user,
|
96 |
+
loading: false,
|
97 |
+
error: null,
|
98 |
+
})
|
99 |
+
} catch (error) {
|
100 |
+
set({
|
101 |
+
user: null,
|
102 |
+
token: null,
|
103 |
+
loading: false,
|
104 |
+
error: null,
|
105 |
+
})
|
106 |
+
}
|
107 |
+
},
|
108 |
+
|
109 |
+
updateUser: (userData) => {
|
110 |
+
const { user } = get()
|
111 |
+
if (user) {
|
112 |
+
set({
|
113 |
+
user: { ...user, ...userData },
|
114 |
+
})
|
115 |
+
}
|
116 |
+
},
|
117 |
+
|
118 |
+
clearError: () => {
|
119 |
+
set({ error: null })
|
120 |
+
},
|
121 |
+
}),
|
122 |
+
{
|
123 |
+
name: 'auth-storage',
|
124 |
+
partialize: (state) => ({
|
125 |
+
token: state.token,
|
126 |
+
user: state.user,
|
127 |
+
}),
|
128 |
+
}
|
129 |
+
)
|
130 |
+
)
|
client/src/store/chatStore.ts
ADDED
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { create } from 'zustand'
|
2 |
+
import { Chat, Message } from '../../../shared/types'
|
3 |
+
import { chatService } from '../services/chatService'
|
4 |
+
|
5 |
+
interface ChatState {
|
6 |
+
chats: Chat[]
|
7 |
+
currentChat: Chat | null
|
8 |
+
messages: Record<string, Message[]>
|
9 |
+
typingUsers: Record<string, string[]>
|
10 |
+
onlineUsers: Set<string>
|
11 |
+
loading: boolean
|
12 |
+
error: string | null
|
13 |
+
}
|
14 |
+
|
15 |
+
interface ChatActions {
|
16 |
+
// Chat management
|
17 |
+
loadChats: () => Promise<void>
|
18 |
+
selectChat: (chatId: string) => void
|
19 |
+
createChat: (data: {
|
20 |
+
type: 'direct' | 'group'
|
21 |
+
name?: string
|
22 |
+
participantIds: string[]
|
23 |
+
}) => Promise<Chat>
|
24 |
+
updateChat: (chatId: string, data: Partial<Chat>) => Promise<void>
|
25 |
+
deleteChat: (chatId: string) => Promise<void>
|
26 |
+
|
27 |
+
// Message management
|
28 |
+
loadMessages: (chatId: string, page?: number) => Promise<void>
|
29 |
+
sendMessage: (chatId: string, content: string, attachments?: File[]) => Promise<void>
|
30 |
+
editMessage: (messageId: string, content: string) => Promise<void>
|
31 |
+
deleteMessage: (messageId: string) => Promise<void>
|
32 |
+
addReaction: (messageId: string, emoji: string) => Promise<void>
|
33 |
+
removeReaction: (messageId: string, emoji: string) => Promise<void>
|
34 |
+
|
35 |
+
// Real-time updates
|
36 |
+
addMessage: (message: Message) => void
|
37 |
+
updateMessage: (message: Message) => void
|
38 |
+
removeMessage: (messageId: string) => void
|
39 |
+
applyChatUpdate: (chat: Chat) => void
|
40 |
+
|
41 |
+
// Typing indicators
|
42 |
+
setTyping: (chatId: string, userId: string, isTyping: boolean) => void
|
43 |
+
|
44 |
+
// User status
|
45 |
+
setUserOnline: (userId: string, isOnline: boolean) => void
|
46 |
+
|
47 |
+
// Utility
|
48 |
+
clearError: () => void
|
49 |
+
reset: () => void
|
50 |
+
}
|
51 |
+
|
52 |
+
export const useChatStore = create<ChatState & ChatActions>((set, get) => ({
|
53 |
+
// State
|
54 |
+
chats: [],
|
55 |
+
currentChat: null,
|
56 |
+
messages: {},
|
57 |
+
typingUsers: {},
|
58 |
+
onlineUsers: new Set(),
|
59 |
+
loading: false,
|
60 |
+
error: null,
|
61 |
+
|
62 |
+
// Actions
|
63 |
+
loadChats: async () => {
|
64 |
+
set({ loading: true, error: null })
|
65 |
+
try {
|
66 |
+
const chats = await chatService.getChats()
|
67 |
+
set({ chats, loading: false })
|
68 |
+
} catch (error: any) {
|
69 |
+
set({ error: error.message, loading: false })
|
70 |
+
}
|
71 |
+
},
|
72 |
+
|
73 |
+
selectChat: (chatId: string) => {
|
74 |
+
const { chats } = get()
|
75 |
+
const chat = chats.find(c => c.id === chatId)
|
76 |
+
if (chat) {
|
77 |
+
set({ currentChat: chat })
|
78 |
+
// Load messages if not already loaded
|
79 |
+
if (!get().messages[chatId]) {
|
80 |
+
get().loadMessages(chatId)
|
81 |
+
}
|
82 |
+
}
|
83 |
+
},
|
84 |
+
|
85 |
+
createChat: async (data) => {
|
86 |
+
set({ loading: true, error: null })
|
87 |
+
try {
|
88 |
+
const chat = await chatService.createChat(data)
|
89 |
+
set(state => ({
|
90 |
+
chats: [chat, ...state.chats],
|
91 |
+
currentChat: chat,
|
92 |
+
loading: false
|
93 |
+
}))
|
94 |
+
return chat
|
95 |
+
} catch (error: any) {
|
96 |
+
set({ error: error.message, loading: false })
|
97 |
+
throw error
|
98 |
+
}
|
99 |
+
},
|
100 |
+
|
101 |
+
updateChat: async (chatId: string, data: Partial<Chat>) => {
|
102 |
+
try {
|
103 |
+
const updatedChat = await chatService.updateChat(chatId, data)
|
104 |
+
set(state => ({
|
105 |
+
chats: state.chats.map(chat =>
|
106 |
+
chat.id === chatId ? updatedChat : chat
|
107 |
+
),
|
108 |
+
currentChat: state.currentChat?.id === chatId ? updatedChat : state.currentChat
|
109 |
+
}))
|
110 |
+
} catch (error: any) {
|
111 |
+
set({ error: error.message })
|
112 |
+
}
|
113 |
+
},
|
114 |
+
|
115 |
+
deleteChat: async (chatId: string) => {
|
116 |
+
try {
|
117 |
+
await chatService.deleteChat(chatId)
|
118 |
+
set(state => ({
|
119 |
+
chats: state.chats.filter(chat => chat.id !== chatId),
|
120 |
+
currentChat: state.currentChat?.id === chatId ? null : state.currentChat,
|
121 |
+
messages: Object.fromEntries(
|
122 |
+
Object.entries(state.messages).filter(([id]) => id !== chatId)
|
123 |
+
)
|
124 |
+
}))
|
125 |
+
} catch (error: any) {
|
126 |
+
set({ error: error.message })
|
127 |
+
}
|
128 |
+
},
|
129 |
+
|
130 |
+
loadMessages: async (chatId: string, page = 1) => {
|
131 |
+
set({ loading: true, error: null })
|
132 |
+
try {
|
133 |
+
const messages = await chatService.getMessages(chatId, page)
|
134 |
+
set(state => ({
|
135 |
+
messages: {
|
136 |
+
...state.messages,
|
137 |
+
[chatId]: page === 1 ? messages : [...(state.messages[chatId] || []), ...messages]
|
138 |
+
},
|
139 |
+
loading: false
|
140 |
+
}))
|
141 |
+
} catch (error: any) {
|
142 |
+
set({ error: error.message, loading: false })
|
143 |
+
}
|
144 |
+
},
|
145 |
+
|
146 |
+
sendMessage: async (chatId: string, content: string, attachments?: File[]) => {
|
147 |
+
try {
|
148 |
+
await chatService.sendMessage(chatId, {
|
149 |
+
content,
|
150 |
+
type: 'text',
|
151 |
+
attachments
|
152 |
+
})
|
153 |
+
// Message will be added via socket event
|
154 |
+
} catch (error: any) {
|
155 |
+
set({ error: error.message })
|
156 |
+
}
|
157 |
+
},
|
158 |
+
|
159 |
+
editMessage: async (messageId: string, content: string) => {
|
160 |
+
try {
|
161 |
+
await chatService.editMessage(messageId, content)
|
162 |
+
// Message will be updated via socket event
|
163 |
+
} catch (error: any) {
|
164 |
+
set({ error: error.message })
|
165 |
+
}
|
166 |
+
},
|
167 |
+
|
168 |
+
deleteMessage: async (messageId: string) => {
|
169 |
+
try {
|
170 |
+
await chatService.deleteMessage(messageId)
|
171 |
+
// Message will be removed via socket event
|
172 |
+
} catch (error: any) {
|
173 |
+
set({ error: error.message })
|
174 |
+
}
|
175 |
+
},
|
176 |
+
|
177 |
+
addReaction: async (messageId: string, emoji: string) => {
|
178 |
+
try {
|
179 |
+
await chatService.addReaction(messageId, emoji)
|
180 |
+
// Reaction will be updated via socket event
|
181 |
+
} catch (error: any) {
|
182 |
+
set({ error: error.message })
|
183 |
+
}
|
184 |
+
},
|
185 |
+
|
186 |
+
removeReaction: async (messageId: string, emoji: string) => {
|
187 |
+
try {
|
188 |
+
await chatService.removeReaction(messageId, emoji)
|
189 |
+
// Reaction will be updated via socket event
|
190 |
+
} catch (error: any) {
|
191 |
+
set({ error: error.message })
|
192 |
+
}
|
193 |
+
},
|
194 |
+
|
195 |
+
// Real-time updates
|
196 |
+
addMessage: (message: Message) => {
|
197 |
+
set(state => ({
|
198 |
+
messages: {
|
199 |
+
...state.messages,
|
200 |
+
[message.chatId]: [...(state.messages[message.chatId] || []), message]
|
201 |
+
},
|
202 |
+
chats: state.chats.map(chat =>
|
203 |
+
chat.id === message.chatId
|
204 |
+
? { ...chat, lastMessage: message, updatedAt: new Date() }
|
205 |
+
: chat
|
206 |
+
)
|
207 |
+
}))
|
208 |
+
},
|
209 |
+
|
210 |
+
updateMessage: (message: Message) => {
|
211 |
+
set(state => ({
|
212 |
+
messages: {
|
213 |
+
...state.messages,
|
214 |
+
[message.chatId]: (state.messages[message.chatId] || []).map(msg =>
|
215 |
+
msg.id === message.id ? message : msg
|
216 |
+
)
|
217 |
+
}
|
218 |
+
}))
|
219 |
+
},
|
220 |
+
|
221 |
+
removeMessage: (messageId: string) => {
|
222 |
+
set(state => {
|
223 |
+
const newMessages = { ...state.messages }
|
224 |
+
Object.keys(newMessages).forEach(chatId => {
|
225 |
+
newMessages[chatId] = newMessages[chatId].filter(msg => msg.id !== messageId)
|
226 |
+
})
|
227 |
+
return { messages: newMessages }
|
228 |
+
})
|
229 |
+
},
|
230 |
+
|
231 |
+
applyChatUpdate: (chat: Chat) => {
|
232 |
+
set(state => ({
|
233 |
+
chats: state.chats.map(c => c.id === chat.id ? chat : c),
|
234 |
+
currentChat: state.currentChat?.id === chat.id ? chat : state.currentChat
|
235 |
+
}))
|
236 |
+
},
|
237 |
+
|
238 |
+
setTyping: (chatId: string, userId: string, isTyping: boolean) => {
|
239 |
+
set(state => {
|
240 |
+
const typingUsers = { ...state.typingUsers }
|
241 |
+
const currentTyping = typingUsers[chatId] || []
|
242 |
+
|
243 |
+
if (isTyping) {
|
244 |
+
if (!currentTyping.includes(userId)) {
|
245 |
+
typingUsers[chatId] = [...currentTyping, userId]
|
246 |
+
}
|
247 |
+
} else {
|
248 |
+
typingUsers[chatId] = currentTyping.filter(id => id !== userId)
|
249 |
+
if (typingUsers[chatId].length === 0) {
|
250 |
+
delete typingUsers[chatId]
|
251 |
+
}
|
252 |
+
}
|
253 |
+
|
254 |
+
return { typingUsers }
|
255 |
+
})
|
256 |
+
},
|
257 |
+
|
258 |
+
setUserOnline: (userId: string, isOnline: boolean) => {
|
259 |
+
set(state => {
|
260 |
+
const onlineUsers = new Set(state.onlineUsers)
|
261 |
+
if (isOnline) {
|
262 |
+
onlineUsers.add(userId)
|
263 |
+
} else {
|
264 |
+
onlineUsers.delete(userId)
|
265 |
+
}
|
266 |
+
return { onlineUsers }
|
267 |
+
})
|
268 |
+
},
|
269 |
+
|
270 |
+
clearError: () => {
|
271 |
+
set({ error: null })
|
272 |
+
},
|
273 |
+
|
274 |
+
reset: () => {
|
275 |
+
set({
|
276 |
+
chats: [],
|
277 |
+
currentChat: null,
|
278 |
+
messages: {},
|
279 |
+
typingUsers: {},
|
280 |
+
onlineUsers: new Set(),
|
281 |
+
loading: false,
|
282 |
+
error: null
|
283 |
+
})
|
284 |
+
}
|
285 |
+
}))
|
client/src/vite-env.d.ts
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/// <reference types="vite/client" />
|
2 |
+
|
3 |
+
interface ImportMetaEnv {
|
4 |
+
readonly VITE_API_URL?: string
|
5 |
+
readonly VITE_SOCKET_URL?: string
|
6 |
+
}
|
7 |
+
|
8 |
+
interface ImportMeta {
|
9 |
+
readonly env: ImportMetaEnv
|
10 |
+
}
|
client/tailwind.config.js
ADDED
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/** @type {import('tailwindcss').Config} */
|
2 |
+
export default {
|
3 |
+
darkMode: ["class"],
|
4 |
+
content: [
|
5 |
+
'./pages/**/*.{ts,tsx}',
|
6 |
+
'./components/**/*.{ts,tsx}',
|
7 |
+
'./app/**/*.{ts,tsx}',
|
8 |
+
'./src/**/*.{ts,tsx}',
|
9 |
+
],
|
10 |
+
theme: {
|
11 |
+
container: {
|
12 |
+
center: true,
|
13 |
+
padding: "2rem",
|
14 |
+
screens: {
|
15 |
+
"2xl": "1400px",
|
16 |
+
},
|
17 |
+
},
|
18 |
+
extend: {
|
19 |
+
colors: {
|
20 |
+
border: "hsl(var(--border))",
|
21 |
+
input: "hsl(var(--input))",
|
22 |
+
ring: "hsl(var(--ring))",
|
23 |
+
background: "hsl(var(--background))",
|
24 |
+
foreground: "hsl(var(--foreground))",
|
25 |
+
primary: {
|
26 |
+
DEFAULT: "hsl(var(--primary))",
|
27 |
+
foreground: "hsl(var(--primary-foreground))",
|
28 |
+
},
|
29 |
+
secondary: {
|
30 |
+
DEFAULT: "hsl(var(--secondary))",
|
31 |
+
foreground: "hsl(var(--secondary-foreground))",
|
32 |
+
},
|
33 |
+
destructive: {
|
34 |
+
DEFAULT: "hsl(var(--destructive))",
|
35 |
+
foreground: "hsl(var(--destructive-foreground))",
|
36 |
+
},
|
37 |
+
muted: {
|
38 |
+
DEFAULT: "hsl(var(--muted))",
|
39 |
+
foreground: "hsl(var(--muted-foreground))",
|
40 |
+
},
|
41 |
+
accent: {
|
42 |
+
DEFAULT: "hsl(var(--accent))",
|
43 |
+
foreground: "hsl(var(--accent-foreground))",
|
44 |
+
},
|
45 |
+
popover: {
|
46 |
+
DEFAULT: "hsl(var(--popover))",
|
47 |
+
foreground: "hsl(var(--popover-foreground))",
|
48 |
+
},
|
49 |
+
card: {
|
50 |
+
DEFAULT: "hsl(var(--card))",
|
51 |
+
foreground: "hsl(var(--card-foreground))",
|
52 |
+
},
|
53 |
+
},
|
54 |
+
borderRadius: {
|
55 |
+
lg: "var(--radius)",
|
56 |
+
md: "calc(var(--radius) - 2px)",
|
57 |
+
sm: "calc(var(--radius) - 4px)",
|
58 |
+
},
|
59 |
+
keyframes: {
|
60 |
+
"accordion-down": {
|
61 |
+
from: { height: 0 },
|
62 |
+
to: { height: "var(--radix-accordion-content-height)" },
|
63 |
+
},
|
64 |
+
"accordion-up": {
|
65 |
+
from: { height: "var(--radix-accordion-content-height)" },
|
66 |
+
to: { height: 0 },
|
67 |
+
},
|
68 |
+
},
|
69 |
+
animation: {
|
70 |
+
"accordion-down": "accordion-down 0.2s ease-out",
|
71 |
+
"accordion-up": "accordion-up 0.2s ease-out",
|
72 |
+
},
|
73 |
+
},
|
74 |
+
},
|
75 |
+
plugins: [require("tailwindcss-animate")],
|
76 |
+
}
|
client/tsconfig.json
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"compilerOptions": {
|
3 |
+
"target": "ES2020",
|
4 |
+
"useDefineForClassFields": true,
|
5 |
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
6 |
+
"module": "ESNext",
|
7 |
+
"skipLibCheck": true,
|
8 |
+
|
9 |
+
/* Bundler mode */
|
10 |
+
"moduleResolution": "bundler",
|
11 |
+
"allowImportingTsExtensions": true,
|
12 |
+
"resolveJsonModule": true,
|
13 |
+
"isolatedModules": true,
|
14 |
+
"noEmit": true,
|
15 |
+
"jsx": "react-jsx",
|
16 |
+
|
17 |
+
/* Linting */
|
18 |
+
"strict": true,
|
19 |
+
"noUnusedLocals": true,
|
20 |
+
"noUnusedParameters": true,
|
21 |
+
"noFallthroughCasesInSwitch": true,
|
22 |
+
|
23 |
+
/* Path mapping */
|
24 |
+
"baseUrl": ".",
|
25 |
+
"paths": {
|
26 |
+
"@/*": ["./src/*"]
|
27 |
+
}
|
28 |
+
},
|
29 |
+
"include": ["src"],
|
30 |
+
"references": [{ "path": "./tsconfig.node.json" }]
|
31 |
+
}
|
client/tsconfig.node.json
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"compilerOptions": {
|
3 |
+
"composite": true,
|
4 |
+
"skipLibCheck": true,
|
5 |
+
"module": "ESNext",
|
6 |
+
"moduleResolution": "bundler",
|
7 |
+
"allowSyntheticDefaultImports": true
|
8 |
+
},
|
9 |
+
"include": ["vite.config.ts"]
|
10 |
+
}
|
client/vite.config.ts
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { defineConfig } from 'vite'
|
2 |
+
import react from '@vitejs/plugin-react'
|
3 |
+
import path from 'path'
|
4 |
+
|
5 |
+
// https://vitejs.dev/config/
|
6 |
+
export default defineConfig({
|
7 |
+
plugins: [react()],
|
8 |
+
resolve: {
|
9 |
+
alias: {
|
10 |
+
"@": path.resolve(__dirname, "./src"),
|
11 |
+
},
|
12 |
+
},
|
13 |
+
server: {
|
14 |
+
port: 5173,
|
15 |
+
proxy: {
|
16 |
+
'/api': {
|
17 |
+
target: 'http://localhost:3001',
|
18 |
+
changeOrigin: true,
|
19 |
+
},
|
20 |
+
'/socket.io': {
|
21 |
+
target: 'http://localhost:3001',
|
22 |
+
changeOrigin: true,
|
23 |
+
ws: true,
|
24 |
+
},
|
25 |
+
},
|
26 |
+
},
|
27 |
+
})
|
package.json
ADDED
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "chat-app",
|
3 |
+
"version": "1.0.0",
|
4 |
+
"description": "A modern chat application with Telegram-like features",
|
5 |
+
"main": "index.js",
|
6 |
+
"scripts": {
|
7 |
+
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
8 |
+
"dev:server": "cd server && npm run dev",
|
9 |
+
"dev:client": "cd client && npm run dev",
|
10 |
+
"build": "npm run build:client && npm run build:server",
|
11 |
+
"build:client": "cd client && npm run build",
|
12 |
+
"build:server": "cd server && npm run build",
|
13 |
+
"start": "cd server && npm run start",
|
14 |
+
"test": "npm run test:client && npm run test:server",
|
15 |
+
"test:client": "cd client && npm run test",
|
16 |
+
"test:server": "cd server && npm run test",
|
17 |
+
"lint": "npm run lint:client && npm run lint:server",
|
18 |
+
"lint:client": "cd client && npm run lint",
|
19 |
+
"lint:server": "cd server && npm run lint",
|
20 |
+
"install:all": "npm install && cd client && npm install && cd ../server && npm install"
|
21 |
+
},
|
22 |
+
"keywords": [
|
23 |
+
"chat",
|
24 |
+
"messaging",
|
25 |
+
"real-time",
|
26 |
+
"socket.io",
|
27 |
+
"react",
|
28 |
+
"node.js",
|
29 |
+
"typescript"
|
30 |
+
],
|
31 |
+
"author": "Your Name",
|
32 |
+
"license": "MIT",
|
33 |
+
"devDependencies": {
|
34 |
+
"concurrently": "^8.2.2"
|
35 |
+
},
|
36 |
+
"workspaces": [
|
37 |
+
"client",
|
38 |
+
"server",
|
39 |
+
"shared"
|
40 |
+
]
|
41 |
+
}
|
server/.env.example
ADDED
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Server Configuration
|
2 |
+
NODE_ENV=development
|
3 |
+
PORT=3001
|
4 |
+
HOST=localhost
|
5 |
+
|
6 |
+
# Database Configuration (Supabase)
|
7 |
+
DATABASE_URL=postgresql://username:password@localhost:5432/chatapp
|
8 |
+
SUPABASE_URL=your_supabase_url
|
9 |
+
SUPABASE_SERVICE_KEY=your_supabase_service_key
|
10 |
+
|
11 |
+
# JWT Configuration
|
12 |
+
JWT_SECRET=your_super_secret_jwt_key_here
|
13 |
+
JWT_EXPIRES_IN=7d
|
14 |
+
JWT_REFRESH_SECRET=your_refresh_token_secret
|
15 |
+
JWT_REFRESH_EXPIRES_IN=30d
|
16 |
+
|
17 |
+
# Redis Configuration (Optional - for caching and sessions)
|
18 |
+
REDIS_URL=redis://localhost:6379
|
19 |
+
REDIS_PASSWORD=
|
20 |
+
|
21 |
+
# File Upload Configuration
|
22 |
+
MAX_FILE_SIZE=10485760
|
23 |
+
ALLOWED_FILE_TYPES=image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,audio/mp3,audio/wav,application/pdf,text/plain
|
24 |
+
UPLOAD_PATH=uploads
|
25 |
+
|
26 |
+
# Email Configuration (for notifications)
|
27 |
+
SMTP_HOST=smtp.gmail.com
|
28 |
+
SMTP_PORT=587
|
29 | |
30 |
+
SMTP_PASS=your_app_password
|
31 | |
32 |
+
FROM_NAME=ChatApp
|
33 |
+
|
34 |
+
# Rate Limiting
|
35 |
+
RATE_LIMIT_WINDOW_MS=900000
|
36 |
+
RATE_LIMIT_MAX_REQUESTS=100
|
37 |
+
|
38 |
+
# CORS Configuration
|
39 |
+
CORS_ORIGIN=http://localhost:5173
|
40 |
+
|
41 |
+
# Admin Configuration
|
42 | |
43 |
+
ADMIN_PASSWORD=admin123456
|
44 |
+
|
45 |
+
# Logging
|
46 |
+
LOG_LEVEL=info
|
47 |
+
LOG_FILE=logs/app.log
|
48 |
+
|
49 |
+
# WebSocket Configuration
|
50 |
+
SOCKET_CORS_ORIGIN=http://localhost:5173
|
server/package.json
ADDED
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "chat-app-server",
|
3 |
+
"version": "1.0.0",
|
4 |
+
"description": "Chat application backend server",
|
5 |
+
"main": "dist/index.js",
|
6 |
+
"scripts": {
|
7 |
+
"dev": "nodemon src/index.ts",
|
8 |
+
"build": "tsc",
|
9 |
+
"start": "node dist/index.js",
|
10 |
+
"test": "jest",
|
11 |
+
"test:watch": "jest --watch",
|
12 |
+
"lint": "eslint src --ext .ts",
|
13 |
+
"lint:fix": "eslint src --ext .ts --fix",
|
14 |
+
"migrate": "npx prisma migrate dev",
|
15 |
+
"db:generate": "npx prisma generate",
|
16 |
+
"db:push": "npx prisma db push",
|
17 |
+
"db:studio": "npx prisma studio"
|
18 |
+
},
|
19 |
+
"keywords": [
|
20 |
+
"chat",
|
21 |
+
"socket.io",
|
22 |
+
"express",
|
23 |
+
"typescript",
|
24 |
+
"node.js"
|
25 |
+
],
|
26 |
+
"author": "Your Name",
|
27 |
+
"license": "MIT",
|
28 |
+
"dependencies": {
|
29 |
+
"express": "^4.18.2",
|
30 |
+
"socket.io": "^4.7.4",
|
31 |
+
"cors": "^2.8.5",
|
32 |
+
"helmet": "^7.1.0",
|
33 |
+
"bcryptjs": "^2.4.3",
|
34 |
+
"jsonwebtoken": "^9.0.2",
|
35 |
+
"multer": "^1.4.5-lts.1",
|
36 |
+
"sharp": "^0.32.6",
|
37 |
+
"joi": "^17.11.0",
|
38 |
+
"dotenv": "^16.3.1",
|
39 |
+
"@supabase/supabase-js": "^2.38.4",
|
40 |
+
"prisma": "^5.6.0",
|
41 |
+
"@prisma/client": "^5.6.0",
|
42 |
+
"redis": "^4.6.10",
|
43 |
+
"nodemailer": "^6.9.7",
|
44 |
+
"express-rate-limit": "^7.1.5",
|
45 |
+
"compression": "^1.7.4",
|
46 |
+
"morgan": "^1.10.0",
|
47 |
+
"uuid": "^9.0.1"
|
48 |
+
},
|
49 |
+
"devDependencies": {
|
50 |
+
"@types/express": "^4.17.21",
|
51 |
+
"@types/cors": "^2.8.17",
|
52 |
+
"@types/bcryptjs": "^2.4.6",
|
53 |
+
"@types/jsonwebtoken": "^9.0.5",
|
54 |
+
"@types/multer": "^1.4.11",
|
55 |
+
"@types/node": "^20.9.0",
|
56 |
+
"@types/compression": "^1.7.5",
|
57 |
+
"@types/morgan": "^1.9.9",
|
58 |
+
"@types/uuid": "^9.0.7",
|
59 |
+
"@types/nodemailer": "^6.4.14",
|
60 |
+
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
61 |
+
"@typescript-eslint/parser": "^6.10.0",
|
62 |
+
"eslint": "^8.53.0",
|
63 |
+
"jest": "^29.7.0",
|
64 |
+
"@types/jest": "^29.5.8",
|
65 |
+
"ts-jest": "^29.1.1",
|
66 |
+
"nodemon": "^3.0.1",
|
67 |
+
"ts-node": "^10.9.1",
|
68 |
+
"typescript": "^5.2.2"
|
69 |
+
}
|
70 |
+
}
|
server/prisma/schema.prisma
ADDED
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// This is your Prisma schema file,
|
2 |
+
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
3 |
+
|
4 |
+
generator client {
|
5 |
+
provider = "prisma-client-js"
|
6 |
+
}
|
7 |
+
|
8 |
+
datasource db {
|
9 |
+
provider = "postgresql"
|
10 |
+
url = env("DATABASE_URL")
|
11 |
+
}
|
12 |
+
|
13 |
+
model User {
|
14 |
+
id String @id @default(cuid())
|
15 |
+
email String @unique
|
16 |
+
username String @unique
|
17 |
+
displayName String
|
18 |
+
password String
|
19 |
+
avatar String?
|
20 |
+
bio String?
|
21 |
+
isOnline Boolean @default(false)
|
22 |
+
lastSeen DateTime @default(now())
|
23 |
+
isAdmin Boolean @default(false)
|
24 |
+
isVerified Boolean @default(false)
|
25 |
+
createdAt DateTime @default(now())
|
26 |
+
updatedAt DateTime @updatedAt
|
27 |
+
|
28 |
+
// Relations
|
29 |
+
sentMessages Message[] @relation("MessageSender")
|
30 |
+
chatParticipants ChatParticipant[]
|
31 |
+
messageReactions MessageReaction[]
|
32 |
+
ownedGroups Group[] @relation("GroupOwner")
|
33 |
+
notifications Notification[]
|
34 |
+
userSessions UserSession[]
|
35 |
+
|
36 |
+
@@map("users")
|
37 |
+
}
|
38 |
+
|
39 |
+
model Chat {
|
40 |
+
id String @id @default(cuid())
|
41 |
+
type ChatType @default(DIRECT)
|
42 |
+
name String?
|
43 |
+
description String?
|
44 |
+
avatar String?
|
45 |
+
createdAt DateTime @default(now())
|
46 |
+
updatedAt DateTime @updatedAt
|
47 |
+
|
48 |
+
// Relations
|
49 |
+
participants ChatParticipant[]
|
50 |
+
messages Message[]
|
51 |
+
group Group?
|
52 |
+
|
53 |
+
@@map("chats")
|
54 |
+
}
|
55 |
+
|
56 |
+
model ChatParticipant {
|
57 |
+
id String @id @default(cuid())
|
58 |
+
chatId String
|
59 |
+
userId String
|
60 |
+
role ParticipantRole @default(MEMBER)
|
61 |
+
permissions Json?
|
62 |
+
joinedAt DateTime @default(now())
|
63 |
+
leftAt DateTime?
|
64 |
+
|
65 |
+
// Relations
|
66 |
+
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
67 |
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
68 |
+
|
69 |
+
@@unique([chatId, userId])
|
70 |
+
@@map("chat_participants")
|
71 |
+
}
|
72 |
+
|
73 |
+
model Message {
|
74 |
+
id String @id @default(cuid())
|
75 |
+
chatId String
|
76 |
+
senderId String
|
77 |
+
content String
|
78 |
+
type MessageType @default(TEXT)
|
79 |
+
replyToId String?
|
80 |
+
isEdited Boolean @default(false)
|
81 |
+
isDeleted Boolean @default(false)
|
82 |
+
createdAt DateTime @default(now())
|
83 |
+
updatedAt DateTime @updatedAt
|
84 |
+
|
85 |
+
// Relations
|
86 |
+
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
87 |
+
sender User @relation("MessageSender", fields: [senderId], references: [id], onDelete: Cascade)
|
88 |
+
replyTo Message? @relation("MessageReply", fields: [replyToId], references: [id])
|
89 |
+
replies Message[] @relation("MessageReply")
|
90 |
+
attachments MessageAttachment[]
|
91 |
+
reactions MessageReaction[]
|
92 |
+
|
93 |
+
@@map("messages")
|
94 |
+
}
|
95 |
+
|
96 |
+
model MessageAttachment {
|
97 |
+
id String @id @default(cuid())
|
98 |
+
messageId String
|
99 |
+
type String
|
100 |
+
name String
|
101 |
+
url String
|
102 |
+
size Int
|
103 |
+
mimeType String
|
104 |
+
thumbnail String?
|
105 |
+
|
106 |
+
// Relations
|
107 |
+
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
108 |
+
|
109 |
+
@@map("message_attachments")
|
110 |
+
}
|
111 |
+
|
112 |
+
model MessageReaction {
|
113 |
+
id String @id @default(cuid())
|
114 |
+
messageId String
|
115 |
+
userId String
|
116 |
+
emoji String
|
117 |
+
createdAt DateTime @default(now())
|
118 |
+
|
119 |
+
// Relations
|
120 |
+
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
121 |
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
122 |
+
|
123 |
+
@@unique([messageId, userId, emoji])
|
124 |
+
@@map("message_reactions")
|
125 |
+
}
|
126 |
+
|
127 |
+
model Group {
|
128 |
+
id String @id @default(cuid())
|
129 |
+
chatId String @unique
|
130 |
+
name String
|
131 |
+
description String?
|
132 |
+
avatar String?
|
133 |
+
type GroupType @default(PRIVATE)
|
134 |
+
maxMembers Int @default(100)
|
135 |
+
ownerId String
|
136 |
+
settings Json?
|
137 |
+
createdAt DateTime @default(now())
|
138 |
+
updatedAt DateTime @updatedAt
|
139 |
+
|
140 |
+
// Relations
|
141 |
+
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
142 |
+
owner User @relation("GroupOwner", fields: [ownerId], references: [id])
|
143 |
+
|
144 |
+
@@map("groups")
|
145 |
+
}
|
146 |
+
|
147 |
+
model Notification {
|
148 |
+
id String @id @default(cuid())
|
149 |
+
userId String
|
150 |
+
type NotificationType
|
151 |
+
title String
|
152 |
+
content String
|
153 |
+
data Json?
|
154 |
+
isRead Boolean @default(false)
|
155 |
+
createdAt DateTime @default(now())
|
156 |
+
|
157 |
+
// Relations
|
158 |
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
159 |
+
|
160 |
+
@@map("notifications")
|
161 |
+
}
|
162 |
+
|
163 |
+
model UserSession {
|
164 |
+
id String @id @default(cuid())
|
165 |
+
userId String
|
166 |
+
token String @unique
|
167 |
+
userAgent String?
|
168 |
+
ipAddress String?
|
169 |
+
expiresAt DateTime
|
170 |
+
createdAt DateTime @default(now())
|
171 |
+
|
172 |
+
// Relations
|
173 |
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
174 |
+
|
175 |
+
@@map("user_sessions")
|
176 |
+
}
|
177 |
+
|
178 |
+
// Enums
|
179 |
+
enum ChatType {
|
180 |
+
DIRECT
|
181 |
+
GROUP
|
182 |
+
}
|
183 |
+
|
184 |
+
enum ParticipantRole {
|
185 |
+
MEMBER
|
186 |
+
ADMIN
|
187 |
+
OWNER
|
188 |
+
}
|
189 |
+
|
190 |
+
enum MessageType {
|
191 |
+
TEXT
|
192 |
+
IMAGE
|
193 |
+
FILE
|
194 |
+
AUDIO
|
195 |
+
VIDEO
|
196 |
+
SYSTEM
|
197 |
+
}
|
198 |
+
|
199 |
+
enum GroupType {
|
200 |
+
PUBLIC
|
201 |
+
PRIVATE
|
202 |
+
}
|
203 |
+
|
204 |
+
enum NotificationType {
|
205 |
+
MESSAGE
|
206 |
+
MENTION
|
207 |
+
GROUP_INVITE
|
208 |
+
SYSTEM
|
209 |
+
}
|
server/src/config/database.ts
ADDED
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { PrismaClient } from '@prisma/client'
|
2 |
+
|
3 |
+
declare global {
|
4 |
+
var __prisma: PrismaClient | undefined
|
5 |
+
}
|
6 |
+
|
7 |
+
// Prevent multiple instances of Prisma Client in development
|
8 |
+
const prisma = globalThis.__prisma || new PrismaClient({
|
9 |
+
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
10 |
+
})
|
11 |
+
|
12 |
+
if (process.env.NODE_ENV === 'development') {
|
13 |
+
globalThis.__prisma = prisma
|
14 |
+
}
|
15 |
+
|
16 |
+
export { prisma }
|
17 |
+
|
18 |
+
export async function initializeDatabase() {
|
19 |
+
try {
|
20 |
+
// Test the connection
|
21 |
+
await prisma.$connect()
|
22 |
+
console.log('✅ Database connected successfully')
|
23 |
+
|
24 |
+
// Run any initialization logic here
|
25 |
+
await createDefaultAdmin()
|
26 |
+
|
27 |
+
} catch (error) {
|
28 |
+
console.error('❌ Database connection failed:', error)
|
29 |
+
throw error
|
30 |
+
}
|
31 |
+
}
|
32 |
+
|
33 |
+
async function createDefaultAdmin() {
|
34 |
+
try {
|
35 |
+
const adminEmail = process.env.ADMIN_EMAIL || '[email protected]'
|
36 |
+
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123456'
|
37 |
+
|
38 |
+
// Check if admin user already exists
|
39 |
+
const existingAdmin = await prisma.user.findUnique({
|
40 |
+
where: { email: adminEmail }
|
41 |
+
})
|
42 |
+
|
43 |
+
if (!existingAdmin) {
|
44 |
+
const bcrypt = await import('bcryptjs')
|
45 |
+
const hashedPassword = await bcrypt.hash(adminPassword, 12)
|
46 |
+
|
47 |
+
await prisma.user.create({
|
48 |
+
data: {
|
49 |
+
email: adminEmail,
|
50 |
+
username: 'admin',
|
51 |
+
displayName: 'Administrator',
|
52 |
+
password: hashedPassword,
|
53 |
+
isAdmin: true,
|
54 |
+
isVerified: true,
|
55 |
+
}
|
56 |
+
})
|
57 |
+
|
58 |
+
console.log('✅ Default admin user created')
|
59 |
+
console.log(`📧 Admin email: ${adminEmail}`)
|
60 |
+
console.log(`🔑 Admin password: ${adminPassword}`)
|
61 |
+
}
|
62 |
+
} catch (error) {
|
63 |
+
console.error('❌ Failed to create default admin:', error)
|
64 |
+
}
|
65 |
+
}
|
66 |
+
|
67 |
+
export async function disconnectDatabase() {
|
68 |
+
await prisma.$disconnect()
|
69 |
+
}
|
server/src/index.ts
ADDED
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import express from 'express'
|
2 |
+
import { createServer } from 'http'
|
3 |
+
import { Server } from 'socket.io'
|
4 |
+
import cors from 'cors'
|
5 |
+
import helmet from 'helmet'
|
6 |
+
import compression from 'compression'
|
7 |
+
import morgan from 'morgan'
|
8 |
+
import dotenv from 'dotenv'
|
9 |
+
import path from 'path'
|
10 |
+
|
11 |
+
// Load environment variables
|
12 |
+
dotenv.config()
|
13 |
+
|
14 |
+
// Import routes and middleware
|
15 |
+
import authRoutes from './routes/auth'
|
16 |
+
import userRoutes from './routes/users'
|
17 |
+
import chatRoutes from './routes/chats'
|
18 |
+
import messageRoutes from './routes/messages'
|
19 |
+
import uploadRoutes from './routes/upload'
|
20 |
+
import adminRoutes from './routes/admin'
|
21 |
+
|
22 |
+
// Import middleware
|
23 |
+
import { errorHandler } from './middleware/errorHandler'
|
24 |
+
import { rateLimiter } from './middleware/rateLimiter'
|
25 |
+
import { authMiddleware } from './middleware/auth'
|
26 |
+
|
27 |
+
// Import socket handlers
|
28 |
+
import { setupSocketHandlers } from './socket'
|
29 |
+
|
30 |
+
// Import database
|
31 |
+
import { initializeDatabase } from './config/database'
|
32 |
+
|
33 |
+
const app = express()
|
34 |
+
const server = createServer(app)
|
35 |
+
|
36 |
+
// Initialize Socket.IO
|
37 |
+
const io = new Server(server, {
|
38 |
+
cors: {
|
39 |
+
origin: process.env.CORS_ORIGIN || "http://localhost:5173",
|
40 |
+
methods: ["GET", "POST"],
|
41 |
+
credentials: true
|
42 |
+
},
|
43 |
+
transports: ['websocket', 'polling']
|
44 |
+
})
|
45 |
+
|
46 |
+
// Middleware
|
47 |
+
app.use(helmet({
|
48 |
+
crossOriginEmbedderPolicy: false,
|
49 |
+
contentSecurityPolicy: {
|
50 |
+
directives: {
|
51 |
+
defaultSrc: ["'self'"],
|
52 |
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
53 |
+
scriptSrc: ["'self'"],
|
54 |
+
imgSrc: ["'self'", "data:", "https:"],
|
55 |
+
},
|
56 |
+
},
|
57 |
+
}))
|
58 |
+
|
59 |
+
app.use(cors({
|
60 |
+
origin: process.env.CORS_ORIGIN || "http://localhost:5173",
|
61 |
+
credentials: true
|
62 |
+
}))
|
63 |
+
|
64 |
+
app.use(compression())
|
65 |
+
app.use(morgan('combined'))
|
66 |
+
app.use(express.json({ limit: '10mb' }))
|
67 |
+
app.use(express.urlencoded({ extended: true, limit: '10mb' }))
|
68 |
+
|
69 |
+
// Rate limiting
|
70 |
+
app.use(rateLimiter)
|
71 |
+
|
72 |
+
// Static files
|
73 |
+
app.use('/uploads', express.static(path.join(__dirname, '../uploads')))
|
74 |
+
|
75 |
+
// Health check
|
76 |
+
app.get('/health', (req, res) => {
|
77 |
+
res.json({
|
78 |
+
status: 'OK',
|
79 |
+
timestamp: new Date().toISOString(),
|
80 |
+
uptime: process.uptime()
|
81 |
+
})
|
82 |
+
})
|
83 |
+
|
84 |
+
// API Routes
|
85 |
+
app.use('/api/auth', authRoutes)
|
86 |
+
app.use('/api/users', authMiddleware, userRoutes)
|
87 |
+
app.use('/api/chats', authMiddleware, chatRoutes)
|
88 |
+
app.use('/api/messages', authMiddleware, messageRoutes)
|
89 |
+
app.use('/api/upload', authMiddleware, uploadRoutes)
|
90 |
+
app.use('/api/admin', authMiddleware, adminRoutes)
|
91 |
+
|
92 |
+
// 404 handler
|
93 |
+
app.use('*', (req, res) => {
|
94 |
+
res.status(404).json({
|
95 |
+
success: false,
|
96 |
+
error: 'Route not found'
|
97 |
+
})
|
98 |
+
})
|
99 |
+
|
100 |
+
// Error handling middleware
|
101 |
+
app.use(errorHandler)
|
102 |
+
|
103 |
+
// Setup Socket.IO handlers
|
104 |
+
setupSocketHandlers(io)
|
105 |
+
|
106 |
+
// Initialize database and start server
|
107 |
+
async function startServer() {
|
108 |
+
try {
|
109 |
+
// Initialize database connection
|
110 |
+
await initializeDatabase()
|
111 |
+
console.log('Database connected successfully')
|
112 |
+
|
113 |
+
const PORT = process.env.PORT || 3001
|
114 |
+
const HOST = process.env.HOST || 'localhost'
|
115 |
+
|
116 |
+
server.listen(PORT, () => {
|
117 |
+
console.log(`🚀 Server running on http://${HOST}:${PORT}`)
|
118 |
+
console.log(`📡 Socket.IO server ready`)
|
119 |
+
console.log(`🌍 Environment: ${process.env.NODE_ENV || 'development'}`)
|
120 |
+
})
|
121 |
+
} catch (error) {
|
122 |
+
console.error('Failed to start server:', error)
|
123 |
+
process.exit(1)
|
124 |
+
}
|
125 |
+
}
|
126 |
+
|
127 |
+
// Graceful shutdown
|
128 |
+
process.on('SIGTERM', () => {
|
129 |
+
console.log('SIGTERM received, shutting down gracefully')
|
130 |
+
server.close(() => {
|
131 |
+
console.log('Server closed')
|
132 |
+
process.exit(0)
|
133 |
+
})
|
134 |
+
})
|
135 |
+
|
136 |
+
process.on('SIGINT', () => {
|
137 |
+
console.log('SIGINT received, shutting down gracefully')
|
138 |
+
server.close(() => {
|
139 |
+
console.log('Server closed')
|
140 |
+
process.exit(0)
|
141 |
+
})
|
142 |
+
})
|
143 |
+
|
144 |
+
// Handle uncaught exceptions
|
145 |
+
process.on('uncaughtException', (error) => {
|
146 |
+
console.error('Uncaught Exception:', error)
|
147 |
+
process.exit(1)
|
148 |
+
})
|
149 |
+
|
150 |
+
process.on('unhandledRejection', (reason, promise) => {
|
151 |
+
console.error('Unhandled Rejection at:', promise, 'reason:', reason)
|
152 |
+
process.exit(1)
|
153 |
+
})
|
154 |
+
|
155 |
+
startServer()
|
156 |
+
|
157 |
+
export { app, server, io }
|
server/src/middleware/auth.ts
ADDED
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Request, Response, NextFunction } from 'express'
|
2 |
+
import jwt from 'jsonwebtoken'
|
3 |
+
import { prisma } from '../config/database'
|
4 |
+
|
5 |
+
export interface AuthRequest extends Request {
|
6 |
+
// Use a loose type here to avoid dependency on generated Prisma types
|
7 |
+
user?: any
|
8 |
+
}
|
9 |
+
|
10 |
+
export const authMiddleware = async (
|
11 |
+
req: AuthRequest,
|
12 |
+
res: Response,
|
13 |
+
next: NextFunction
|
14 |
+
) => {
|
15 |
+
try {
|
16 |
+
const authHeader = req.headers.authorization
|
17 |
+
|
18 |
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
19 |
+
return res.status(401).json({
|
20 |
+
success: false,
|
21 |
+
error: 'Access token required'
|
22 |
+
})
|
23 |
+
}
|
24 |
+
|
25 |
+
const token = authHeader.substring(7) // Remove 'Bearer ' prefix
|
26 |
+
|
27 |
+
if (!token) {
|
28 |
+
return res.status(401).json({
|
29 |
+
success: false,
|
30 |
+
error: 'Access token required'
|
31 |
+
})
|
32 |
+
}
|
33 |
+
|
34 |
+
// Verify JWT token
|
35 |
+
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string }
|
36 |
+
|
37 |
+
// Get user from database
|
38 |
+
const user = await prisma.user.findUnique({
|
39 |
+
where: { id: decoded.userId },
|
40 |
+
select: {
|
41 |
+
id: true,
|
42 |
+
email: true,
|
43 |
+
username: true,
|
44 |
+
displayName: true,
|
45 |
+
avatar: true,
|
46 |
+
bio: true,
|
47 |
+
isOnline: true,
|
48 |
+
lastSeen: true,
|
49 |
+
isAdmin: true,
|
50 |
+
isVerified: true,
|
51 |
+
createdAt: true,
|
52 |
+
updatedAt: true,
|
53 |
+
}
|
54 |
+
})
|
55 |
+
|
56 |
+
if (!user) {
|
57 |
+
return res.status(401).json({
|
58 |
+
success: false,
|
59 |
+
error: 'Invalid token - user not found'
|
60 |
+
})
|
61 |
+
}
|
62 |
+
|
63 |
+
// Attach user to request
|
64 |
+
req.user = user
|
65 |
+
next()
|
66 |
+
} catch (error) {
|
67 |
+
if (error instanceof jwt.JsonWebTokenError) {
|
68 |
+
return res.status(401).json({
|
69 |
+
success: false,
|
70 |
+
error: 'Invalid token'
|
71 |
+
})
|
72 |
+
}
|
73 |
+
|
74 |
+
if (error instanceof jwt.TokenExpiredError) {
|
75 |
+
return res.status(401).json({
|
76 |
+
success: false,
|
77 |
+
error: 'Token expired'
|
78 |
+
})
|
79 |
+
}
|
80 |
+
|
81 |
+
console.error('Auth middleware error:', error)
|
82 |
+
return res.status(500).json({
|
83 |
+
success: false,
|
84 |
+
error: 'Authentication failed'
|
85 |
+
})
|
86 |
+
}
|
87 |
+
}
|
88 |
+
|
89 |
+
export const adminMiddleware = (
|
90 |
+
req: AuthRequest,
|
91 |
+
res: Response,
|
92 |
+
next: NextFunction
|
93 |
+
) => {
|
94 |
+
if (!req.user) {
|
95 |
+
return res.status(401).json({
|
96 |
+
success: false,
|
97 |
+
error: 'Authentication required'
|
98 |
+
})
|
99 |
+
}
|
100 |
+
|
101 |
+
if (!req.user.isAdmin) {
|
102 |
+
return res.status(403).json({
|
103 |
+
success: false,
|
104 |
+
error: 'Admin access required'
|
105 |
+
})
|
106 |
+
}
|
107 |
+
|
108 |
+
next()
|
109 |
+
}
|
110 |
+
|
111 |
+
export const optionalAuthMiddleware = async (
|
112 |
+
req: AuthRequest,
|
113 |
+
res: Response,
|
114 |
+
next: NextFunction
|
115 |
+
) => {
|
116 |
+
try {
|
117 |
+
const authHeader = req.headers.authorization
|
118 |
+
|
119 |
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
120 |
+
return next()
|
121 |
+
}
|
122 |
+
|
123 |
+
const token = authHeader.substring(7)
|
124 |
+
|
125 |
+
if (!token) {
|
126 |
+
return next()
|
127 |
+
}
|
128 |
+
|
129 |
+
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string }
|
130 |
+
|
131 |
+
const user = await prisma.user.findUnique({
|
132 |
+
where: { id: decoded.userId },
|
133 |
+
select: {
|
134 |
+
id: true,
|
135 |
+
email: true,
|
136 |
+
username: true,
|
137 |
+
displayName: true,
|
138 |
+
avatar: true,
|
139 |
+
bio: true,
|
140 |
+
isOnline: true,
|
141 |
+
lastSeen: true,
|
142 |
+
isAdmin: true,
|
143 |
+
isVerified: true,
|
144 |
+
createdAt: true,
|
145 |
+
updatedAt: true,
|
146 |
+
}
|
147 |
+
})
|
148 |
+
|
149 |
+
if (user) {
|
150 |
+
req.user = user
|
151 |
+
}
|
152 |
+
|
153 |
+
next()
|
154 |
+
} catch (error) {
|
155 |
+
// Ignore auth errors for optional auth
|
156 |
+
next()
|
157 |
+
}
|
158 |
+
}
|
server/src/middleware/errorHandler.ts
ADDED
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Request, Response, NextFunction } from 'express'
|
2 |
+
import { Prisma } from '@prisma/client'
|
3 |
+
|
4 |
+
export interface AppError extends Error {
|
5 |
+
statusCode?: number
|
6 |
+
code?: string
|
7 |
+
}
|
8 |
+
|
9 |
+
export const errorHandler = (
|
10 |
+
error: AppError,
|
11 |
+
req: Request,
|
12 |
+
res: Response,
|
13 |
+
next: NextFunction
|
14 |
+
) => {
|
15 |
+
console.error('Error:', error)
|
16 |
+
|
17 |
+
// Default error
|
18 |
+
let statusCode = error.statusCode || 500
|
19 |
+
let message = error.message || 'Internal server error'
|
20 |
+
let code = error.code || 'INTERNAL_ERROR'
|
21 |
+
|
22 |
+
// Prisma errors
|
23 |
+
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
24 |
+
switch (error.code) {
|
25 |
+
case 'P2002':
|
26 |
+
statusCode = 409
|
27 |
+
message = 'Resource already exists'
|
28 |
+
code = 'DUPLICATE_ERROR'
|
29 |
+
break
|
30 |
+
case 'P2025':
|
31 |
+
statusCode = 404
|
32 |
+
message = 'Resource not found'
|
33 |
+
code = 'NOT_FOUND'
|
34 |
+
break
|
35 |
+
case 'P2003':
|
36 |
+
statusCode = 400
|
37 |
+
message = 'Invalid reference'
|
38 |
+
code = 'INVALID_REFERENCE'
|
39 |
+
break
|
40 |
+
default:
|
41 |
+
statusCode = 400
|
42 |
+
message = 'Database error'
|
43 |
+
code = 'DATABASE_ERROR'
|
44 |
+
}
|
45 |
+
}
|
46 |
+
|
47 |
+
// Validation errors
|
48 |
+
if (error.name === 'ValidationError') {
|
49 |
+
statusCode = 400
|
50 |
+
code = 'VALIDATION_ERROR'
|
51 |
+
}
|
52 |
+
|
53 |
+
// JWT errors
|
54 |
+
if (error.name === 'JsonWebTokenError') {
|
55 |
+
statusCode = 401
|
56 |
+
message = 'Invalid token'
|
57 |
+
code = 'INVALID_TOKEN'
|
58 |
+
}
|
59 |
+
|
60 |
+
if (error.name === 'TokenExpiredError') {
|
61 |
+
statusCode = 401
|
62 |
+
message = 'Token expired'
|
63 |
+
code = 'TOKEN_EXPIRED'
|
64 |
+
}
|
65 |
+
|
66 |
+
// Multer errors (file upload)
|
67 |
+
if (error.code === 'LIMIT_FILE_SIZE') {
|
68 |
+
statusCode = 413
|
69 |
+
message = 'File too large'
|
70 |
+
code = 'FILE_TOO_LARGE'
|
71 |
+
}
|
72 |
+
|
73 |
+
if (error.code === 'LIMIT_FILE_COUNT') {
|
74 |
+
statusCode = 413
|
75 |
+
message = 'Too many files'
|
76 |
+
code = 'TOO_MANY_FILES'
|
77 |
+
}
|
78 |
+
|
79 |
+
// Don't expose internal errors in production
|
80 |
+
if (process.env.NODE_ENV === 'production' && statusCode === 500) {
|
81 |
+
message = 'Internal server error'
|
82 |
+
}
|
83 |
+
|
84 |
+
res.status(statusCode).json({
|
85 |
+
success: false,
|
86 |
+
error: message,
|
87 |
+
code,
|
88 |
+
...(process.env.NODE_ENV === 'development' && { stack: error.stack })
|
89 |
+
})
|
90 |
+
}
|
91 |
+
|
92 |
+
export const notFoundHandler = (req: Request, res: Response) => {
|
93 |
+
res.status(404).json({
|
94 |
+
success: false,
|
95 |
+
error: 'Route not found',
|
96 |
+
code: 'NOT_FOUND'
|
97 |
+
})
|
98 |
+
}
|
99 |
+
|
100 |
+
export const asyncHandler = (fn: Function) => {
|
101 |
+
return (req: Request, res: Response, next: NextFunction) => {
|
102 |
+
Promise.resolve(fn(req, res, next)).catch(next)
|
103 |
+
}
|
104 |
+
}
|