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
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ __pycache__/
|
||||
test.db
|
||||
web/node_modules
|
||||
web/dist
|
||||
web/tsconfig.tsbuildinfo
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.users.models import User
|
||||
@@ -31,3 +31,19 @@ async def list_users_by_ids(db: AsyncSession, user_ids: list[int]) -> list[User]
|
||||
return []
|
||||
result = await db.execute(select(User).where(User.id.in_(user_ids)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def search_users_by_username(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
exclude_user_id: int | None = None,
|
||||
) -> list[User]:
|
||||
normalized = query.lower().strip().lstrip("@")
|
||||
stmt = select(User).where(func.lower(User.username).like(f"%{normalized}%"))
|
||||
if exclude_user_id is not None:
|
||||
stmt = stmt.where(User.id != exclude_user_id)
|
||||
stmt = stmt.order_by(User.username.asc()).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -4,8 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.auth.service import get_current_user
|
||||
from app.database.session import get_db
|
||||
from app.users.models import User
|
||||
from app.users.schemas import UserProfileUpdate, UserRead
|
||||
from app.users.service import get_user_by_id, get_user_by_username, update_user_profile
|
||||
from app.users.schemas import UserProfileUpdate, UserRead, UserSearchRead
|
||||
from app.users.service import get_user_by_id, get_user_by_username, search_users_by_username, update_user_profile
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
@@ -15,6 +15,24 @@ async def read_me(current_user: User = Depends(get_current_user)) -> UserRead:
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/search", response_model=list[UserSearchRead])
|
||||
async def search_users(
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list[UserSearchRead]:
|
||||
if len(query.strip().lstrip("@")) < 2:
|
||||
return []
|
||||
users = await search_users_by_username(
|
||||
db,
|
||||
query=query,
|
||||
limit=limit,
|
||||
exclude_user_id=current_user.id,
|
||||
)
|
||||
return users
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserRead)
|
||||
async def read_user(user_id: int, db: AsyncSession = Depends(get_db), _current_user: User = Depends(get_current_user)) -> UserRead:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
@@ -25,3 +25,11 @@ class UserRead(UserBase):
|
||||
class UserProfileUpdate(BaseModel):
|
||||
username: str | None = Field(default=None, min_length=3, max_length=50)
|
||||
avatar_url: str | None = Field(default=None, max_length=512)
|
||||
|
||||
|
||||
class UserSearchRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
username: str
|
||||
avatar_url: str | None = None
|
||||
|
||||
@@ -16,6 +16,22 @@ async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
|
||||
return await repository.get_user_by_username(db, username)
|
||||
|
||||
|
||||
async def search_users_by_username(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
exclude_user_id: int | None = None,
|
||||
) -> list[User]:
|
||||
safe_limit = max(1, min(limit, 50))
|
||||
return await repository.search_users_by_username(
|
||||
db,
|
||||
query=query,
|
||||
limit=safe_limit,
|
||||
exclude_user_id=exclude_user_id,
|
||||
)
|
||||
|
||||
|
||||
async def update_user_profile(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
|
||||
@@ -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