feat: add favorite player functionality and improve navigation
- components: add FavButton for player favoriting - store: create useFavPlayerStore for managing favorites - pages: add FavePlayersPage for displaying favorites - components: add MenuButtons for navigation controls - ui: update App and ProfilePage for new features - meta: update index.html title to "我的开球网"
This commit is contained in:
parent
789bbb6e03
commit
3b87230173
@ -2,8 +2,7 @@ import { useCallback } from "react";
|
||||
import { ClubSelector } from "./components/GameSelector";
|
||||
import type { IEventInfo } from "./types";
|
||||
import { useNavigate } from "react-router";
|
||||
import { FloatButton, Typography } from "antd";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { Typography } from "antd";
|
||||
import "./index.css";
|
||||
|
||||
export function App() {
|
||||
@ -15,7 +14,6 @@ export function App() {
|
||||
<div className="app">
|
||||
<Typography.Title level={1}>开球网比赛分组预测</Typography.Title>
|
||||
<ClubSelector onGameClick={handleGameClick} />
|
||||
<FloatButton icon={<SearchOutlined />} onClick={() => navigate('/find')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
38
src/components/FavButton.tsx
Normal file
38
src/components/FavButton.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { Rate } from "antd";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useFavPlayerStore, type FavPlayer } from "../store/useFavPlayerStore";
|
||||
import styled from "styled-components";
|
||||
|
||||
interface Props {
|
||||
user?: FavPlayer;
|
||||
}
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.ant-rate {
|
||||
margin: 0;
|
||||
.ant-rate-star {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function FavButton(props: Props) {
|
||||
const favStore = useFavPlayerStore(state => state);
|
||||
const [value, setValue] = useState(favStore.isFav(props.user?.uid) ? 1 : 0);
|
||||
const handleFavClick = useCallback((value: number) => {
|
||||
if (!props.user) return;
|
||||
setValue(value);
|
||||
if (value) {
|
||||
favStore.fav(props.user);
|
||||
} else {
|
||||
favStore.unFav(props.user.uid);
|
||||
}
|
||||
}, [favStore]);
|
||||
return (
|
||||
<StyledContainer>
|
||||
<Rate allowClear count={1} value={value} onChange={handleFavClick} />
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
15
src/components/MenuButtons.tsx
Normal file
15
src/components/MenuButtons.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { ArrowLeftOutlined, HeartOutlined, MenuOutlined, ScheduleOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { FloatButton } from "antd";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export function MenuButtons() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<FloatButton.Group trigger="click" icon={<MenuOutlined />}>
|
||||
<FloatButton icon={<ScheduleOutlined />} onClick={() => navigate('/')} />
|
||||
<FloatButton icon={<HeartOutlined />} onClick={() => navigate('/fav-players')} />
|
||||
<FloatButton icon={<SearchOutlined />} onClick={() => navigate('/find')} />
|
||||
<FloatButton icon={<ArrowLeftOutlined />} onClick={() => navigate(-1)} />
|
||||
</FloatButton.Group>
|
||||
);
|
||||
}
|
||||
@ -15,6 +15,8 @@ import ProfilePage from "./page/ProfilePage";
|
||||
import EventPage from "./page/EventPage";
|
||||
import type { MatchInfo, XCXMember } from "./types";
|
||||
import { FindUserPage } from "./page/FindUserPage";
|
||||
import { FavePlayersPage } from "./page/FavPlayersPage";
|
||||
import { MenuButtons } from "./components/MenuButtons";
|
||||
|
||||
const elem = document.getElementById("root")!;
|
||||
|
||||
@ -26,9 +28,13 @@ const route = createBrowserRouter([
|
||||
{
|
||||
path: '',
|
||||
index: true,
|
||||
element: <App />,
|
||||
Component: App,
|
||||
HydrateFallback: () => <HydrateFallback />
|
||||
},
|
||||
{
|
||||
path: '/fav-players',
|
||||
Component: FavePlayersPage,
|
||||
},
|
||||
{
|
||||
path: 'event/:matchId',
|
||||
loader: async ({ params }) => {
|
||||
@ -57,12 +63,12 @@ const route = createBrowserRouter([
|
||||
Component: ProfilePage,
|
||||
HydrateFallback: () => <HydrateFallback />
|
||||
},
|
||||
{
|
||||
path: 'find',
|
||||
Component: FindUserPage,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'find',
|
||||
Component: FindUserPage,
|
||||
}
|
||||
]);
|
||||
|
||||
function HydrateFallback() {
|
||||
@ -76,7 +82,10 @@ function HydrateFallback() {
|
||||
function Layout() {
|
||||
const navigation = useNavigation();
|
||||
const loading = navigation.state === 'loading';
|
||||
return loading ? <HydrateFallback /> : <Outlet />
|
||||
return loading ? <HydrateFallback /> : (<>
|
||||
<Outlet />
|
||||
<MenuButtons />
|
||||
</>);
|
||||
}
|
||||
|
||||
const app = (
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="./logo.jpg" />
|
||||
<title>开球网比赛分组预测</title>
|
||||
<title>我的开球网</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
72
src/page/FavPlayersPage.tsx
Normal file
72
src/page/FavPlayersPage.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { Avatar, Card, Divider, Flex, Image, Segmented, Space, Typography } from "antd";
|
||||
import { useFavPlayerStore } from "../store/useFavPlayerStore";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
enum SortType {
|
||||
DEFAULT = '默认排序',
|
||||
SCORE_UP = '积分升序',
|
||||
SCORE_DOWN = '积分降序',
|
||||
}
|
||||
|
||||
export function FavePlayersPage() {
|
||||
const { favMap } = useFavPlayerStore(state => state);
|
||||
const [sortType, setSortType] = useState<SortType>(SortType.SCORE_DOWN);
|
||||
const list = useMemo(() => {
|
||||
const l = Object.values(favMap);
|
||||
switch (sortType) {
|
||||
case SortType.DEFAULT: return l;
|
||||
case SortType.SCORE_UP: return l.sort(({ score: a }, { score: b }) => +a - +b);
|
||||
case SortType.SCORE_DOWN: return l.sort(({ score: b }, { score: a }) => +a - +b);
|
||||
default: return l;
|
||||
}
|
||||
}, [favMap, sortType]);
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="app">
|
||||
<Flex vertical gap={48}>
|
||||
<Typography.Title>收藏的球员</Typography.Title>
|
||||
<div
|
||||
style={{ position: 'sticky', top: 12, zIndex: 1 }}
|
||||
>
|
||||
<Segmented
|
||||
value={sortType}
|
||||
onChange={e => setSortType(e)}
|
||||
options={[
|
||||
SortType.SCORE_DOWN,
|
||||
SortType.SCORE_UP,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{list.length === 0 ? (
|
||||
<Divider>暂无收藏的球员</Divider>
|
||||
) : (
|
||||
<Flex wrap gap={16} align="center" justify="center">
|
||||
{list.map(e => (
|
||||
<Card
|
||||
hoverable
|
||||
size="small"
|
||||
key={e.uid}
|
||||
style={{ width: 240 }}
|
||||
>
|
||||
<Flex gap={12}>
|
||||
{e.avatar?.includes('noavatar') ? <Avatar size={64} shape="square">{e.name}</Avatar> : <Image width={64} src={e.avatar} />}
|
||||
<Flex
|
||||
vertical
|
||||
justify="center"
|
||||
align="center"
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => navigate(`/profile/${e.uid}`)}
|
||||
>
|
||||
<h2 style={{ margin: 0 }}>{e.name}</h2>
|
||||
<Typography.Text type="secondary">{e.realname}</Typography.Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,7 @@ import { ChangeBackground } from "../components/ChangeBackground";
|
||||
import UserTags from "../components/Tags";
|
||||
import { GameTable } from "../components/GameTable";
|
||||
import { useTitle } from "ahooks";
|
||||
import { FavButton } from "../components/FavButton";
|
||||
|
||||
function Honor(props: { honors?: XCXProfile['honors'] }) {
|
||||
if (!props.honors?.length) return null;
|
||||
@ -96,7 +97,20 @@ export default function ProfilePage() {
|
||||
<FloatButton icon={<HomeOutlined />} onClick={() => navigate('/')} />
|
||||
<Flex vertical align="center" style={{ padding: 24 }}>
|
||||
<Avatar src={profile?.realpic} size={128} />
|
||||
<Typography.Title level={2}>{profile?.username}</Typography.Title>
|
||||
<Typography.Title level={2}>
|
||||
<Flex justify="center">
|
||||
<span>{profile?.username}</span>
|
||||
<FavButton
|
||||
user={{
|
||||
uid,
|
||||
score: profile.score,
|
||||
name: profile.username,
|
||||
realname: profile.realname,
|
||||
avatar: profile.realpic
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Typography.Title>
|
||||
<Typography.Text>姓名:{profile?.realname}</Typography.Text>
|
||||
<Typography.Text>积分:{profile?.score}</Typography.Text>
|
||||
<Typography.Text style={{ textAlign: 'center' }}>
|
||||
|
||||
33
src/store/useFavPlayerStore.ts
Normal file
33
src/store/useFavPlayerStore.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { BasePlayer } from '../types';
|
||||
|
||||
export interface FavPlayer extends BasePlayer {
|
||||
realname?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface FavPlayerStore {
|
||||
favMap: Record<string, FavPlayer>;
|
||||
fav(player: FavPlayer): void;
|
||||
unFav(uid: string): void;
|
||||
isFav(uid?: string): boolean;
|
||||
}
|
||||
|
||||
export const useFavPlayerStore = create(persist<FavPlayerStore>((set, get) => ({
|
||||
favMap: {},
|
||||
fav: user => {
|
||||
const { favMap } = get();
|
||||
favMap[user.uid] = user;
|
||||
set({ favMap: { ...favMap } });
|
||||
},
|
||||
unFav: uid => {
|
||||
const { favMap } = get();
|
||||
delete favMap[uid];
|
||||
set({ favMap: { ...favMap } });
|
||||
},
|
||||
isFav(uid) {
|
||||
if (!uid) return false;
|
||||
return !!get().favMap[uid];
|
||||
},
|
||||
}), { name: 'fav-player-store', storage: createJSONStorage(() => localStorage) }));
|
||||
Loading…
Reference in New Issue
Block a user