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 { ClubSelector } from "./components/GameSelector";
|
||||||
import type { IEventInfo } from "./types";
|
import type { IEventInfo } from "./types";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { FloatButton, Typography } from "antd";
|
import { Typography } from "antd";
|
||||||
import { SearchOutlined } from "@ant-design/icons";
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@ -15,7 +14,6 @@ export function App() {
|
|||||||
<div className="app">
|
<div className="app">
|
||||||
<Typography.Title level={1}>开球网比赛分组预测</Typography.Title>
|
<Typography.Title level={1}>开球网比赛分组预测</Typography.Title>
|
||||||
<ClubSelector onGameClick={handleGameClick} />
|
<ClubSelector onGameClick={handleGameClick} />
|
||||||
<FloatButton icon={<SearchOutlined />} onClick={() => navigate('/find')} />
|
|
||||||
</div>
|
</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 EventPage from "./page/EventPage";
|
||||||
import type { MatchInfo, XCXMember } from "./types";
|
import type { MatchInfo, XCXMember } from "./types";
|
||||||
import { FindUserPage } from "./page/FindUserPage";
|
import { FindUserPage } from "./page/FindUserPage";
|
||||||
|
import { FavePlayersPage } from "./page/FavPlayersPage";
|
||||||
|
import { MenuButtons } from "./components/MenuButtons";
|
||||||
|
|
||||||
const elem = document.getElementById("root")!;
|
const elem = document.getElementById("root")!;
|
||||||
|
|
||||||
@ -26,9 +28,13 @@ const route = createBrowserRouter([
|
|||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
index: true,
|
index: true,
|
||||||
element: <App />,
|
Component: App,
|
||||||
HydrateFallback: () => <HydrateFallback />
|
HydrateFallback: () => <HydrateFallback />
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/fav-players',
|
||||||
|
Component: FavePlayersPage,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'event/:matchId',
|
path: 'event/:matchId',
|
||||||
loader: async ({ params }) => {
|
loader: async ({ params }) => {
|
||||||
@ -57,12 +63,12 @@ const route = createBrowserRouter([
|
|||||||
Component: ProfilePage,
|
Component: ProfilePage,
|
||||||
HydrateFallback: () => <HydrateFallback />
|
HydrateFallback: () => <HydrateFallback />
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'find',
|
path: 'find',
|
||||||
Component: FindUserPage,
|
Component: FindUserPage,
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function HydrateFallback() {
|
function HydrateFallback() {
|
||||||
@ -76,7 +82,10 @@ function HydrateFallback() {
|
|||||||
function Layout() {
|
function Layout() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const loading = navigation.state === 'loading';
|
const loading = navigation.state === 'loading';
|
||||||
return loading ? <HydrateFallback /> : <Outlet />
|
return loading ? <HydrateFallback /> : (<>
|
||||||
|
<Outlet />
|
||||||
|
<MenuButtons />
|
||||||
|
</>);
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = (
|
const app = (
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||||
|
|
||||||
<link rel="icon" type="image/svg+xml" href="./logo.jpg" />
|
<link rel="icon" type="image/svg+xml" href="./logo.jpg" />
|
||||||
<title>开球网比赛分组预测</title>
|
<title>我的开球网</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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 UserTags from "../components/Tags";
|
||||||
import { GameTable } from "../components/GameTable";
|
import { GameTable } from "../components/GameTable";
|
||||||
import { useTitle } from "ahooks";
|
import { useTitle } from "ahooks";
|
||||||
|
import { FavButton } from "../components/FavButton";
|
||||||
|
|
||||||
function Honor(props: { honors?: XCXProfile['honors'] }) {
|
function Honor(props: { honors?: XCXProfile['honors'] }) {
|
||||||
if (!props.honors?.length) return null;
|
if (!props.honors?.length) return null;
|
||||||
@ -96,7 +97,20 @@ export default function ProfilePage() {
|
|||||||
<FloatButton icon={<HomeOutlined />} onClick={() => navigate('/')} />
|
<FloatButton icon={<HomeOutlined />} onClick={() => navigate('/')} />
|
||||||
<Flex vertical align="center" style={{ padding: 24 }}>
|
<Flex vertical align="center" style={{ padding: 24 }}>
|
||||||
<Avatar src={profile?.realpic} size={128} />
|
<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?.realname}</Typography.Text>
|
||||||
<Typography.Text>积分:{profile?.score}</Typography.Text>
|
<Typography.Text>积分:{profile?.score}</Typography.Text>
|
||||||
<Typography.Text style={{ textAlign: 'center' }}>
|
<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