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