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:
JA
2026-02-13 16:34:49 +00:00
parent d980314522
commit 22fe893e65
21 changed files with 7922 additions and 0 deletions

5
frontend/next-env.d.ts vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

33
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 }),
}));

View 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 }),
}));

View 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 }),
}));

View 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
View 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"]
}