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

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.github
.idea
.venv
.venv312
__pycache__
.pytest_cache
test.db
web/node_modules
web/dist
*.pyc
*.pyo
*.pyd

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@ __pycache__/
.idea/ .idea/
.env .env
test.db test.db
web/node_modules
web/dist

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -23,3 +23,21 @@ celery -A app.celery_app:celery_app worker --loglevel=info
python -m compileall app main.py python -m compileall app main.py
- Tests: - Tests:
pytest -q pytest -q
## Web Client
1. cd web
2. copy `.env.example` to `.env`
3. npm install
4. npm run dev
## Docker Quick Start
Run full stack (web + api + worker + postgres + redis + minio + mailpit):
1. docker compose up --build
2. Open:
- Web: http://localhost
- API docs: http://localhost:8000/docs
- Mailpit UI: http://localhost:8025
- MinIO console: http://localhost:9001

159
docker-compose.yml Normal file
View File

@@ -0,0 +1,159 @@
services:
postgres:
image: postgres:16-alpine
container_name: messenger-postgres
environment:
POSTGRES_DB: messenger
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d messenger"]
interval: 5s
timeout: 5s
retries: 20
redis:
image: redis:7-alpine
container_name: messenger-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
minio:
image: minio/minio:latest
container_name: messenger-minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
minio-init:
image: minio/mc:latest
depends_on:
- minio
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 minioadmin minioadmin &&
mc mb --ignore-existing local/messenger-media
"
restart: "no"
backend:
build:
context: .
dockerfile: Dockerfile
container_name: messenger-backend
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio-init:
condition: service_completed_successfully
environment:
APP_NAME: BenyaMessenger
ENVIRONMENT: production
DEBUG: "false"
API_V1_PREFIX: /api/v1
AUTO_CREATE_TABLES: "true"
SECRET_KEY: "change-me-please-with-a-long-random-secret"
JWT_ALGORITHM: HS256
ACCESS_TOKEN_EXPIRE_MINUTES: "30"
REFRESH_TOKEN_EXPIRE_DAYS: "30"
EMAIL_VERIFICATION_TOKEN_EXPIRE_HOURS: "24"
PASSWORD_RESET_TOKEN_EXPIRE_HOURS: "1"
POSTGRES_DSN: postgresql+asyncpg://postgres:postgres@postgres:5432/messenger
REDIS_URL: redis://redis:6379/0
S3_ENDPOINT_URL: http://minio:9000
S3_ACCESS_KEY: minioadmin
S3_SECRET_KEY: minioadmin
S3_REGION: us-east-1
S3_BUCKET_NAME: messenger-media
S3_PRESIGN_EXPIRE_SECONDS: "900"
MAX_UPLOAD_SIZE_BYTES: "104857600"
FRONTEND_BASE_URL: http://localhost
SMTP_HOST: mailpit
SMTP_PORT: "1025"
SMTP_USERNAME: ""
SMTP_PASSWORD: ""
SMTP_USE_TLS: "false"
SMTP_FROM_EMAIL: no-reply@benyamessenger.local
ports:
- "8000:8000"
worker:
build:
context: .
dockerfile: Dockerfile
container_name: messenger-worker
command: celery -A app.celery_app:celery_app worker --loglevel=info
depends_on:
redis:
condition: service_healthy
backend:
condition: service_started
environment:
APP_NAME: BenyaMessenger
ENVIRONMENT: production
DEBUG: "false"
API_V1_PREFIX: /api/v1
AUTO_CREATE_TABLES: "false"
SECRET_KEY: "change-me-please-with-a-long-random-secret"
JWT_ALGORITHM: HS256
ACCESS_TOKEN_EXPIRE_MINUTES: "30"
REFRESH_TOKEN_EXPIRE_DAYS: "30"
EMAIL_VERIFICATION_TOKEN_EXPIRE_HOURS: "24"
PASSWORD_RESET_TOKEN_EXPIRE_HOURS: "1"
POSTGRES_DSN: postgresql+asyncpg://postgres:postgres@postgres:5432/messenger
REDIS_URL: redis://redis:6379/0
S3_ENDPOINT_URL: http://minio:9000
S3_ACCESS_KEY: minioadmin
S3_SECRET_KEY: minioadmin
S3_REGION: us-east-1
S3_BUCKET_NAME: messenger-media
S3_PRESIGN_EXPIRE_SECONDS: "900"
MAX_UPLOAD_SIZE_BYTES: "104857600"
FRONTEND_BASE_URL: http://localhost
SMTP_HOST: mailpit
SMTP_PORT: "1025"
SMTP_USERNAME: ""
SMTP_PASSWORD: ""
SMTP_USE_TLS: "false"
SMTP_FROM_EMAIL: no-reply@benyamessenger.local
mailpit:
image: axllent/mailpit:latest
container_name: messenger-mailpit
ports:
- "1025:1025"
- "8025:8025"
web:
build:
context: ./web
dockerfile: Dockerfile
container_name: messenger-web
depends_on:
- backend
ports:
- "80:80"
volumes:
pg_data:
redis_data:
minio_data:

