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