Frontend: Complete Next.js implementation
Core Features: - ✅ Authentication (login/register pages) - ✅ Main layout with sidebar - ✅ Channel list and navigation - ✅ Channel page with real-time messaging - ✅ Direct messages (DMs) page - ✅ Message components (list, input) - ✅ WebSocket integration for real-time updates - ✅ Typing indicators - ✅ Online status indicators - ✅ Stores (auth, channels, messages) - ✅ API client with JWT auth - ✅ Socket.IO client wrapper - ✅ Responsive UI with Tailwind Tech Stack: - Next.js 14 (App Router) - TypeScript - TailwindCSS - Socket.IO client - Zustand (state management) - Axios (HTTP client) - date-fns (date formatting) - lucide-react (icons) Build Status: ✅ Successfully compiles Production Ready: ✅ Optimized build generated
This commit is contained in:
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
9
frontend/next.config.mjs
Normal file
9
frontend/next.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
images: {
|
||||||
|
domains: ['localhost'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6357
frontend/package-lock.json
generated
Normal file
6357
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "grimlock-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "14.2.18",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"@tanstack/react-query": "^5.62.8",
|
||||||
|
"zustand": "^5.0.2",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"date-fns": "^4.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"postcss": "^8",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.2.18",
|
||||||
|
"autoprefixer": "^10.4.20"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
116
frontend/src/app/(auth)/login/page.tsx
Normal file
116
frontend/src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { login, isLoading, error, clearError } = useAuthStore();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(formData);
|
||||||
|
router.push('/channels');
|
||||||
|
} catch (err) {
|
||||||
|
// Error is handled by the store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-center text-4xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Grimlock
|
||||||
|
</h1>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||||
|
Sign in to your account
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Or{' '}
|
||||||
|
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500">
|
||||||
|
create a new account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||||
|
<div className="text-sm text-red-800 dark:text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-md shadow-sm -space-y-px">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="sr-only">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Email address"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="sr-only">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:bg-blue-400 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="text-center text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<p>Demo credentials:</p>
|
||||||
|
<p>Email: demo@grimlock.com | Password: demo123</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
frontend/src/app/(auth)/register/page.tsx
Normal file
176
frontend/src/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { register, isLoading, error, clearError } = useAuthStore();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: '',
|
||||||
|
name: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
role: 'engineer' as 'engineer' | 'BD' | 'admin' | 'exec',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
alert('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register({
|
||||||
|
email: formData.email,
|
||||||
|
name: formData.name,
|
||||||
|
password: formData.password,
|
||||||
|
role: formData.role,
|
||||||
|
});
|
||||||
|
router.push('/channels');
|
||||||
|
} catch (err) {
|
||||||
|
// Error is handled by the store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-center text-4xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Grimlock
|
||||||
|
</h1>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||||
|
Create your account
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Or{' '}
|
||||||
|
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500">
|
||||||
|
sign in to existing account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||||
|
<div className="text-sm text-red-800 dark:text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Full Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
placeholder="John Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="role" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Role
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="role"
|
||||||
|
name="role"
|
||||||
|
value={formData.role}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
>
|
||||||
|
<option value="engineer">Engineer</option>
|
||||||
|
<option value="BD">Business Development</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="exec">Executive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:bg-blue-400 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creating account...' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
frontend/src/app/(main)/channels/[id]/page.tsx
Normal file
92
frontend/src/app/(main)/channels/[id]/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Hash } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { useChannelStore } from '@/stores/useChannelStore';
|
||||||
|
import { useMessageStore } from '@/stores/useMessageStore';
|
||||||
|
import { wsClient } from '@/lib/socket';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
import MessageList from '@/components/chat/MessageList';
|
||||||
|
import MessageInput from '@/components/chat/MessageInput';
|
||||||
|
|
||||||
|
export default function ChannelPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const channelId = parseInt(params.id as string);
|
||||||
|
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const { channels } = useChannelStore();
|
||||||
|
const { messagesByChannel, loadMessages, typingUsers } = useMessageStore();
|
||||||
|
|
||||||
|
const channel = channels.find(c => c.id === channelId);
|
||||||
|
const channelMessages = messagesByChannel[channelId] || [];
|
||||||
|
const channelTypingUsers = typingUsers[channelId] || new Set();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (channelId) {
|
||||||
|
// Load messages for this channel
|
||||||
|
loadMessages(channelId);
|
||||||
|
|
||||||
|
// Join WebSocket room
|
||||||
|
wsClient.joinChannel(channelId);
|
||||||
|
|
||||||
|
// Cleanup: leave channel when component unmounts
|
||||||
|
return () => {
|
||||||
|
wsClient.leaveChannel(channelId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [channelId, loadMessages]);
|
||||||
|
|
||||||
|
const handleSendMessage = async (content: string) => {
|
||||||
|
try {
|
||||||
|
await apiClient.sendMessage(channelId, content);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send message:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center text-gray-400">
|
||||||
|
<p className="text-lg">Channel not found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Channel header */}
|
||||||
|
<div className="h-16 flex items-center justify-between px-4 border-b border-gray-700 bg-gray-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Hash className="w-5 h-5 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{channel.name}</h2>
|
||||||
|
{channel.description && (
|
||||||
|
<p className="text-sm text-gray-400">{channel.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||||
|
{channel.member_count && (
|
||||||
|
<span>{channel.member_count} member{channel.member_count !== 1 ? 's' : ''}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<MessageList messages={channelMessages} currentUserId={user?.id} />
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<MessageInput
|
||||||
|
onSend={handleSendMessage}
|
||||||
|
channelId={channelId}
|
||||||
|
placeholder={`Message #${channel.name}`}
|
||||||
|
typingUsers={channelTypingUsers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
frontend/src/app/(main)/dms/[userId]/page.tsx
Normal file
132
frontend/src/app/(main)/dms/[userId]/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { MessageSquare } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { useMessageStore } from '@/stores/useMessageStore';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
import MessageList from '@/components/chat/MessageList';
|
||||||
|
import MessageInput from '@/components/chat/MessageInput';
|
||||||
|
import type { User } from '@/lib/types';
|
||||||
|
|
||||||
|
export default function DMPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const recipientId = parseInt(params.userId as string);
|
||||||
|
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const { dmsByUser, loadDirectMessages } = useMessageStore();
|
||||||
|
const [recipient, setRecipient] = useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const messages = dmsByUser[recipientId] || [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
if (recipientId) {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Load DM history
|
||||||
|
await loadDirectMessages(recipientId);
|
||||||
|
|
||||||
|
// Get recipient info from conversations or fetch it
|
||||||
|
// For now, we'll get it from the first message if available
|
||||||
|
// In a real app, you might want to fetch user info separately
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load DM data:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [recipientId, loadDirectMessages]);
|
||||||
|
|
||||||
|
// Get recipient name from messages if available
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > 0) {
|
||||||
|
const firstMessage = messages[0];
|
||||||
|
if (firstMessage.sender_id === recipientId && firstMessage.sender) {
|
||||||
|
setRecipient(firstMessage.sender);
|
||||||
|
} else if (firstMessage.recipient_id === recipientId && firstMessage.recipient) {
|
||||||
|
setRecipient(firstMessage.recipient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [messages, recipientId]);
|
||||||
|
|
||||||
|
const handleSendMessage = async (content: string) => {
|
||||||
|
try {
|
||||||
|
await apiClient.sendDirectMessage(recipientId, content);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send DM:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert DMs to message format for MessageList component
|
||||||
|
const formattedMessages = messages.map(dm => ({
|
||||||
|
id: dm.id,
|
||||||
|
content: dm.content,
|
||||||
|
user_id: dm.sender_id,
|
||||||
|
channel_id: 0, // Not a channel message
|
||||||
|
user: dm.sender,
|
||||||
|
created_at: dm.created_at,
|
||||||
|
updated_at: dm.created_at, // DMs don't have updated_at, use created_at
|
||||||
|
parent_id: null, // DMs don't have threads
|
||||||
|
is_ai_response: dm.is_ai_response || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-gray-400">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* DM header */}
|
||||||
|
<div className="h-16 flex items-center justify-between px-4 border-b border-gray-700 bg-gray-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MessageSquare className="w-5 h-5 text-gray-400" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{recipient ? (
|
||||||
|
<>
|
||||||
|
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-sm font-semibold">
|
||||||
|
{recipient.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{recipient.name}</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${recipient.is_online ? 'bg-green-500' : 'bg-gray-500'}`} />
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{recipient.is_online ? 'Online' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
{recipient.role && (
|
||||||
|
<span className="text-xs text-gray-500 bg-gray-700 px-1.5 py-0.5 rounded">
|
||||||
|
{recipient.role}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<h2 className="text-lg font-semibold">Direct Message</h2>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<MessageList messages={formattedMessages} currentUserId={user?.id} />
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<MessageInput
|
||||||
|
onSend={handleSendMessage}
|
||||||
|
placeholder={`Message ${recipient?.name || 'user'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
frontend/src/app/(main)/layout.tsx
Normal file
82
frontend/src/app/(main)/layout.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { useChannelStore } from '@/stores/useChannelStore';
|
||||||
|
import { useMessageStore } from '@/stores/useMessageStore';
|
||||||
|
import { wsClient } from '@/lib/socket';
|
||||||
|
import Sidebar from '@/components/sidebar/Sidebar';
|
||||||
|
|
||||||
|
export default function MainLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAuthenticated, isLoading, loadUser, user } = useAuthStore();
|
||||||
|
const { loadChannels } = useChannelStore();
|
||||||
|
const { addMessage, addDirectMessage, setUserTyping, setUserStoppedTyping, loadConversations } = useMessageStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUser();
|
||||||
|
}, [loadUser]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !isAuthenticated) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isLoading, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
loadChannels();
|
||||||
|
loadConversations();
|
||||||
|
|
||||||
|
// Setup WebSocket event handlers
|
||||||
|
wsClient.updateHandlers({
|
||||||
|
onNewMessage: (message) => {
|
||||||
|
addMessage(message.channel_id, message);
|
||||||
|
},
|
||||||
|
onNewDM: (dm) => {
|
||||||
|
addDirectMessage(dm);
|
||||||
|
// Reload conversations to update unread count
|
||||||
|
loadConversations();
|
||||||
|
},
|
||||||
|
onUserTyping: (data) => {
|
||||||
|
if (data.user_id !== user?.id) {
|
||||||
|
setUserTyping(data.channel_id, data.user_id);
|
||||||
|
// Auto-clear typing after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setUserStoppedTyping(data.channel_id, data.user_id);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUserStoppedTyping: (data) => {
|
||||||
|
setUserStoppedTyping(data.channel_id, data.user_id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, loadChannels, loadConversations, addMessage, addDirectMessage, setUserTyping, setUserStoppedTyping, user]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
||||||
|
<div className="text-white text-xl">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-gray-900 text-white overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/app/globals.css
Normal file
46
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--background);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
19
frontend/src/app/layout.tsx
Normal file
19
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Grimlock - AI-Native Company OS',
|
||||||
|
description: 'Team communication platform with integrated AI assistance',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className="antialiased">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/app/page.tsx
Normal file
35
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { useChannelStore } from '@/stores/useChannelStore';
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAuthenticated, isLoading } = useAuthStore();
|
||||||
|
const { channels } = useChannelStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading) {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
router.push('/login');
|
||||||
|
} else if (channels.length > 0) {
|
||||||
|
// Redirect to first channel
|
||||||
|
router.push(`/channels/${channels[0].id}`);
|
||||||
|
} else {
|
||||||
|
// Show welcome screen if no channels
|
||||||
|
router.push('/channels/new');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isLoading, channels, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-900 text-white">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-6xl font-bold mb-4">Grimlock</div>
|
||||||
|
<div className="text-xl text-gray-400">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
frontend/src/components/chat/MessageInput.tsx
Normal file
139
frontend/src/components/chat/MessageInput.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Send, Paperclip } from 'lucide-react';
|
||||||
|
import { wsClient } from '@/lib/socket';
|
||||||
|
|
||||||
|
interface MessageInputProps {
|
||||||
|
onSend: (content: string) => Promise<void>;
|
||||||
|
channelId?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
typingUsers?: Set<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageInput({ onSend, channelId, placeholder = 'Type a message...', typingUsers }: MessageInputProps) {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!content.trim() || isSending) return;
|
||||||
|
|
||||||
|
setIsSending(true);
|
||||||
|
try {
|
||||||
|
await onSend(content.trim());
|
||||||
|
setContent('');
|
||||||
|
|
||||||
|
// Stop typing indicator when message is sent
|
||||||
|
if (channelId) {
|
||||||
|
wsClient.stopTyping(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset textarea height
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = 'auto';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send message:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setContent(e.target.value);
|
||||||
|
|
||||||
|
// Auto-resize textarea
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = 'auto';
|
||||||
|
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing indicator
|
||||||
|
if (channelId) {
|
||||||
|
wsClient.startTyping(channelId);
|
||||||
|
|
||||||
|
// Clear existing timeout
|
||||||
|
if (typingTimeoutRef.current) {
|
||||||
|
clearTimeout(typingTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop typing after 2 seconds of inactivity
|
||||||
|
typingTimeoutRef.current = setTimeout(() => {
|
||||||
|
wsClient.stopTyping(channelId);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (typingTimeoutRef.current) {
|
||||||
|
clearTimeout(typingTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const typingCount = typingUsers?.size || 0;
|
||||||
|
const typingText = typingCount === 1
|
||||||
|
? 'Someone is typing...'
|
||||||
|
: typingCount > 1
|
||||||
|
? `${typingCount} people are typing...`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-gray-700 bg-gray-800">
|
||||||
|
{typingText && (
|
||||||
|
<div className="px-4 py-1 text-xs text-gray-400 italic">
|
||||||
|
{typingText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-4">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-2 hover:bg-gray-700 rounded text-gray-400 hover:text-white transition-colors flex-shrink-0"
|
||||||
|
title="Attach file"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 bg-gray-700 rounded-lg">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={content}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={1}
|
||||||
|
className="w-full px-4 py-3 bg-transparent resize-none focus:outline-none text-sm max-h-32 overflow-y-auto"
|
||||||
|
disabled={isSending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!content.trim() || isSending}
|
||||||
|
className="p-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded text-white transition-colors flex-shrink-0"
|
||||||
|
title="Send message"
|
||||||
|
>
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-xs text-gray-400">
|
||||||
|
<span className="font-semibold">@grimlock</span> to get AI assistance • <span className="font-semibold">Shift+Enter</span> for new line
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
frontend/src/components/chat/MessageList.tsx
Normal file
91
frontend/src/components/chat/MessageList.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import type { Message } from '@/lib/types';
|
||||||
|
import { Bot } from 'lucide-react';
|
||||||
|
|
||||||
|
interface MessageListProps {
|
||||||
|
messages: Message[];
|
||||||
|
currentUserId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageList({ messages, currentUserId }: MessageListProps) {
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const formatTime = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
if (diffInHours < 24) {
|
||||||
|
return format(date, 'h:mm a');
|
||||||
|
} else {
|
||||||
|
return format(date, 'MMM d, h:mm a');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-gray-400">
|
||||||
|
<p>No messages yet. Start the conversation!</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||||
|
{messages.map((message, index) => {
|
||||||
|
const isCurrentUser = message.user_id === currentUserId;
|
||||||
|
const showAvatar = index === 0 || messages[index - 1].user_id !== message.user_id;
|
||||||
|
const isAI = message.is_ai_response;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={message.id} className="flex gap-3">
|
||||||
|
{showAvatar ? (
|
||||||
|
<div className="w-8 h-8 flex-shrink-0">
|
||||||
|
{isAI ? (
|
||||||
|
<div className="w-8 h-8 bg-purple-600 rounded flex items-center justify-center">
|
||||||
|
<Bot className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center text-sm font-semibold">
|
||||||
|
{message.user?.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{showAvatar && (
|
||||||
|
<div className="flex items-baseline gap-2 mb-1">
|
||||||
|
<span className={`font-semibold text-sm ${isAI ? 'text-purple-400' : ''}`}>
|
||||||
|
{isAI ? 'Grimlock AI' : message.user?.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{formatTime(message.created_at)}
|
||||||
|
</span>
|
||||||
|
{!isAI && message.user?.role && (
|
||||||
|
<span className="text-xs text-gray-500 bg-gray-700 px-1.5 py-0.5 rounded">
|
||||||
|
{message.user.role}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-sm break-words whitespace-pre-wrap">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
frontend/src/components/sidebar/Sidebar.tsx
Normal file
166
frontend/src/components/sidebar/Sidebar.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Hash, Plus, MessageSquare, Settings, LogOut, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { useChannelStore } from '@/stores/useChannelStore';
|
||||||
|
import { useMessageStore } from '@/stores/useMessageStore';
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const { channels, createChannel } = useChannelStore();
|
||||||
|
const { conversations } = useMessageStore();
|
||||||
|
|
||||||
|
const [showChannels, setShowChannels] = useState(true);
|
||||||
|
const [showDMs, setShowDMs] = useState(true);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
const handleCreateChannel = async () => {
|
||||||
|
const name = prompt('Channel name:');
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const description = prompt('Description (optional):');
|
||||||
|
const isPrivate = confirm('Make this channel private?');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = await createChannel({ name, description: description || undefined, is_private: isPrivate });
|
||||||
|
router.push(`/channels/${channel.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to create channel');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
router.push('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-64 bg-gray-800 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="h-16 flex items-center justify-between px-4 border-b border-gray-700">
|
||||||
|
<h1 className="text-xl font-bold">Grimlock</h1>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="p-2 hover:bg-gray-700 rounded"
|
||||||
|
title="Logout"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{/* Channels Section */}
|
||||||
|
<div className="p-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowChannels(!showChannels)}
|
||||||
|
className="w-full flex items-center justify-between px-2 py-1 hover:bg-gray-700 rounded text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{showChannels ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
<span className="font-semibold">Channels</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCreateChannel();
|
||||||
|
}}
|
||||||
|
className="p-1 hover:bg-gray-600 rounded"
|
||||||
|
title="Create channel"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showChannels && (
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<button
|
||||||
|
key={channel.id}
|
||||||
|
onClick={() => router.push(`/channels/${channel.id}`)}
|
||||||
|
className="w-full flex items-center gap-2 px-4 py-1.5 hover:bg-gray-700 rounded text-sm group"
|
||||||
|
>
|
||||||
|
<Hash className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="truncate flex-1 text-left">{channel.name}</span>
|
||||||
|
{channel.member_count && (
|
||||||
|
<span className="text-xs text-gray-400">{channel.member_count}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{channels.length === 0 && (
|
||||||
|
<div className="px-4 py-2 text-sm text-gray-400">
|
||||||
|
No channels yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Direct Messages Section */}
|
||||||
|
<div className="p-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDMs(!showDMs)}
|
||||||
|
className="w-full flex items-center justify-between px-2 py-1 hover:bg-gray-700 rounded text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{showDMs ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
<span className="font-semibold">Direct Messages</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showDMs && (
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{conversations.map((conv) => (
|
||||||
|
<button
|
||||||
|
key={conv.user.id}
|
||||||
|
onClick={() => router.push(`/dms/${conv.user.id}`)}
|
||||||
|
className="w-full flex items-center gap-2 px-4 py-1.5 hover:bg-gray-700 rounded text-sm group"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<MessageSquare className="w-4 h-4 text-gray-400" />
|
||||||
|
{conv.user.is_online && (
|
||||||
|
<div className="absolute -bottom-0.5 -right-0.5 w-2 h-2 bg-green-500 rounded-full border border-gray-800" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="truncate flex-1 text-left">{conv.user.name}</span>
|
||||||
|
{conv.unread_count > 0 && (
|
||||||
|
<span className="px-1.5 py-0.5 bg-blue-600 text-xs rounded-full">
|
||||||
|
{conv.unread_count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{conversations.length === 0 && (
|
||||||
|
<div className="px-4 py-2 text-sm text-gray-400">
|
||||||
|
No conversations yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User info footer */}
|
||||||
|
<div className="h-14 flex items-center justify-between px-4 border-t border-gray-700 bg-gray-800">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0">
|
||||||
|
{user?.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">{user?.name}</div>
|
||||||
|
<div className="text-xs text-gray-400 truncate">{user?.role}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-gray-700 rounded flex-shrink-0"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
frontend/src/stores/useAuthStore.ts
Normal file
102
frontend/src/stores/useAuthStore.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
import { wsClient } from '@/lib/socket';
|
||||||
|
import type { User, LoginRequest, RegisterRequest } from '@/lib/types';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
login: (data: LoginRequest) => Promise<void>;
|
||||||
|
register: (data: RegisterRequest) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
loadUser: () => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
login: async (data: LoginRequest) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const response = await apiClient.login(data);
|
||||||
|
apiClient.setToken(response.access_token);
|
||||||
|
|
||||||
|
const user = await apiClient.getCurrentUser();
|
||||||
|
set({ user, isAuthenticated: true, isLoading: false });
|
||||||
|
|
||||||
|
// Connect WebSocket after successful login
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
wsClient.connect(response.access_token);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.detail || 'Login failed';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (data: RegisterRequest) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const response = await apiClient.register(data);
|
||||||
|
apiClient.setToken(response.access_token);
|
||||||
|
|
||||||
|
const user = await apiClient.getCurrentUser();
|
||||||
|
set({ user, isAuthenticated: true, isLoading: false });
|
||||||
|
|
||||||
|
// Connect WebSocket after successful registration
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
wsClient.connect(response.access_token);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.detail || 'Registration failed';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
await apiClient.logout();
|
||||||
|
wsClient.disconnect();
|
||||||
|
set({ user: null, isAuthenticated: false, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
// Even if logout fails, clear local state
|
||||||
|
apiClient.clearToken();
|
||||||
|
wsClient.disconnect();
|
||||||
|
set({ user: null, isAuthenticated: false, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadUser: async () => {
|
||||||
|
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
||||||
|
if (!token) {
|
||||||
|
set({ isAuthenticated: false, isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const user = await apiClient.getCurrentUser();
|
||||||
|
set({ user, isAuthenticated: true, isLoading: false });
|
||||||
|
|
||||||
|
// Connect WebSocket if not already connected
|
||||||
|
if (typeof window !== 'undefined' && !wsClient.isConnected()) {
|
||||||
|
wsClient.connect(token);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
apiClient.clearToken();
|
||||||
|
set({ user: null, isAuthenticated: false, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
89
frontend/src/stores/useChannelStore.ts
Normal file
89
frontend/src/stores/useChannelStore.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
import type { Channel } from '@/lib/types';
|
||||||
|
|
||||||
|
interface ChannelState {
|
||||||
|
channels: Channel[];
|
||||||
|
currentChannel: Channel | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
loadChannels: () => Promise<void>;
|
||||||
|
createChannel: (data: { name: string; description?: string; is_private?: boolean }) => Promise<Channel>;
|
||||||
|
setCurrentChannel: (channel: Channel | null) => void;
|
||||||
|
joinChannel: (channelId: number) => Promise<void>;
|
||||||
|
leaveChannel: (channelId: number) => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChannelStore = create<ChannelState>((set, get) => ({
|
||||||
|
channels: [],
|
||||||
|
currentChannel: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
loadChannels: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const channels = await apiClient.getChannels();
|
||||||
|
set({ channels, isLoading: false });
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.detail || 'Failed to load channels';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createChannel: async (data) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const channel = await apiClient.createChannel(data);
|
||||||
|
set((state) => ({
|
||||||
|
channels: [...state.channels, channel],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
return channel;
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.detail || 'Failed to create channel';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurrentChannel: (channel) => {
|
||||||
|
set({ currentChannel: channel });
|
||||||
|
},
|
||||||
|
|
||||||
|
joinChannel: async (channelId) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
await apiClient.joinChannel(channelId);
|
||||||
|
// Reload channels to update member count
|
||||||
|
await get().loadChannels();
|
||||||
|
set({ isLoading: false });
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.detail || 'Failed to join channel';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
leaveChannel: async (channelId) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
await apiClient.leaveChannel(channelId);
|
||||||
|
// Reload channels to update member count
|
||||||
|
await get().loadChannels();
|
||||||
|
// Clear current channel if it's the one we left
|
||||||
|
if (get().currentChannel?.id === channelId) {
|
||||||
|
set({ currentChannel: null });
|
||||||
|
}
|
||||||
|
set({ isLoading: false });
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.detail || 'Failed to leave channel';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
182
frontend/src/stores/useMessageStore.ts
Normal file
182
frontend/src/stores/useMessageStore.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
import type { Message, DirectMessage, Conversation } from '@/lib/types';
|
||||||
|
|
||||||
|
interface MessageState {
|
||||||
|
// Channel messages
|
||||||
|
messagesByChannel: Record<number, Message[]>;
|
||||||
|
isLoadingMessages: boolean;
|
||||||
|
|
||||||
|
// Direct messages
|
||||||
|
conversations: Conversation[];
|
||||||
|
dmsByUser: Record<number, DirectMessage[]>;
|
||||||
|
isLoadingDMs: boolean;
|
||||||
|
|
||||||
|
// Typing indicators
|
||||||
|
typingUsers: Record<number, Set<number>>; // channelId -> Set of userIds
|
||||||
|
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Channel message actions
|
||||||
|
loadMessages: (channelId: number) => Promise<void>;
|
||||||
|
sendMessage: (channelId: number, content: string) => Promise<void>;
|
||||||
|
addMessage: (channelId: number, message: Message) => void;
|
||||||
|
|
||||||
|
// Direct message actions
|
||||||
|
loadConversations: () => Promise<void>;
|
||||||
|
loadDirectMessages: (userId: number) => Promise<void>;
|
||||||
|
sendDirectMessage: (userId: number, content: string) => Promise<void>;
|
||||||
|
addDirectMessage: (dm: DirectMessage) => void;
|
||||||
|
|
||||||
|
// Typing indicators
|
||||||
|
setUserTyping: (channelId: number, userId: number) => void;
|
||||||
|
setUserStoppedTyping: (channelId: number, userId: number) => void;
|
||||||
|
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMessageStore = create<MessageState>((set, get) => ({
|
||||||
|
messagesByChannel: {},
|
||||||
|
isLoadingMessages: false,
|
||||||
|
conversations: [],
|
||||||
|
dmsByUser: {},
|
||||||
|
isLoadingDMs: false,
|
||||||
|
typingUsers: {},
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
loadMessages: async (channelId) => {
|
||||||
|
set({ isLoadingMessages: true, error: null });
|
||||||
|
try {
|
||||||
|
const messages = await apiClient.getMessages(channelId);
|
||||||
|
set((state) => ({
|
||||||
|
messagesByChannel: {
|
||||||
|
...state.messagesByChannel,
|
||||||
|
[channelId]: messages,
|
||||||
|
},
|
||||||
|
isLoadingMessages: false,
|
||||||
|
}));
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.detail || 'Failed to load messages';
|
||||||
|
set({ error: message, isLoadingMessages: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMessage: async (channelId, content) => {
|
||||||
|
try {
|
||||||
|
const message = await apiClient.sendMessage(channelId, content);
|
||||||
|
// Message will be added via WebSocket event
|
||||||
|
// But add it optimistically in case WebSocket is slow
|
||||||
|
get().addMessage(channelId, message);
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.detail || 'Failed to send message';
|
||||||
|
set({ error: message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addMessage: (channelId, message) => {
|
||||||
|
set((state) => {
|
||||||
|
const currentMessages = state.messagesByChannel[channelId] || [];
|
||||||
|
// Check if message already exists (avoid duplicates)
|
||||||
|
const exists = currentMessages.some(m => m.id === message.id);
|
||||||
|
if (exists) return state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
messagesByChannel: {
|
||||||
|
...state.messagesByChannel,
|
||||||
|
[channelId]: [...currentMessages, message],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadConversations: async () => {
|
||||||
|
set({ isLoadingDMs: true, error: null });
|
||||||
|
try {
|
||||||
|
const conversations = await apiClient.getConversations();
|
||||||
|
set({ conversations, isLoadingDMs: false });
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.detail || 'Failed to load conversations';
|
||||||
|
set({ error: message, isLoadingDMs: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadDirectMessages: async (userId) => {
|
||||||
|
set({ isLoadingDMs: true, error: null });
|
||||||
|
try {
|
||||||
|
const dms = await apiClient.getDirectMessages(userId);
|
||||||
|
set((state) => ({
|
||||||
|
dmsByUser: {
|
||||||
|
...state.dmsByUser,
|
||||||
|
[userId]: dms,
|
||||||
|
},
|
||||||
|
isLoadingDMs: false,
|
||||||
|
}));
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.detail || 'Failed to load direct messages';
|
||||||
|
set({ error: message, isLoadingDMs: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sendDirectMessage: async (userId, content) => {
|
||||||
|
try {
|
||||||
|
const dm = await apiClient.sendDirectMessage(userId, content);
|
||||||
|
// DM will be added via WebSocket event
|
||||||
|
// But add it optimistically in case WebSocket is slow
|
||||||
|
get().addDirectMessage(dm);
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.detail || 'Failed to send direct message';
|
||||||
|
set({ error: message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addDirectMessage: (dm) => {
|
||||||
|
set((state) => {
|
||||||
|
// Determine the other user ID (not the current user)
|
||||||
|
const otherUserId = dm.sender_id !== state.dmsByUser[dm.sender_id]?.[0]?.recipient_id
|
||||||
|
? dm.sender_id
|
||||||
|
: dm.recipient_id;
|
||||||
|
|
||||||
|
const currentDMs = state.dmsByUser[otherUserId] || [];
|
||||||
|
// Check if message already exists
|
||||||
|
const exists = currentDMs.some(m => m.id === dm.id);
|
||||||
|
if (exists) return state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
dmsByUser: {
|
||||||
|
...state.dmsByUser,
|
||||||
|
[otherUserId]: [...currentDMs, dm],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setUserTyping: (channelId, userId) => {
|
||||||
|
set((state) => {
|
||||||
|
const channelTyping = new Set(state.typingUsers[channelId] || []);
|
||||||
|
channelTyping.add(userId);
|
||||||
|
return {
|
||||||
|
typingUsers: {
|
||||||
|
...state.typingUsers,
|
||||||
|
[channelId]: channelTyping,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setUserStoppedTyping: (channelId, userId) => {
|
||||||
|
set((state) => {
|
||||||
|
const channelTyping = new Set(state.typingUsers[channelId] || []);
|
||||||
|
channelTyping.delete(userId);
|
||||||
|
return {
|
||||||
|
typingUsers: {
|
||||||
|
...state.typingUsers,
|
||||||
|
[channelId]: channelTyping,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
19
frontend/tailwind.config.ts
Normal file
19
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: "var(--background)",
|
||||||
|
foreground: "var(--foreground)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user