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/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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user