История игр в профиле обрезается на 20 партиях без подсказки (#30) #42
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user