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