Add web client and containerized deployment stack
All checks were successful
CI / test (push) Successful in 19s
All checks were successful
CI / test (push) Successful in 19s
Web client: - Added React + TypeScript + Vite + Tailwind application in web/. - Implemented auth, chat list, chat messages, typing indicators, file uploads, and voice recording/playback. - Added typed API layer, Zustand stores, and realtime websocket hook integration. Containerization: - Added backend Dockerfile and project .dockerignore. - Added web multi-stage Dockerfile with nginx static hosting and API/WS reverse proxy. - Added full docker-compose stack with postgres, redis, minio, backend, worker, mailpit, and web. - Added MinIO bucket bootstrap init job and updated README with Docker quick-start.
This commit is contained in:
58
web/src/store/authStore.ts
Normal file
58
web/src/store/authStore.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { create } from "zustand";
|
||||
import { loginRequest, meRequest, refreshRequest } from "../api/auth";
|
||||
import type { AuthUser } from "../chat/types";
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
me: AuthUser | null;
|
||||
loading: boolean;
|
||||
setTokens: (accessToken: string, refreshToken: string) => void;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
loadMe: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const ACCESS_KEY = "bm_access_token";
|
||||
const REFRESH_KEY = "bm_refresh_token";
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
accessToken: localStorage.getItem(ACCESS_KEY),
|
||||
refreshToken: localStorage.getItem(REFRESH_KEY),
|
||||
me: null,
|
||||
loading: false,
|
||||
setTokens: (accessToken, refreshToken) => {
|
||||
localStorage.setItem(ACCESS_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_KEY, refreshToken);
|
||||
set({ accessToken, refreshToken });
|
||||
},
|
||||
login: async (email, password) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const data = await loginRequest(email, password);
|
||||
get().setTokens(data.access_token, data.refresh_token);
|
||||
await get().loadMe();
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
loadMe: async () => {
|
||||
const me = await meRequest();
|
||||
set({ me });
|
||||
},
|
||||
refresh: async () => {
|
||||
const token = get().refreshToken;
|
||||
if (!token) {
|
||||
get().logout();
|
||||
return;
|
||||
}
|
||||
const data = await refreshRequest(token);
|
||||
get().setTokens(data.access_token, data.refresh_token);
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
set({ accessToken: null, refreshToken: null, me: null });
|
||||
}
|
||||
}));
|
||||
47
web/src/store/chatStore.ts
Normal file
47
web/src/store/chatStore.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { create } from "zustand";
|
||||
import { getChats, getMessages } from "../api/chats";
|
||||
import type { Chat, Message } from "../chat/types";
|
||||
|
||||
interface ChatState {
|
||||
chats: Chat[];
|
||||
activeChatId: number | null;
|
||||
messagesByChat: Record<number, Message[]>;
|
||||
typingByChat: Record<number, number[]>;
|
||||
loadChats: () => Promise<void>;
|
||||
setActiveChatId: (chatId: number | null) => void;
|
||||
loadMessages: (chatId: number) => Promise<void>;
|
||||
prependMessage: (chatId: number, message: Message) => void;
|
||||
setTypingUsers: (chatId: number, userIds: number[]) => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set, get) => ({
|
||||
chats: [],
|
||||
activeChatId: null,
|
||||
messagesByChat: {},
|
||||
typingByChat: {},
|
||||
loadChats: async () => {
|
||||
const chats = await getChats();
|
||||
set({ chats, activeChatId: chats[0]?.id ?? null });
|
||||
},
|
||||
setActiveChatId: (chatId) => set({ activeChatId: chatId }),
|
||||
loadMessages: async (chatId) => {
|
||||
const messages = await getMessages(chatId);
|
||||
set((state) => ({
|
||||
messagesByChat: {
|
||||
...state.messagesByChat,
|
||||
[chatId]: [...messages].reverse()
|
||||
}
|
||||
}));
|
||||
},
|
||||
prependMessage: (chatId, message) => {
|
||||
const old = get().messagesByChat[chatId] ?? [];
|
||||
if (old.some((m) => m.id === message.id)) {
|
||||
return;
|
||||
}
|
||||
set((state) => ({
|
||||
messagesByChat: { ...state.messagesByChat, [chatId]: [...old, message] }
|
||||
}));
|
||||
},
|
||||
setTypingUsers: (chatId, userIds) =>
|
||||
set((state) => ({ typingByChat: { ...state.typingByChat, [chatId]: userIds } }))
|
||||
}));
|
||||
Reference in New Issue
Block a user