feat(fav-players): optimize un-fav API response and refactor player list UI

- Modify the DELETE /api/fav endpoint to include the player uid in the response JSON.
- Refactor FavPlayersPage to remove explicit dependency on `aud` for API calls, relying solely on `useAuthHeaders`.
- Add a dedicated "un-fav" button to player cards to allow users to unfollow individual players.
- Implement logic to distinguish between local un-fav (for unauthenticated users) and server-side un-fav (for authenticated users).
- Improve UI layout using `styled-components` and updated Ant Design components (`Typography.Title`).
- Add a link to sign in and view cloud favorites when the local list is empty.
This commit is contained in:
kyuuseiryuu 2026-03-16 19:33:44 +09:00
parent 2d928ab1e3
commit 86c3b6651b
2 changed files with 43 additions and 21 deletions

View File

@ -172,7 +172,7 @@ const server = serve({
async DELETE(req) { async DELETE(req) {
const payload = await verifyLogtoToken(req.headers); const payload = await verifyLogtoToken(req.headers);
await unFavPlayer(`${payload.sub}`, req.params.uid); await unFavPlayer(`${payload.sub}`, req.params.uid);
return Response.json({ ok: 'ok' }); return Response.json({ ok: 'ok' , uid: req.params.uid });
} }
}, },
'/api/battle/:eventId': { '/api/battle/:eventId': {

View File

@ -1,13 +1,14 @@
import { Avatar, Button, Card, Divider, Flex, Image, message as AntdMessage, Popconfirm, Radio, Segmented, Spin, Typography, App } from "antd"; import { Avatar, Button, Card, Divider, Flex, Image, message as AntdMessage, Popconfirm, Radio, Segmented, Spin, Typography, App } from "antd";
import { useFavPlayerStore } from "../store/useFavPlayerStore"; import { useFavPlayerStore, type FavPlayer } from "../store/useFavPlayerStore";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useLogto, type IdTokenClaims } from "@logto/react"; import { useLogto, type IdTokenClaims } from "@logto/react";
import { DeleteOutlined, LoginOutlined, SyncOutlined, UploadOutlined } from "@ant-design/icons"; import { DeleteOutlined, LoginOutlined, ShopTwoTone, StarFilled, SyncOutlined, UploadOutlined } from "@ant-design/icons";
import { useRequest } from "ahooks"; import { useRequest } from "ahooks";
import type { XCXProfile } from "../types"; import type { XCXProfile } from "../types";
import useAutoLogin from "../hooks/useAutoLogin"; import useAutoLogin from "../hooks/useAutoLogin";
import { useAuthHeaders } from "../hooks/useAuthHeaders"; import { useAuthHeaders } from "../hooks/useAuthHeaders";
import styled from "styled-components";
enum SortType { enum SortType {
DEFAULT = '注册时间', DEFAULT = '注册时间',
@ -19,15 +20,22 @@ enum ShowType {
ACCOUNT = '账号收藏', ACCOUNT = '账号收藏',
} }
const StyledContainer = styled.div`
.player-name {
margin: 0;
}
.unfav-btn {
height: 100%;
}
`;
export function FavePlayersPage() { export function FavePlayersPage() {
const { favMap, unFav } = useFavPlayerStore(state => state); const { favMap, unFav } = useFavPlayerStore(state => state);
const [sortType, setSortType] = useState<SortType>(SortType.DEFAULT); const [sortType, setSortType] = useState<SortType>(SortType.DEFAULT);
const [showType, setShowType] = useState(ShowType.LOCAL); const [showType, setShowType] = useState(ShowType.LOCAL);
const [claims, setClaims] = useState<IdTokenClaims>();
const headers = useAuthHeaders(); const headers = useAuthHeaders();
const { autoSignIn } = useAutoLogin(); const { autoSignIn } = useAutoLogin();
const localList = Object.values(favMap); const localList = Object.values(favMap);
const favListRequest = useRequest<XCXProfile[], [string]>(async (aud: string) => { const favListRequest = useRequest<XCXProfile[], []>(async () => {
const res = await fetch(`/api/fav`, { headers }); const res = await fetch(`/api/fav`, { headers });
const data = await res.json(); const data = await res.json();
return data; return data;
@ -52,29 +60,34 @@ export function FavePlayersPage() {
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return; if (!isAuthenticated) return;
const id = setTimeout(async () => { const id = setTimeout(async () => {
const claims = await getIdTokenClaims(); favListRequest.runAsync();
setClaims(claims);
favListRequest.runAsync(claims?.aud!);
}, 300); }, 300);
return () => clearTimeout(id); return () => clearTimeout(id);
}, [isAuthenticated, getIdTokenClaims]); }, [isAuthenticated, getIdTokenClaims]);
const handleSyncFav = useCallback(async () => { const handleSyncFav = useCallback(async () => {
if (!isAuthenticated) return; if (!isAuthenticated) return;
const claims = await getIdTokenClaims()!;
const aud = claims?.aud;
const jobs = list.map(async u => { const jobs = list.map(async u => {
await fetch(`/api/fav/${u.uid}`, { method: 'PUT', headers }); await fetch(`/api/fav/${u.uid}`, { method: 'PUT', headers });
}); });
message.open({ key: 'sync', content: '同步中...', type: 'loading' }); message.open({ key: 'sync', content: '同步中...', type: 'loading' });
await Promise.all(jobs); await Promise.all(jobs);
await favListRequest.runAsync(aud!); await favListRequest.runAsync();
message.open({ key: 'sync', content: '已同步', type: 'success' }); message.open({ key: 'sync', content: '已同步', type: 'success' });
}, [isAuthenticated, list]); }, [isAuthenticated, list]);
const handleClearLocal = useCallback(() => { const handleClearLocal = useCallback(() => {
list.forEach(e => unFav(e.uid)); list.forEach(e => unFav(e.uid));
}, []); }, []);
const handleUnFav = useCallback(async (e: FavPlayer) => {
if (showType === ShowType.LOCAL) {
unFav(e.uid);
}
if (showType === ShowType.ACCOUNT) {
await fetch(`/api/fav/${e.uid}`, { method: 'DELETE', headers });
await favListRequest.runAsync();
}
}, [showType, favListRequest, headers]);
return ( return (
<div className="app"> <StyledContainer className="app">
<Flex vertical gap={48}> <Flex vertical gap={48}>
<Typography.Title></Typography.Title> <Typography.Title></Typography.Title>
<Flex justify="center" align="center" gap={12} style={{ display: isAuthenticated ? '' : 'none' }}> <Flex justify="center" align="center" gap={12} style={{ display: isAuthenticated ? '' : 'none' }}>
@ -88,7 +101,7 @@ export function FavePlayersPage() {
]} ]}
/> />
<Button <Button
onClick={async () => favListRequest.runAsync(claims?.aud!)} onClick={async () => favListRequest.runAsync()}
icon={<SyncOutlined spin={favListRequest.loading} />} icon={<SyncOutlined spin={favListRequest.loading} />}
/> />
</Flex> </Flex>
@ -107,21 +120,25 @@ export function FavePlayersPage() {
{!favListRequest.loading && list.length === 0 ? ( {!favListRequest.loading && list.length === 0 ? (
<> <>
<Divider></Divider> <Divider></Divider>
{showType === ShowType.LOCAL && (
<Button type='link' onClick={() => autoSignIn()}></Button>
)}
</> </>
) : ( ) : (
<> <>
<Flex wrap gap={16} align="center" justify="center"> <Flex wrap gap={16} align="center" justify="center">
{list.map(e => ( {list.map(e => (
<Card <Card
hoverable
size="small" size="small"
key={e.uid} key={e.uid}
style={{ width: 240 }} style={{ minWidth: 280 }}
> >
<Flex gap={12}> <Flex gap={12}>
{e.avatar?.includes('noavatar') <Flex vertical align="center" justify="center">
? <Avatar size={64} shape="square">{e.name}</Avatar> {e.avatar?.includes('noavatar')
: <Image width={64} height={64} src={e.avatar} style={{ objectFit: 'cover' }} />} ? <Avatar size={64} shape="square">{e.name}</Avatar>
: <Image width={64} height={64} src={e.avatar} style={{ objectFit: 'cover' }} />}
</Flex>
<Flex <Flex
vertical vertical
justify="center" justify="center"
@ -129,9 +146,14 @@ export function FavePlayersPage() {
style={{ flex: 1 }} style={{ flex: 1 }}
onClick={() => navigate(`/profile/${e.uid}`)} onClick={() => navigate(`/profile/${e.uid}`)}
> >
<h2 style={{ margin: 0 }}>{e.name}</h2> <Typography.Title level={3} className="player-name">{e.name}</Typography.Title>
<Typography.Text type="secondary">{e.realname}</Typography.Text> <Typography.Text type="secondary">{e.realname}</Typography.Text>
</Flex> </Flex>
<Flex align="center" justify="center">
<Button className="unfav-btn" type="link" onClick={() => handleUnFav(e)}>
</Button>
</Flex>
</Flex> </Flex>
</Card> </Card>
))} ))}
@ -162,6 +184,6 @@ export function FavePlayersPage() {
)} )}
</Spin> </Spin>
</Flex> </Flex>
</div> </StyledContainer>
); );
} }