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:
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
2
.gitignore
vendored
@@ -5,3 +5,5 @@ __pycache__/
|
||||
.idea/
|
||||
.env
|
||||
test.db
|
||||
web/node_modules
|
||||
web/dist
|
||||
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal 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"]
|
||||
18
README.md
18
README.md
@@ -23,3 +23,21 @@ celery -A app.celery_app:celery_app worker --loglevel=info
|
||||
python -m compileall app main.py
|
||||
- Tests:
|
||||
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
159
docker-compose.yml
Normal 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
2
web/.env.example
Normal 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
14
web/Dockerfile
Normal 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
12
web/index.html
Normal 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
28
web/nginx.conf
Normal 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
3073
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
web/package.json
Normal file
27
web/package.json
Normal 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
6
web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
21
web/src/api/auth.ts
Normal file
21
web/src/api/auth.ts
Normal 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
57
web/src/api/chats.ts
Normal 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
17
web/src/api/http.ts
Normal 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
31
web/src/app/App.tsx
Normal 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
35
web/src/chat/types.ts
Normal 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;
|
||||
}
|
||||
58
web/src/components/AuthPanel.tsx
Normal file
58
web/src/components/AuthPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
web/src/components/ChatList.tsx
Normal file
25
web/src/components/ChatList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
web/src/components/MessageComposer.tsx
Normal file
115
web/src/components/MessageComposer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
web/src/components/MessageList.tsx
Normal file
44
web/src/components/MessageList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
web/src/hooks/useRealtime.ts
Normal file
60
web/src/hooks/useRealtime.ts
Normal 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
14
web/src/index.css
Normal 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
10
web/src/main.tsx
Normal 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>
|
||||
);
|
||||
9
web/src/pages/AuthPage.tsx
Normal file
9
web/src/pages/AuthPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
web/src/pages/ChatsPage.tsx
Normal file
45
web/src/pages/ChatsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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 } }))
|
||||
}));
|
||||
4
web/src/utils/format.ts
Normal file
4
web/src/utils/format.ts
Normal 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
8
web/src/utils/ws.ts
Normal 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
1
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
16
web/tailwind.config.ts
Normal file
16
web/tailwind.config.ts
Normal 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
20
web/tsconfig.json
Normal 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
1
web/tsconfig.tsbuildinfo
Normal 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
9
web/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user