2
web/.env.example Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://localhost:8000/api/v1
VITE_WS_URL=ws://localhost:8000/api/v1/realtime/ws

14
web/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:20-alpine AS build
WORKDIR /web
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /web/dist /usr/share/nginx/html
EXPOSE 80

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Benya Messenger</title>
</head>
<body class="bg-bg text-text">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
web/nginx.conf Normal file
View File

@@ -0,0 +1,28 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/v1/realtime/ws {
proxy_pass http://backend:8000/api/v1/realtime/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600;
}
}

3073
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
web/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "benya-messenger-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "1.11.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"zustand": "5.0.8"
},
"devDependencies": {
"@types/react": "18.3.24",
"@types/react-dom": "18.3.7",
"@vitejs/plugin-react": "5.0.2",
"autoprefixer": "10.4.21",
"postcss": "8.5.6",
"tailwindcss": "3.4.17",
"typescript": "5.9.2",
"vite": "7.1.3"
}
}

6
web/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

21
web/src/api/auth.ts Normal file
View File

@@ -0,0 +1,21 @@
import { http } from "./http";
import type { AuthUser, TokenPair } from "../chat/types";
export async function registerRequest(email: string, username: string, password: string): Promise<void> {
await http.post("/auth/register", { email, username, password });
}
export async function loginRequest(email: string, password: string): Promise<TokenPair> {
const { data } = await http.post<TokenPair>("/auth/login", { email, password });
return data;
}
export async function refreshRequest(refreshToken: string): Promise<TokenPair> {
const { data } = await http.post<TokenPair>("/auth/refresh", { refresh_token: refreshToken });
return data;
}
export async function meRequest(): Promise<AuthUser> {
const { data } = await http.get<AuthUser>("/auth/me");
return data;
}

57
web/src/api/chats.ts Normal file
View File

@@ -0,0 +1,57 @@
import { http } from "./http";
import type { Chat, Message, MessageType } from "../chat/types";
export async function getChats(): Promise<Chat[]> {
const { data } = await http.get<Chat[]>("/chats");
return data;
}
export async function createPrivateChat(memberId: number): Promise<Chat> {
const { data } = await http.post<Chat>("/chats", {
type: "private",
title: null,
member_ids: [memberId]
});
return data;
}
export async function getMessages(chatId: number, beforeId?: number): Promise<Message[]> {
const { data } = await http.get<Message[]>(`/messages/${chatId}`, {
params: {
limit: 50,
before_id: beforeId
}
});
return data;
}
export async function sendMessage(chatId: number, text: string, type: MessageType = "text"): Promise<Message> {
const { data } = await http.post<Message>("/messages", { chat_id: chatId, text, type });
return data;
}
export interface UploadUrlResponse {
upload_url: string;
file_url: string;
object_key: string;
expires_in: number;
required_headers: Record<string, string>;
}
export async function requestUploadUrl(file: File): Promise<UploadUrlResponse> {
const { data } = await http.post<UploadUrlResponse>("/media/upload-url", {
file_name: file.name,
file_type: file.type || "application/octet-stream",
file_size: file.size
});
return data;
}
export async function attachFile(messageId: number, fileUrl: string, fileType: string, fileSize: number): Promise<void> {
await http.post("/media/attachments", {
message_id: messageId,
file_url: fileUrl,
file_type: fileType,
file_size: fileSize
});
}

17
web/src/api/http.ts Normal file
View File

@@ -0,0 +1,17 @@
import axios from "axios";
import { useAuthStore } from "../store/authStore";
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
export const http = axios.create({
baseURL: apiBaseUrl,
timeout: 10000
});
http.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});

