Add web client and containerized deployment stack
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:
2026-03-07 21:55:50 +03:00
parent 85631b566a
commit 2501466c7a
35 changed files with 4074 additions and 0 deletions

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

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