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:
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 }),
|
||||
}));
|
||||
Reference in New Issue
Block a user