История игр в профиле обрезается на 20 партиях без подсказки (#30) #42

Merged
NotBigGhost merged 1 commits from issue-30-history-load-more into dev 2026-09-09 20:31:06 +03:00
4 changed files with 76 additions and 15 deletions
+26 -1
View File
@@ -13,14 +13,38 @@ export function MatchHistory({
items, items,
userId, userId,
detail, detail,
hasMore = false,
loadingMore = false,
onMore,
}: { }: {
items: MatchListItem[]; items: MatchListItem[];
userId: number; userId: number;
detail: string; detail: string;
hasMore?: boolean;
loadingMore?: boolean;
onMore?: () => void;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const more = hasMore && onMore && (
<button
className="btn btn-ghost btn-block"
style={{ marginTop: 10 }}
onClick={onMore}
disabled={loadingMore}
>
{loadingMore ? "Загружаю…" : "Показать ещё"}
</button>
);
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>; if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
if (detail === "full") return <MatchListView items={items} />; if (detail === "full")
return (
<>
<MatchListView items={items} />
{more}
</>
);
return ( return (
<div className="stack"> <div className="stack">
@@ -55,6 +79,7 @@ export function MatchHistory({
</button> </button>
); );
})} })}
{more}
</div> </div>
); );
} }
+20 -7
View File
@@ -1,4 +1,4 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, unwrap } from "../api/client"; import { api, unwrap } from "../api/client";
@@ -15,18 +15,31 @@ function useDebounced<T>(value: T, ms = 100): T {
return debounced; return debounced;
} }
/** История партий игрока. Режим и подробность приходят вместе со списком: const HISTORY_PAGE = 20;
* это витрина ВЛАДЕЛЬЦА профиля, её видят и гости. */
/** История партий игрока страницами по 20 («Показать ещё»).
*
* Режим и подробность приходят вместе со списком: это витрина ВЛАДЕЛЬЦА профиля,
* её видят и гости. Постранично, а не одним большим запросом: у эндпоинта потолок
* limit=100, и на 101-й партии обрезка вернулась бы. */
export function useUserMatches(userId: number | null) { export function useUserMatches(userId: number | null) {
return useQuery({ return useInfiniteQuery({
queryKey: userId ? qk.userMatches(userId) : ["userMatches", "none"], queryKey: userId != null ? qk.userMatches(userId) : ["userMatches", "none"],
enabled: userId != null, enabled: userId != null,
queryFn: async () => initialPageParam: 0,
queryFn: async ({ pageParam }) =>
unwrap( unwrap(
await api.GET("/api/users/{user_id}/matches", { await api.GET("/api/users/{user_id}/matches", {
params: { path: { user_id: userId as number } }, params: {
path: { user_id: userId as number },
query: { limit: HISTORY_PAGE, offset: pageParam as number },
},
}), }),
), ),
getNextPageParam: (last, pages) => {
const loaded = pages.reduce((n, p) => n + p.items.length, 0);
return loaded < last.total ? loaded : undefined;
},
}); });
} }
+15 -2
View File
@@ -32,7 +32,12 @@ export function AccountPage() {
const updateHistory = useUpdateHistoryPrefs(); const updateHistory = useUpdateHistoryPrefs();
const { data: factions } = useFactions(); const { data: factions } = useFactions();
const { data: expansions } = useExpansions(); const { data: expansions } = useExpansions();
const { data: history } = useUserMatches(me?.id ?? null); const {
data: history,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useUserMatches(me?.id ?? null);
const uploadAvatar = useUploadAvatar(); const uploadAvatar = useUploadAvatar();
const deleteAvatar = useDeleteAvatar(); const deleteAvatar = useDeleteAvatar();
const logout = useLogout(); const logout = useLogout();
@@ -124,6 +129,7 @@ export function AccountPage() {
<Avatar url={me.avatar_url} nickname={me.nickname} size={64} /> <Avatar url={me.avatar_url} nickname={me.nickname} size={64} />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<b style={{ fontSize: 18 }}>{me.nickname}</b> <b style={{ fontSize: 18 }}>{me.nickname}</b>
{stats && <div className="muted small">Партий: {stats.overall.games}</div>}
<div className="row" style={{ gap: 8, marginTop: 8 }}> <div className="row" style={{ gap: 8, marginTop: 8 }}>
<button <button
className="btn small" className="btn small"
@@ -238,7 +244,14 @@ export function AccountPage() {
</div> </div>
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
{history && ( {history && (
<MatchHistory items={history.items} userId={me.id} detail={history.detail} /> <MatchHistory
items={history.pages.flatMap((p) => p.items)}
userId={me.id}
detail={history.pages[0].detail}
hasMore={hasNextPage}
loadingMore={isFetchingNextPage}
onMore={() => fetchNextPage()}
/>
)} )}
</div> </div>
</div> </div>
+15 -5
View File
@@ -21,7 +21,12 @@ export function PublicProfilePage() {
const id = Number(userId); const id = Number(userId);
const { data, isLoading, isError } = usePublicProfile(id); const { data, isLoading, isError } = usePublicProfile(id);
const { data: me } = useMe(); const { data: me } = useMe();
const { data: history } = useUserMatches(Number.isFinite(id) ? id : null); const {
data: history,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useUserMatches(Number.isFinite(id) ? id : null);
const invite = useInviteMember(); const invite = useInviteMember();
const toast = useToast(); const toast = useToast();
const [pickOpen, setPickOpen] = useState(false); const [pickOpen, setPickOpen] = useState(false);
@@ -29,6 +34,7 @@ export function PublicProfilePage() {
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;
if (isError || !data) return <div className="muted">Профиль не найден.</div>; if (isError || !data) return <div className="muted">Профиль не найден.</div>;
const historyItems = history?.pages.flatMap((p) => p.items) ?? [];
const groups = me?.groups ?? []; const groups = me?.groups ?? [];
const isSelf = me?.id === data.user_id; const isSelf = me?.id === data.user_id;
@@ -49,6 +55,7 @@ export function PublicProfilePage() {
<Avatar url={data.avatar_url} nickname={data.nickname} size={64} /> <Avatar url={data.avatar_url} nickname={data.nickname} size={64} />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<b style={{ fontSize: 18 }}>{data.nickname}</b> <b style={{ fontSize: 18 }}>{data.nickname}</b>
<div className="muted small">Партий: {data.stats.overall.games}</div>
{data.bio ? ( {data.bio ? (
<div className="muted" style={{ marginTop: 6, whiteSpace: "pre-wrap" }}> <div className="muted" style={{ marginTop: 6, whiteSpace: "pre-wrap" }}>
{data.bio} {data.bio}
@@ -103,13 +110,16 @@ export function PublicProfilePage() {
<ProfileStatsCard stats={data.stats} title="Статистика" /> <ProfileStatsCard stats={data.stats} title="Статистика" />
{/* История в том виде, который выбрал владелец профиля; контролов здесь нет. */} {/* История в том виде, который выбрал владелец профиля; контролов здесь нет. */}
{history && history.items.length > 0 && ( {historyItems.length > 0 && (
<div className="card"> <div className="card">
<h3>{history.mode === "best" ? "Лучшая партия" : "История игр"}</h3> <h3>{history!.pages[0].mode === "best" ? "Лучшая партия" : "История игр"}</h3>
<MatchHistory <MatchHistory
items={history.items} items={historyItems}
userId={data.user_id} userId={data.user_id}
detail={history.detail} detail={history!.pages[0].detail}
hasMore={hasNextPage}
loadingMore={isFetchingNextPage}
onMore={() => fetchNextPage()}
/> />
</div> </div>
)} )}