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

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