diff --git a/package-lock.json b/package-lock.json index c791f23..ec17337 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "onlinejudge3-fe", - "version": "3.6.1", + "version": "3.7.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "onlinejudge3-fe", - "version": "3.6.1", + "version": "3.7.0", "license": "MIT", "dependencies": { "animate.css": "^3.7.0", @@ -46,6 +46,7 @@ "react-player": "~2.13.0", "react-scroll": "^1.7.12", "react-syntax-highlighter": "^9.0.0", + "react-toastify": "^8.2.0", "react-tooltip": "^3.9.2", "short-number": "^1.0.6", "socket.io-client": "^2.4.0", @@ -8448,6 +8449,14 @@ "readable-stream": "^2.3.5" } }, + "node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmmirror.com/co/-/co-4.6.0.tgz", @@ -25227,6 +25236,18 @@ "react": "^16.14.0" } }, + "node_modules/react-toastify": { + "version": "8.2.0", + "resolved": "https://registry.npmmirror.com/react-toastify/-/react-toastify-8.2.0.tgz", + "integrity": "sha512-Pg2Ju7NngAamarFvLwqrFomJ57u/Ay6i6zfLurt/qPynWkAkOthu6vxfqYpJCyNhHRhR4hu7+bySSeWWJu6PAg==", + "dependencies": { + "clsx": "^1.1.1" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-tooltip": { "version": "3.11.6", "resolved": "https://registry.npmmirror.com/react-tooltip/-/react-tooltip-3.11.6.tgz", @@ -43184,6 +43205,11 @@ "readable-stream": "^2.3.5" } }, + "clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==" + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmmirror.com/co/-/co-4.6.0.tgz", @@ -57016,6 +57042,14 @@ "scheduler": "^0.19.1" } }, + "react-toastify": { + "version": "8.2.0", + "resolved": "https://registry.npmmirror.com/react-toastify/-/react-toastify-8.2.0.tgz", + "integrity": "sha512-Pg2Ju7NngAamarFvLwqrFomJ57u/Ay6i6zfLurt/qPynWkAkOthu6vxfqYpJCyNhHRhR4hu7+bySSeWWJu6PAg==", + "requires": { + "clsx": "^1.1.1" + } + }, "react-tooltip": { "version": "3.11.6", "resolved": "https://registry.npmmirror.com/react-tooltip/-/react-tooltip-3.11.6.tgz", diff --git a/package.json b/package.json index d8576b6..4d5447f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "onlinejudge3-fe", - "version": "3.6.4", + "version": "3.7.0", "description": "", "scripts": { "dev": "gulp dev", @@ -63,6 +63,7 @@ "react-player": "~2.13.0", "react-scroll": "^1.7.12", "react-syntax-highlighter": "^9.0.0", + "react-toastify": "^8.2.0", "react-tooltip": "^3.9.2", "short-number": "^1.0.6", "socket.io-client": "^2.4.0", diff --git a/public/assets/audio/achievement-receive.mp3 b/public/assets/audio/achievement-receive.mp3 new file mode 100644 index 0000000..66b3b34 Binary files /dev/null and b/public/assets/audio/achievement-receive.mp3 differ diff --git a/public/assets/audio/achievement-receive.wav b/public/assets/audio/achievement-receive.wav new file mode 100644 index 0000000..1e0fd88 Binary files /dev/null and b/public/assets/audio/achievement-receive.wav differ diff --git a/public/assets/audio/achievement-toast.mp3 b/public/assets/audio/achievement-toast.mp3 new file mode 100644 index 0000000..8a7b122 Binary files /dev/null and b/public/assets/audio/achievement-toast.mp3 differ diff --git a/public/assets/audio/achievement-toast.wav b/public/assets/audio/achievement-toast.wav new file mode 100644 index 0000000..064decd Binary files /dev/null and b/public/assets/audio/achievement-toast.wav differ diff --git a/src/assets/svg/achievement-trophy.svg b/src/assets/svg/achievement-trophy.svg new file mode 100644 index 0000000..7c3d7f8 --- /dev/null +++ b/src/assets/svg/achievement-trophy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/common b/src/common index 061bbbe..3a1ec92 160000 --- a/src/common +++ b/src/common @@ -1 +1 @@ -Subproject commit 061bbbec92d8e65d45feff884fe2d9b264f4dd9c +Subproject commit 3a1ec92573105a77c2940eb46969c5ad71a34446 diff --git a/src/components/AchievementToast.tsx b/src/components/AchievementToast.tsx new file mode 100644 index 0000000..ada9320 --- /dev/null +++ b/src/components/AchievementToast.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { toast } from 'react-toastify'; +import classNames from 'classnames'; +import { Icon } from 'antd'; +import { Howl } from 'howler'; +import { IAchievement } from '@/common/interfaces/achievement'; +import { EAchievementKey } from '@/common/configs/achievement.config'; +import { getAchievementByKey } from '@/utils/achievement'; +import AchievementTrophySvg from '@/assets/svg/achievement-trophy.svg'; +import { EAchievementLevel } from '@/common/enums'; + +export interface IAchievementToastProps { + achievement: IAchievement; +} + +class AchievementToast extends React.Component { + componentDidMount() { + const sound = new Howl({ + src: [`${process.env.PUBLIC_PATH}assets/audio/achievement-toast.mp3`], + }); + setTimeout(() => sound.play(), 200); + } + + render() { + const { achievement } = this.props; + + return ( +
+
+ +
+
+
{achievement.title}
+
+ {achievement.description} +
+
+
+ ); + } +} + +export default AchievementToast; + +export function showAchievementToast(achievementKey: EAchievementKey) { + const achievement = getAchievementByKey(achievementKey); + return toast(, { containerId: 'achievement' }); +} diff --git a/src/components/AchievementToastContainer.tsx b/src/components/AchievementToastContainer.tsx new file mode 100644 index 0000000..1d1a765 --- /dev/null +++ b/src/components/AchievementToastContainer.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { ToastContainer, Zoom } from 'react-toastify'; + +class AchievementToastContainer extends React.Component { + render() { + return ( + + ); + } +} + +export default AchievementToastContainer; diff --git a/src/components/AchievementsModal.tsx b/src/components/AchievementsModal.tsx new file mode 100644 index 0000000..f133d7f --- /dev/null +++ b/src/components/AchievementsModal.tsx @@ -0,0 +1,350 @@ +import React from 'react'; +import { connect } from 'dva'; +import { Modal, Icon, Button, Badge } from 'antd'; +import { ReduxProps, RouteProps } from '@/@types/props'; +import tracker from '@/utils/tracker'; +import { withRouter } from 'react-router'; +import { IAchievementCategory } from '@/common/interfaces/achievement'; +import achievementConfig from '@/common/configs/achievement-config.json'; +import moment from 'moment'; +import classNames from 'classnames'; +import { EAchievementLevel, EUserAchievementStatus } from '@/common/enums'; +import AchievementTrophySvg from '@/assets/svg/achievement-trophy.svg'; +import msg from '@/utils/msg'; +import { Howl } from 'howler'; +import { EAchievementKey } from '@/common/configs/achievement.config'; + +const achievementLevels = [ + EAchievementLevel.gold, + EAchievementLevel.silver, + EAchievementLevel.bronze, +]; + +export interface Props extends ReduxProps, RouteProps { + achievedAchievements: { + achievementKey: string; + status: EUserAchievementStatus; + createdAt: string; + }[]; + stats: Record< + EAchievementKey, + { + count: number; + rate: number; + } + >; + receiveLoading: boolean; + onClickShowModal?: React.MouseEventHandler; +} + +interface State { + visible: boolean; + selectedCategory: string; +} + +class AchievementsModal extends React.Component { + constructor(props) { + super(props); + this.state = { + visible: false, + selectedCategory: achievementConfig[0].categoryKey, + }; + } + + handleShowModel = (e) => { + if (e) { + e.stopPropagation(); + } + this.setState({ visible: true }); + this.props.onClickShowModal && this.props.onClickShowModal(e); + tracker.event({ + category: 'component.NavMenu', + action: 'showAchievements', + }); + }; + + handleHideModel = () => { + this.setState({ visible: false }); + }; + + handleSelectCategory = (category: IAchievementCategory) => { + this.setState({ selectedCategory: category.categoryKey }); + }; + + handleReceiveAchievement = (achievementKey: string) => { + const { dispatch, receiveLoading } = this.props; + if (receiveLoading) { + return; + } + dispatch({ + type: 'users/receiveAchievement', + payload: { + achievementKey, + }, + }).then((ret) => { + msg.auto(ret); + if (ret.success) { + dispatch({ + type: 'users/getSelfAchievedAchievements', + }); + dispatch({ + type: 'achievements/getStats', + }); + tracker.event({ + category: 'users', + action: 'receiveAchievement', + }); + const sound = new Howl({ + src: [`${process.env.PUBLIC_PATH}assets/audio/achievement-receive.mp3`], + }); + sound.play(); + } + }); + }; + + renderRate(achievementKey: string) { + const { stats } = this.props; + const { count, rate } = stats[achievementKey]; + if (rate === 0 && count === 0) { + return Nobody has achieved; + } + if (rate < 0.05 && count > 0) { + return {count} {count === 1 ? 'user has' : `users have`} achieved; + } + return {(rate * 100).toFixed(2)}% users have achieved; + } + + render() { + const { children, achievedAchievements } = this.props; + const { selectedCategory } = this.state; + const currentCategoryAchievements = ( + achievementConfig.find((c) => c.categoryKey === selectedCategory)?.achievements || [] + ).map((a) => ({ + ...a, + status: achievedAchievements.find((ca) => ca.achievementKey === a.achievementKey)?.status, + })); + const currentCategoryAchievedAchievements = currentCategoryAchievements.filter( + (a) => a.status !== undefined, + ); + const currentCategoryUnreadAchievements = currentCategoryAchievedAchievements.filter( + (a) => a.status !== EUserAchievementStatus.received, + ); + const currentCategoryReadAchievements = currentCategoryAchievedAchievements.filter( + (a) => a.status === EUserAchievementStatus.received, + ); + const currentCategoryUnachievedAchievements = currentCategoryAchievements.filter( + (a) => + !currentCategoryAchievedAchievements.some((ca) => ca.achievementKey === a.achievementKey), + ); + const currentCategoryUnachievedVisibleAchievements = currentCategoryUnachievedAchievements.filter( + (a) => !a.hidden, + ); + const currentCategoryUnachievedHiddenAchievements = currentCategoryUnachievedAchievements.filter( + (a) => a.hidden, + ); + const shownAchievements = [ + ...currentCategoryUnreadAchievements, + ...currentCategoryReadAchievements, + ...currentCategoryUnachievedVisibleAchievements, + ...currentCategoryUnachievedHiddenAchievements, + ]; + const allAchievedAchievements = achievementConfig + .map((c) => c.achievements) + .reduce((prev, curr) => prev.concat(curr), []) + .filter((a) => achievedAchievements.some((ca) => a.achievementKey === ca.achievementKey)); + const achievedCountPerLevel = achievementLevels.map( + (level) => allAchievedAchievements.filter((a) => a.level === level).length, + ); + + return ( + <> + {children} + +
+
+
+
    + {achievementConfig.map((category) => { + const achievedAchievementsInCategory = category.achievements + .filter((achievement) => + this.props.achievedAchievements.find( + (ca) => ca.achievementKey === achievement.achievementKey, + ), + ) + .map((achievement) => { + const a = this.props.achievedAchievements.find( + (ca) => ca.achievementKey === achievement.achievementKey, + ); + return { + ...achievement, + status: a.status, + }; + }); + const hasUnread = achievedAchievementsInCategory.some( + (ca) => ca.status !== EUserAchievementStatus.received, + ); + + return ( +
  • this.handleSelectCategory(category)} + > + + {category.title} + {hasUnread && } + + + {achievedAchievementsInCategory.length}/{category.achievements.length} + +
  • + ); + })} +
+
+
+
    + {shownAchievements.map((achievement) => { + const achievedAchievement = achievedAchievements.find( + (ca) => ca.achievementKey === achievement.achievementKey, + ); + const shouldHide = achievement.hidden && !achievedAchievement; + const unread = + achievedAchievement && + achievedAchievement.status !== EUserAchievementStatus.received; + return ( +
  • +
    + {!shouldHide && ( + + )} +
    +
    +
    +
    +
    + {shouldHide ? '???' : achievement.title} +
    +
    + {shouldHide ? '' : achievement.description} +
    +
    + {shouldHide ? '' : achievement.annotation} +
    +
    +
    + {achievedAchievement ? ( + unread ? ( +
    + +
    + ) : ( +
    +
    + Achieved +
    +
    + {moment(achievedAchievement.createdAt).format('YYYY-MM-DD')} +
    +
    + ) + ) : ( +
    + Unachieved +
    + )} +
    +
    +
    + {this.renderRate(achievement.achievementKey)} +
    +
    +
  • + ); + })} +
+
+
+
+
+ + Total Achieved{' '} + + {allAchievedAchievements.length} + + +
+
+
+ {achievementLevels.map((level, index) => ( +
+
+ +
+
+ {achievedCountPerLevel[index]} +
+
+ ))} +
+
+
+ + + ); + } +} + +function mapStateToProps(state) { + return { + achievedAchievements: state.users.achievedAchievements, + stats: state.achievements.stats, + receiveLoading: !!state.loading.effects['users/receiveAchievement'], + }; +} + +export default connect(mapStateToProps)(withRouter(AchievementsModal)); diff --git a/src/components/SubmitSolutionModal.tsx b/src/components/SubmitSolutionModal.tsx index 06ed427..59cf505 100644 --- a/src/components/SubmitSolutionModal.tsx +++ b/src/components/SubmitSolutionModal.tsx @@ -17,6 +17,7 @@ import Results from '@/configs/results/resultsEnum'; import { IProblemSpConfig } from '@/common/interfaces/problem'; import AutoVideoScreen from '@/components/AutoVideoScreen'; import { getSocket } from '@/utils/socket'; +import localStorage from '@/utils/localStorage'; export interface Props extends ReduxProps, FormProps { problemId: number; @@ -261,6 +262,7 @@ class SubmitSolutionModal extends React.Component { action: 'submit', label: values.language, }); + localStorage.set('defaultLanguage', values.language); const solutionId = ret.data.solutionId; if (this.useSpSecondaryConfirm) { this.setState({ @@ -370,7 +372,7 @@ class SubmitSolutionModal extends React.Component { {getFieldDecorator('language', { rules: [{ required: true, message: 'Please select language' }], - initialValue: 'C++', // TODO 判断用户默认语言设置 + initialValue: localStorage.get('defaultLanguage') || 'C++', })(