31
web/src/app/App.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { useEffect } from "react";
import { AuthPage } from "../pages/AuthPage";
import { ChatsPage } from "../pages/ChatsPage";
import { useAuthStore } from "../store/authStore";
export function App() {
const accessToken = useAuthStore((s) => s.accessToken);
const me = useAuthStore((s) => s.me);
const loadMe = useAuthStore((s) => s.loadMe);
const refresh = useAuthStore((s) => s.refresh);
const logout = useAuthStore((s) => s.logout);
useEffect(() => {
if (!accessToken) {
return;
}
loadMe().catch(async () => {
try {
await refresh();
await loadMe();
} catch {
logout();
}
});
}, [accessToken, loadMe, refresh, logout]);
if (!accessToken || !me) {
return <AuthPage />;
}
return <ChatsPage />;
}

35
web/src/chat/types.ts Normal file
View File

@@ -0,0 +1,35 @@
export type ChatType = "private" | "group" | "channel";
export type MessageType = "text" | "image" | "video" | "audio" | "voice" | "file" | "circle_video";
export interface Chat {
id: number;
type: ChatType;
title: string | null;
created_at: string;
}
export interface Message {
id: number;
chat_id: number;
sender_id: number;
type: MessageType;
text: string | null;
created_at: string;
updated_at: string;
}
export interface AuthUser {
id: number;
email: string;
username: string;
avatar_url: string | null;
email_verified: boolean;
created_at: string;
updated_at: string;
}
export interface TokenPair {
access_token: string;
refresh_token: string;
token_type: string;
}

View File

