Add username search and improve chat creation UX
All checks were successful
CI / test (push) Successful in 23s
All checks were successful
CI / test (push) Successful in 23s
Backend user search: - Added users search endpoint for @username lookup. - Implemented repository/service/router support with bounded result limits. Web chat creation: - Added API client for /users/search. - Added NewChatPanel for creating private chats via @username search. - Added group/channel creation flow from sidebar. UX refinement: - Hide message composer when no chat is selected. - Show explicit placeholder: 'Выберите чат, чтобы начать переписку'. - Added tsbuildinfo ignore rule.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { http } from "./http";
|
||||
import type { Chat, Message, MessageType } from "../chat/types";
|
||||
import type { Chat, ChatType, Message, MessageType } from "../chat/types";
|
||||
|
||||
export async function getChats(): Promise<Chat[]> {
|
||||
const { data } = await http.get<Chat[]>("/chats");
|
||||
@@ -7,10 +7,14 @@ export async function getChats(): Promise<Chat[]> {
|
||||
}
|
||||
|
||||
export async function createPrivateChat(memberId: number): Promise<Chat> {
|
||||
return createChat("private", null, [memberId]);
|
||||
}
|
||||
|
||||
export async function createChat(type: ChatType, title: string | null, memberIds: number[] = []): Promise<Chat> {
|
||||
const { data } = await http.post<Chat>("/chats", {
|
||||
type: "private",
|
||||
title: null,
|
||||
member_ids: [memberId]
|
||||
type,
|
||||
title,
|
||||
member_ids: memberIds
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
9
web/src/api/users.ts
Normal file
9
web/src/api/users.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { http } from "./http";
|
||||
import type { UserSearchItem } from "../chat/types";
|
||||
|
||||
export async function searchUsers(query: string, limit = 20): Promise<UserSearchItem[]> {
|
||||
const { data } = await http.get<UserSearchItem[]>("/users/search", {
|
||||
params: { query, limit }
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -33,3 +33,9 @@ export interface TokenPair {
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface UserSearchItem {
|
||||
id: number;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { NewChatPanel } from "./NewChatPanel";
|
||||
|
||||
export function ChatList() {
|
||||
const chats = useChatStore((s) => s.chats);
|
||||
@@ -8,6 +9,7 @@ export function ChatList() {
|
||||
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>
|
||||
<NewChatPanel />
|
||||
<div className="max-h-[calc(100vh-56px)] overflow-auto">
|
||||
{chats.map((chat) => (
|
||||
<button
|
||||
|
||||
131
web/src/components/NewChatPanel.tsx
Normal file
131
web/src/components/NewChatPanel.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { createChat, createPrivateChat, getChats } from "../api/chats";
|
||||
import { searchUsers } from "../api/users";
|
||||
import type { ChatType, UserSearchItem } from "../chat/types";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
|
||||
type CreateMode = "private" | "group" | "channel";
|
||||
|
||||
export function NewChatPanel() {
|
||||
const [mode, setMode] = useState<CreateMode>("private");
|
||||
const [query, setQuery] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [results, setResults] = useState<UserSearchItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
|
||||
|
||||
const normalizedQuery = useMemo(() => query.trim(), [query]);
|
||||
|
||||
async function handleSearch(value: string) {
|
||||
setQuery(value);
|
||||
setError(null);
|
||||
if (value.trim().replace("@", "").length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const users = await searchUsers(value);
|
||||
setResults(users);
|
||||
} catch {
|
||||
setError("Search failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshChatsAndSelectLast() {
|
||||
const chats = await getChats();
|
||||
useChatStore.setState({ chats });
|
||||
if (chats[0]) {
|
||||
setActiveChatId(chats[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
async function createPrivate(userId: number) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await createPrivateChat(userId);
|
||||
await refreshChatsAndSelectLast();
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
} catch {
|
||||
setError("Failed to create private chat");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createByType(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (mode === "private") {
|
||||
return;
|
||||
}
|
||||
if (!title.trim()) {
|
||||
setError("Title is required");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await createChat(mode as ChatType, title.trim(), []);
|
||||
await refreshChatsAndSelectLast();
|
||||
setTitle("");
|
||||
} catch {
|
||||
setError("Failed to create chat");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-slate-700 p-3">
|
||||
<div className="mb-2 flex gap-2 text-xs">
|
||||
<button className={`rounded px-2 py-1 ${mode === "private" ? "bg-accent text-black" : "bg-slate-700"}`} onClick={() => setMode("private")}>
|
||||
Private
|
||||
</button>
|
||||
<button className={`rounded px-2 py-1 ${mode === "group" ? "bg-accent text-black" : "bg-slate-700"}`} onClick={() => setMode("group")}>
|
||||
Group
|
||||
</button>
|
||||
<button className={`rounded px-2 py-1 ${mode === "channel" ? "bg-accent text-black" : "bg-slate-700"}`} onClick={() => setMode("channel")}>
|
||||
Channel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === "private" ? (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
className="w-full rounded bg-slate-800 px-2 py-1 text-sm"
|
||||
placeholder="@username"
|
||||
value={query}
|
||||
onChange={(e) => void handleSearch(e.target.value)}
|
||||
/>
|
||||
<div className="max-h-32 overflow-auto">
|
||||
{results.map((user) => (
|
||||
<button
|
||||
className="mb-1 block w-full rounded bg-slate-800 px-2 py-1 text-left text-sm hover:bg-slate-700"
|
||||
key={user.id}
|
||||
onClick={() => void createPrivate(user.id)}
|
||||
>
|
||||
@{user.username}
|
||||
</button>
|
||||
))}
|
||||
{normalizedQuery && results.length === 0 ? <p className="text-xs text-slate-400">No users</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-2" onSubmit={(e) => void createByType(e)}>
|
||||
<input
|
||||
className="w-full rounded bg-slate-800 px-2 py-1 text-sm"
|
||||
placeholder={mode === "group" ? "Group title" : "Channel title"}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<button className="w-full rounded bg-slate-700 px-2 py-1 text-sm hover:bg-slate-600" disabled={loading} type="submit">
|
||||
Create {mode}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{error ? <p className="mt-2 text-xs text-red-400">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,13 @@ export function ChatsPage() {
|
||||
<div className="min-h-0 flex-1">
|
||||
<MessageList />
|
||||
</div>
|
||||
<MessageComposer />
|
||||
{activeChatId ? (
|
||||
<MessageComposer />
|
||||
) : (
|
||||
<div className="border-t border-slate-700 bg-panel p-4 text-center text-sm text-slate-400">
|
||||
Выберите чат, чтобы начать переписку
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user