@@ -0,0 +1,58 @@
import { FormEvent, useState } from "react";
import { registerRequest } from "../api/auth";
import { useAuthStore } from "../store/authStore";
type Mode = "login" | "register";
export function AuthPanel() {
const login = useAuthStore((s) => s.login);
const loading = useAuthStore((s) => s.loading);
const [mode, setMode] = useState<Mode>("login");
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
async function onSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
setSuccess(null);
try {
if (mode === "register") {
await registerRequest(email, username, password);
setSuccess("Registered. Check email verification, then login.");
setMode("login");
return;
}
await login(email, password);
} catch {
setError("Auth request failed.");
}
}
return (
<div className="mx-auto mt-16 w-full max-w-md rounded-xl bg-panel p-6 shadow-xl">
<div className="mb-4 flex gap-2">
<button className={`rounded px-3 py-2 ${mode === "login" ? "bg-accent text-black" : "bg-slate-700"}`} onClick={() => setMode("login")}>
Login
</button>
<button className={`rounded px-3 py-2 ${mode === "register" ? "bg-accent text-black" : "bg-slate-700"}`} onClick={() => setMode("register")}>
Register
</button>
</div>
<form className="space-y-3" onSubmit={onSubmit}>
<input className="w-full rounded bg-slate-800 px-3 py-2" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
{mode === "register" && (
<input className="w-full rounded bg-slate-800 px-3 py-2" placeholder="Username" value={username} onChange={(e) => setUsername(e.target.value)} />
)}
<input className="w-full rounded bg-slate-800 px-3 py-2" placeholder="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<button className="w-full rounded bg-accent px-3 py-2 font-semibold text-black disabled:opacity-50" disabled={loading} type="submit">
{mode === "login" ? "Sign in" : "Create account"}
</button>
</form>
{error ? <p className="mt-3 text-sm text-red-400">{error}</p> : null}
{success ? <p className="mt-3 text-sm text-emerald-400">{success}</p> : null}
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { useChatStore } from "../store/chatStore";
export function ChatList() {
const chats = useChatStore((s) => s.chats);
const activeChatId = useChatStore((s) => s.activeChatId);
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
return (
<aside className="w-full max-w-xs border-r border-slate-700 bg-panel">
<div className="border-b border-slate-700 p-3 text-sm font-semibold">Chats</div>
<div className="max-h-[calc(100vh-56px)] overflow-auto">
{chats.map((chat) => (
<button
className={`block w-full border-b border-slate-800 px-3 py-3 text-left ${activeChatId === chat.id ? "bg-slate-800" : "hover:bg-slate-800/40"}`}
key={chat.id}
onClick={() => setActiveChatId(chat.id)}
>
<p className="font-medium">{chat.title || `${chat.type} #${chat.id}`}</p>
<p className="text-xs text-slate-400">{chat.type}</p>
</button>
))}
</div>
</aside>
);
}

View File

@@ -0,0 +1,115 @@
import { useRef, useState } from "react";
import { attachFile, requestUploadUrl, sendMessage } from "../api/chats";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { buildWsUrl } from "../utils/ws";
export function MessageComposer() {
const activeChatId = useChatStore((s) => s.activeChatId);
const prependMessage = useChatStore((s) => s.prependMessage);
const accessToken = useAuthStore((s) => s.accessToken);
const [text, setText] = useState("");
const wsRef = useRef<WebSocket | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
function getWs(): WebSocket | null {
if (!accessToken || !activeChatId) {
return null;
}
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
return wsRef.current;
}
const wsUrl = buildWsUrl(accessToken);
wsRef.current = new WebSocket(wsUrl);
return wsRef.current;
}
async function handleSend() {
if (!activeChatId || !text.trim()) {
return;
}
const message = await sendMessage(activeChatId, text.trim(), "text");
prependMessage(activeChatId, message);
setText("");
const ws = getWs();
ws?.send(JSON.stringify({ event: "typing_stop", payload: { chat_id: activeChatId } }));
}
async function handleUpload(file: File, messageType: "file" | "image" | "video" | "audio" | "voice" = "file") {
if (!activeChatId) {
return;
}
const upload = await requestUploadUrl(file);
await fetch(upload.upload_url, {
method: "PUT",
headers: upload.required_headers,
body: file
});
const message = await sendMessage(activeChatId, upload.file_url, messageType);
await attachFile(message.id, upload.file_url, file.type || "application/octet-stream", file.size);
prependMessage(activeChatId, message);
}
async function startRecord() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
chunksRef.current = [];
recorder.ondataavailable = (e) => chunksRef.current.push(e.data);
recorder.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" });
await handleUpload(file, "voice");
};
recorderRef.current = recorder;
recorder.start();
}
function stopRecord() {
recorderRef.current?.stop();
recorderRef.current = null;
}
return (
<div className="border-t border-slate-700 bg-panel p-3">
<div className="mb-2 flex gap-2">
<input
className="flex-1 rounded bg-slate-800 px-3 py-2"
placeholder="Type message..."
value={text}
onChange={(e) => {
setText(e.target.value);
if (activeChatId) {
const ws = getWs();
ws?.send(JSON.stringify({ event: "typing_start", payload: { chat_id: activeChatId } }));
}
}}
/>
<button className="rounded bg-accent px-3 py-2 font-semibold text-black" onClick={handleSend}>
Send
</button>
</div>
<div className="flex items-center gap-2 text-sm">
<label className="cursor-pointer rounded bg-slate-700 px-3 py-1">
Upload
<input
className="hidden"
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
void handleUpload(file, "file");
}
}}
/>
</label>
<button className="rounded bg-slate-700 px-3 py-1" onClick={startRecord}>
Record Voice
</button>
<button className="rounded bg-slate-700 px-3 py-1" onClick={stopRecord}>
Stop
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { useMemo } from "react";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { formatTime } from "../utils/format";
export function MessageList() {
const me = useAuthStore((s) => s.me);
const activeChatId = useChatStore((s) => s.activeChatId);
const messagesByChat = useChatStore((s) => s.messagesByChat);
const typingByChat = useChatStore((s) => s.typingByChat);
const messages = useMemo(() => {
if (!activeChatId) {
return [];
}
return messagesByChat[activeChatId] ?? [];
}, [activeChatId, messagesByChat]);
if (!activeChatId) {
return <div className="flex h-full items-center justify-center text-slate-400">Select a chat</div>;
}
return (
<div className="flex h-full flex-col">
<div className="flex-1 overflow-auto p-4">
{messages.map((message) => (
<div className={`mb-3 flex ${message.sender_id === me?.id ? "justify-end" : "justify-start"}`} key={message.id}>
<div className="max-w-[80%] rounded-lg bg-slate-800 px-3 py-2">
{message.type === "voice" && message.text ? (
<audio controls src={message.text} />
) : (
<p className="whitespace-pre-wrap break-words">{message.text}</p>
)}
<p className="mt-1 text-right text-[11px] text-slate-400">{formatTime(message.created_at)}</p>
</div>
</div>
))}
</div>
<div className="px-4 pb-2 text-xs text-slate-400">
{(typingByChat[activeChatId] ?? []).length > 0 ? "Someone is typing..." : ""}
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { useEffect, useMemo, useRef } from "react";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import type { Message } from "../chat/types";
import { buildWsUrl } from "../utils/ws";
interface RealtimeEnvelope {
event: string;
payload: Record<string, unknown>;
timestamp: string;
}
export function useRealtime() {
const accessToken = useAuthStore((s) => s.accessToken);
const me = useAuthStore((s) => s.me);
const prependMessage = useChatStore((s) => s.prependMessage);
const typingByChat = useRef<Record<number, Set<number>>>({});
const wsUrl = useMemo(() => {
return accessToken ? buildWsUrl(accessToken) : null;
}, [accessToken]);
useEffect(() => {
if (!wsUrl) {
return;
}
const ws = new WebSocket(wsUrl);
ws.onmessage = (messageEvent) => {
const event: RealtimeEnvelope = JSON.parse(messageEvent.data);
if (event.event === "receive_message") {
const chatId = Number(event.payload.chat_id);
const message = event.payload.message as Message;
prependMessage(chatId, message);
}
if (event.event === "typing_start") {
const chatId = Number(event.payload.chat_id);
const userId = Number(event.payload.user_id);
if (userId === me?.id) {
return;
}
if (!typingByChat.current[chatId]) {
typingByChat.current[chatId] = new Set<number>();
}
typingByChat.current[chatId].add(userId);
useChatStore.getState().setTypingUsers(chatId, [...typingByChat.current[chatId]]);
}
if (event.event === "typing_stop") {
const chatId = Number(event.payload.chat_id);
const userId = Number(event.payload.user_id);
typingByChat.current[chatId]?.delete(userId);
useChatStore.getState().setTypingUsers(chatId, [...(typingByChat.current[chatId] ?? [])]);
}
};
return () => ws.close();
}, [wsUrl, prependMessage, me?.id]);
return null;
}

14
web/src/index.css Normal file
View File

@@ -0,0 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}

10
web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./app/App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,9 @@
import { AuthPanel } from "../components/AuthPanel";
export function AuthPage() {
return (
<main className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-950 to-black p-4">
<AuthPanel />
</main>
);
}

View File

@@ -0,0 +1,45 @@
import { useEffect } from "react";
import { ChatList } from "../components/ChatList";
import { MessageComposer } from "../components/MessageComposer";
import { MessageList } from "../components/MessageList";
import { useRealtime } from "../hooks/useRealtime";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
export function ChatsPage() {
const me = useAuthStore((s) => s.me);
const logout = useAuthStore((s) => s.logout);
const loadChats = useChatStore((s) => s.loadChats);
const activeChatId = useChatStore((s) => s.activeChatId);
const loadMessages = useChatStore((s) => s.loadMessages);
useRealtime();
useEffect(() => {
void loadChats();
}, [loadChats]);
useEffect(() => {
if (activeChatId) {
void loadMessages(activeChatId);
}
}, [activeChatId, loadMessages]);
return (
<main className="flex h-screen w-full bg-bg text-text">
<ChatList />
<section className="flex flex-1 flex-col">
<div className="flex items-center justify-between border-b border-slate-700 bg-panel px-4 py-3">
<p className="text-sm">Signed in as {me?.username}</p>
<button className="rounded bg-slate-700 px-3 py-1 text-sm" onClick={logout}>
Logout
</button>
</div>
<div className="min-h-0 flex-1">
<MessageList />
</div>
<MessageComposer />
</section>
</main>
);
}

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

4
web/src/utils/format.ts Normal file
View File

@@ -0,0 +1,4 @@
export function formatTime(iso: string): string {
const date = new Date(iso);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}

8
web/src/utils/ws.ts Normal file
View File

@@ -0,0 +1,8 @@
export function buildWsUrl(token: string): string {
const explicit = import.meta.env.VITE_WS_URL;
if (explicit) {
return `${explicit}?token=${encodeURIComponent(token)}`;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/api/v1/realtime/ws?token=${encodeURIComponent(token)}`;
}

1
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

16
web/tailwind.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { Config } from "tailwindcss";
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
bg: "#0f172a",
panel: "#111827",
accent: "#14b8a6",
text: "#e5e7eb"
}
}
},
plugins: []
} satisfies Config;

20
web/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": false,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"baseUrl": "./src"
},
"include": ["src"]
}

1
web/tsconfig.tsbuildinfo Normal file
View File

@@ -0,0 +1 @@
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/authpanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/authstore.ts","./src/store/chatstore.ts","./src/utils/format.ts","./src/utils/ws.ts"],"version":"5.9.2"}

9
web/vite.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173
}
});