├── .npmrc ├── .vscode └── settings.json ├── public ├── robots.txt ├── favicon.ico ├── books │ ├── BEC_2.webp │ ├── BEC_3.webp │ ├── GRE_2.webp │ ├── GRE_3.webp │ ├── SAT_2.webp │ ├── SAT_3.webp │ ├── CET4_1.webp │ ├── CET4_2.webp │ ├── CET4_3.webp │ ├── CET6_1.webp │ ├── CET6_2.webp │ ├── CET6_3.webp │ ├── GMAT_2.webp │ ├── GMAT_3.webp │ ├── IELTS_2.webp │ ├── IELTS_3.webp │ ├── TOEFL_2.webp │ ├── TOEFL_3.webp │ ├── CET4luan_1.webp │ ├── CET4luan_2.webp │ ├── CET6luan_1.webp │ ├── ChuZhong_2.webp │ ├── ChuZhong_3.webp │ ├── GMATluan_2.webp │ ├── GaoZhong_2.webp │ ├── GaoZhong_3.webp │ ├── KaoYan_1.webp │ ├── KaoYan_2.webp │ ├── KaoYan_3.webp │ ├── Level4_1.webp │ ├── Level4_2.webp │ ├── Level8_1.webp │ ├── Level8_2.webp │ ├── IELTSluan_2.webp │ ├── KaoYanluan_1.webp │ ├── Level4luan_1.webp │ ├── Level4luan_2.webp │ ├── Level8luan_2.webp │ ├── ChuZhongluan_2.webp │ ├── GaoZhongluan_2.webp │ ├── PEPChuZhong7_1.webp │ ├── PEPChuZhong7_2.webp │ ├── PEPChuZhong8_1.webp │ ├── PEPChuZhong8_2.webp │ ├── PEPChuZhong9_1.webp │ ├── PEPGaoZhong_1.webp │ ├── PEPGaoZhong_10.webp │ ├── PEPGaoZhong_11.webp │ ├── PEPGaoZhong_2.webp │ ├── PEPGaoZhong_3.webp │ ├── PEPGaoZhong_4.webp │ ├── PEPGaoZhong_5.webp │ ├── PEPGaoZhong_6.webp │ ├── PEPGaoZhong_7.webp │ ├── PEPGaoZhong_8.webp │ ├── PEPGaoZhong_9.webp │ ├── PEPXiaoXue3_1.webp │ ├── PEPXiaoXue3_2.webp │ ├── PEPXiaoXue4_1.webp │ ├── PEPXiaoXue4_2.webp │ ├── PEPXiaoXue5_1.webp │ ├── PEPXiaoXue5_2.webp │ ├── PEPXiaoXue6_1.webp │ ├── PEPXiaoXue6_2.webp │ ├── BeiShiGaoZhong_1.webp │ ├── BeiShiGaoZhong_10.webp │ ├── BeiShiGaoZhong_11.webp │ ├── BeiShiGaoZhong_2.webp │ ├── BeiShiGaoZhong_3.webp │ ├── BeiShiGaoZhong_4.webp │ ├── BeiShiGaoZhong_5.webp │ ├── BeiShiGaoZhong_6.webp │ ├── BeiShiGaoZhong_7.webp │ ├── BeiShiGaoZhong_8.webp │ ├── BeiShiGaoZhong_9.webp │ ├── WaiYanSheChuZhong_1.webp │ ├── WaiYanSheChuZhong_2.webp │ ├── WaiYanSheChuZhong_3.webp │ ├── WaiYanSheChuZhong_4.webp │ ├── WaiYanSheChuZhong_5.webp │ └── WaiYanSheChuZhong_6.webp └── svgs │ ├── github_dark.svg │ └── github_light.svg ├── app ├── styles │ ├── hero.ts │ └── global.css ├── routes.ts ├── components │ ├── FormFieldError.tsx │ ├── LuIcon.tsx │ ├── SkeletonBox.tsx │ ├── SignInButton.tsx │ ├── ProgressBar.tsx │ ├── SettingButton.tsx │ ├── GithubIconButton.tsx │ ├── SearchButton.tsx │ ├── GithubButton.tsx │ ├── CloseMenuButton.tsx │ ├── OpenMenuButton.tsx │ ├── BooksPanel.tsx │ ├── CloseSearchBarButton.tsx │ ├── CloseWordDetailDrawerButton.tsx │ ├── UserAvatar.tsx │ ├── GlobalComponents.tsx │ ├── PasswordInput.tsx │ ├── SearchBar.tsx │ ├── DoneWordButton.tsx │ ├── UnDoneWordButton.tsx │ ├── LinkWord.tsx │ ├── ListTabs.tsx │ ├── WordPhrases.tsx │ ├── WordSentences.tsx │ ├── WordCommentItem.tsx │ ├── WordAudioButton.tsx │ ├── ProfileModal.tsx │ ├── WordCognates.tsx │ ├── WordSynonyms.tsx │ ├── WordTranslations.tsx │ ├── SendVerifyCodeButton.tsx │ ├── SettingModal.tsx │ ├── CommentVoteButton.tsx │ ├── SignOutButton.tsx │ ├── AppHeader.tsx │ ├── WordCommentsList.tsx │ ├── WordCommentForm.tsx │ ├── WordListIem.tsx │ ├── AppLayout.tsx │ ├── StudyCalendar.tsx │ ├── BookPanelItem.tsx │ ├── SearchWordsList.tsx │ ├── WordDetailPanel.tsx │ ├── UpdatePasswordModal.tsx │ ├── SignInModal.tsx │ ├── BookWordsList.tsx │ └── SignUpModal.tsx ├── hooks │ ├── useMobile.ts │ ├── useAppTheme.ts │ ├── useDebounceSearchWord.ts │ ├── useMyUserInfo.ts │ └── useZodForm.ts ├── .server │ ├── router │ │ ├── loader │ │ │ ├── getMyUserInfo.ts │ │ │ ├── getWordPhrases.ts │ │ │ ├── getWordCognates.ts │ │ │ ├── getWordSynonyms.ts │ │ │ ├── getAllBooks.ts │ │ │ ├── getBookDetail.ts │ │ │ ├── getWordSentences.ts │ │ │ ├── getStarBooks.ts │ │ │ ├── getWordDetail.ts │ │ │ ├── getWordTranslations.ts │ │ │ ├── getPostVote.ts │ │ │ ├── getIsWordDone.ts │ │ │ ├── getIsPostVote.ts │ │ │ ├── getStudyCalendar.ts │ │ │ ├── getWordsOfBook.ts │ │ │ ├── getWordComments.ts │ │ │ ├── getWordsOfKeyword.ts │ │ │ ├── getDoneWordsOfBook.ts │ │ │ └── getUnDoneWordsOfBook.ts │ │ ├── action │ │ │ ├── signOut.ts │ │ │ ├── doneWord.ts │ │ │ ├── starBook.ts │ │ │ ├── votePost.ts │ │ │ ├── sendComment.ts │ │ │ ├── unDoneWord.ts │ │ │ ├── unStarBook.ts │ │ │ ├── unVotePost.ts │ │ │ ├── updatePassword.ts │ │ │ ├── sendVerifyCode.ts │ │ │ ├── signIn.ts │ │ │ └── signUp.ts │ │ └── index.ts │ ├── common │ │ ├── crypto.ts │ │ ├── mail.ts │ │ ├── auth.ts │ │ ├── cookies.ts │ │ └── trpc.ts │ └── db │ │ ├── index.ts │ │ ├── config.ts │ │ ├── task.ts │ │ └── schema.ts ├── routes │ ├── _index.tsx │ ├── $.tsx │ ├── trpc.$trpc.ts │ └── $bookSlug.words.tsx ├── common │ ├── constants.ts │ ├── queryClient.ts │ ├── store.ts │ ├── types.ts │ ├── trpc.ts │ └── formSchema.ts ├── entry.server.tsx └── root.tsx ├── .gitignore ├── Dockerfile ├── env.d.ts ├── deploy.sh ├── .env ├── react-router.config.ts ├── tsconfig.json ├── vite.config.ts ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=*@heroui/* -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # Allow all crawlers 2 | User-agent: * 3 | Allow: / -------------------------------------------------------------------------------- /app/styles/hero.ts: -------------------------------------------------------------------------------- 1 | import { heroui } from "@heroui/react"; 2 | export default heroui(); 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/books/BEC_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/BEC_2.webp -------------------------------------------------------------------------------- /public/books/BEC_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/BEC_3.webp -------------------------------------------------------------------------------- /public/books/GRE_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/GRE_2.webp -------------------------------------------------------------------------------- /public/books/GRE_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/GRE_3.webp -------------------------------------------------------------------------------- /public/books/SAT_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/SAT_2.webp -------------------------------------------------------------------------------- /public/books/SAT_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/SAT_3.webp -------------------------------------------------------------------------------- /public/books/CET4_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/CET4_1.webp -------------------------------------------------------------------------------- /public/books/CET4_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/CET4_2.webp -------------------------------------------------------------------------------- /public/books/CET4_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/CET4_3.webp -------------------------------------------------------------------------------- /public/books/CET6_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/CET6_1.webp -------------------------------------------------------------------------------- /public/books/CET6_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/CET6_2.webp -------------------------------------------------------------------------------- /public/books/CET6_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/CET6_3.webp -------------------------------------------------------------------------------- /public/books/GMAT_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/GMAT_2.webp -------------------------------------------------------------------------------- /public/books/GMAT_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/GMAT_3.webp -------------------------------------------------------------------------------- /public/books/IELTS_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/IELTS_2.webp -------------------------------------------------------------------------------- /public/books/IELTS_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/IELTS_3.webp -------------------------------------------------------------------------------- /public/books/TOEFL_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/TOEFL_2.webp -------------------------------------------------------------------------------- /public/books/TOEFL_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/TOEFL_3.webp -------------------------------------------------------------------------------- /public/books/CET4luan_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/CET4luan_1.webp -------------------------------------------------------------------------------- /public/books/CET4luan_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/CET4luan_2.webp -------------------------------------------------------------------------------- /public/books/CET6luan_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/CET6luan_1.webp -------------------------------------------------------------------------------- /public/books/ChuZhong_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/ChuZhong_2.webp -------------------------------------------------------------------------------- /public/books/ChuZhong_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/ChuZhong_3.webp -------------------------------------------------------------------------------- /public/books/GMATluan_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/GMATluan_2.webp -------------------------------------------------------------------------------- /public/books/GaoZhong_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/GaoZhong_2.webp -------------------------------------------------------------------------------- /public/books/GaoZhong_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/GaoZhong_3.webp -------------------------------------------------------------------------------- /public/books/KaoYan_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/KaoYan_1.webp -------------------------------------------------------------------------------- /public/books/KaoYan_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/KaoYan_2.webp -------------------------------------------------------------------------------- /public/books/KaoYan_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/KaoYan_3.webp -------------------------------------------------------------------------------- /public/books/Level4_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/Level4_1.webp -------------------------------------------------------------------------------- /public/books/Level4_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/Level4_2.webp -------------------------------------------------------------------------------- /public/books/Level8_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/Level8_1.webp -------------------------------------------------------------------------------- /public/books/Level8_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/Level8_2.webp -------------------------------------------------------------------------------- /public/books/IELTSluan_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/IELTSluan_2.webp -------------------------------------------------------------------------------- /public/books/KaoYanluan_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/KaoYanluan_1.webp -------------------------------------------------------------------------------- /public/books/Level4luan_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/Level4luan_1.webp -------------------------------------------------------------------------------- /public/books/Level4luan_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/Level4luan_2.webp -------------------------------------------------------------------------------- /public/books/Level8luan_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/Level8luan_2.webp -------------------------------------------------------------------------------- /public/books/ChuZhongluan_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/ChuZhongluan_2.webp -------------------------------------------------------------------------------- /public/books/GaoZhongluan_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/GaoZhongluan_2.webp -------------------------------------------------------------------------------- /public/books/PEPChuZhong7_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPChuZhong7_1.webp -------------------------------------------------------------------------------- /public/books/PEPChuZhong7_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPChuZhong7_2.webp -------------------------------------------------------------------------------- /public/books/PEPChuZhong8_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPChuZhong8_1.webp -------------------------------------------------------------------------------- /public/books/PEPChuZhong8_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPChuZhong8_2.webp -------------------------------------------------------------------------------- /public/books/PEPChuZhong9_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPChuZhong9_1.webp -------------------------------------------------------------------------------- /public/books/PEPGaoZhong_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPGaoZhong_1.webp -------------------------------------------------------------------------------- /public/books/PEPGaoZhong_10.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPGaoZhong_10.webp -------------------------------------------------------------------------------- /public/books/PEPGaoZhong_11.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPGaoZhong_11.webp -------------------------------------------------------------------------------- /public/books/PEPGaoZhong_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPGaoZhong_2.webp -------------------------------------------------------------------------------- /public/books/PEPGaoZhong_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPGaoZhong_3.webp -------------------------------------------------------------------------------- /public/books/PEPGaoZhong_4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPGaoZhong_4.webp -------------------------------------------------------------------------------- /public/books/PEPGaoZhong_5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPGaoZhong_5.webp -------------------------------------------------------------------------------- /public/books/PEPGaoZhong_6.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPGaoZhong_6.webp -------------------------------------------------------------------------------- /public/books/PEPGaoZhong_7.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPGaoZhong_7.webp -------------------------------------------------------------------------------- /public/books/PEPGaoZhong_8.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPGaoZhong_8.webp -------------------------------------------------------------------------------- /public/books/PEPGaoZhong_9.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPGaoZhong_9.webp -------------------------------------------------------------------------------- /public/books/PEPXiaoXue3_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPXiaoXue3_1.webp -------------------------------------------------------------------------------- /public/books/PEPXiaoXue3_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPXiaoXue3_2.webp -------------------------------------------------------------------------------- /public/books/PEPXiaoXue4_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPXiaoXue4_1.webp -------------------------------------------------------------------------------- /public/books/PEPXiaoXue4_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPXiaoXue4_2.webp -------------------------------------------------------------------------------- /public/books/PEPXiaoXue5_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPXiaoXue5_1.webp -------------------------------------------------------------------------------- /public/books/PEPXiaoXue5_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPXiaoXue5_2.webp -------------------------------------------------------------------------------- /public/books/PEPXiaoXue6_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPXiaoXue6_1.webp -------------------------------------------------------------------------------- /public/books/PEPXiaoXue6_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/PEPXiaoXue6_2.webp -------------------------------------------------------------------------------- /public/books/BeiShiGaoZhong_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/BeiShiGaoZhong_1.webp -------------------------------------------------------------------------------- /public/books/BeiShiGaoZhong_10.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/BeiShiGaoZhong_10.webp -------------------------------------------------------------------------------- /public/books/BeiShiGaoZhong_11.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/BeiShiGaoZhong_11.webp -------------------------------------------------------------------------------- /public/books/BeiShiGaoZhong_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/BeiShiGaoZhong_2.webp -------------------------------------------------------------------------------- /public/books/BeiShiGaoZhong_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/BeiShiGaoZhong_3.webp -------------------------------------------------------------------------------- /public/books/BeiShiGaoZhong_4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/BeiShiGaoZhong_4.webp -------------------------------------------------------------------------------- /public/books/BeiShiGaoZhong_5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/BeiShiGaoZhong_5.webp -------------------------------------------------------------------------------- /public/books/BeiShiGaoZhong_6.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/BeiShiGaoZhong_6.webp -------------------------------------------------------------------------------- /public/books/BeiShiGaoZhong_7.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/BeiShiGaoZhong_7.webp -------------------------------------------------------------------------------- /public/books/BeiShiGaoZhong_8.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/BeiShiGaoZhong_8.webp -------------------------------------------------------------------------------- /public/books/BeiShiGaoZhong_9.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/BeiShiGaoZhong_9.webp -------------------------------------------------------------------------------- /public/books/WaiYanSheChuZhong_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/WaiYanSheChuZhong_1.webp -------------------------------------------------------------------------------- /public/books/WaiYanSheChuZhong_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/WaiYanSheChuZhong_2.webp -------------------------------------------------------------------------------- /public/books/WaiYanSheChuZhong_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/WaiYanSheChuZhong_3.webp -------------------------------------------------------------------------------- /public/books/WaiYanSheChuZhong_4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/WaiYanSheChuZhong_4.webp -------------------------------------------------------------------------------- /public/books/WaiYanSheChuZhong_5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/WaiYanSheChuZhong_5.webp -------------------------------------------------------------------------------- /public/books/WaiYanSheChuZhong_6.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveSuv/remix-words-funny/HEAD/public/books/WaiYanSheChuZhong_6.webp -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !.env.example 2 | .DS_Store 3 | .react-router 4 | build 5 | node_modules 6 | *.tsbuildinfo 7 | .local 8 | drizzle 9 | 10 | # Uncomment .env when deploy to production 11 | # .env -------------------------------------------------------------------------------- /app/routes.ts: -------------------------------------------------------------------------------- 1 | import { type RouteConfig } from "@react-router/dev/routes"; 2 | import { flatRoutes } from "@react-router/fs-routes"; 3 | 4 | export default flatRoutes() satisfies RouteConfig; 5 | -------------------------------------------------------------------------------- /app/components/FormFieldError.tsx: -------------------------------------------------------------------------------- 1 | export const FormFieldError = ({ message }: { message?: string }) => { 2 | return message ?
{message}
: null; 3 | }; 4 | -------------------------------------------------------------------------------- /app/hooks/useMobile.ts: -------------------------------------------------------------------------------- 1 | import { useMediaQuery } from "usehooks-ts"; 2 | 3 | export const useMobile = () => { 4 | const isMobile = useMediaQuery("(width < 80rem)"); 5 | return { isMobile }; 6 | }; 7 | -------------------------------------------------------------------------------- /app/.server/router/loader/getMyUserInfo.ts: -------------------------------------------------------------------------------- 1 | import { p } from "~/.server/common/trpc"; 2 | 3 | export const getMyUserInfo = p.public.query(({ ctx: { myUserInfo } }) => { 4 | return { myUserInfo }; 5 | }); 6 | -------------------------------------------------------------------------------- /app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | 3 | // homepage just redirect to first book 4 | export const loader = async () => { 5 | return redirect(`/BeiShiGaoZhong_4/words`); 6 | }; 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | 3 | WORKDIR /app 4 | 5 | ADD build build 6 | ADD package.json package.json 7 | ADD node_modules node_modules 8 | ADD .env .env 9 | 10 | EXPOSE 3001 11 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /app/.server/common/crypto.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | 3 | export const encrypt = (text: string) => { 4 | return crypto 5 | .createHmac("sha256", process.env.CRYPTO_SECRET) 6 | .update(text) 7 | .digest("hex"); 8 | }; 9 | -------------------------------------------------------------------------------- /app/styles/global.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin './hero.ts'; 3 | @source '../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}'; 4 | @custom-variant dark (&:where(.dark, .dark *)); 5 | @theme { 6 | --font-merriweathers: "Merriweather"; 7 | } 8 | -------------------------------------------------------------------------------- /app/.server/db/index.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/postgres-js"; 2 | import postgres from "postgres"; 3 | import * as schema from "./schema"; 4 | 5 | const client = postgres(process.env.DATABASE_URL); 6 | 7 | export const db = drizzle(client, { schema }); 8 | -------------------------------------------------------------------------------- /app/.server/db/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | export default defineConfig({ 4 | strict: true, 5 | schema: `${__dirname}/schema.ts`, 6 | dialect: "postgresql", 7 | dbCredentials: { 8 | url: process.env.DATABASE_URL, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | namespace NodeJS { 2 | interface ProcessEnv { 3 | readonly DATABASE_URL: string; 4 | readonly JWT_SECRET: string; 5 | readonly CRYPTO_SECRET: string; 6 | readonly EMAIL_SERVER_ADDRESS: string; 7 | readonly EMAIL_SERVER_PASS: string; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /app/.server/router/action/signOut.ts: -------------------------------------------------------------------------------- 1 | import { Cookies } from "~/.server/common/cookies"; 2 | import { p } from "~/.server/common/trpc"; 3 | import { JWT_KEY } from "~/common/constants"; 4 | 5 | export const signOut = p.auth.mutation(({ ctx: { resHeaders } }) => { 6 | Cookies.delete(resHeaders, JWT_KEY); 7 | }); 8 | -------------------------------------------------------------------------------- /app/.server/db/task.ts: -------------------------------------------------------------------------------- 1 | import { db } from "."; 2 | import { Word } from "./schema"; 3 | 4 | // here to run some db task 5 | const runTask = async () => { 6 | // total words count 7 | const wordsCount = await db.$count(Word); 8 | console.log(`total words count: ${wordsCount}`); 9 | }; 10 | 11 | runTask(); 12 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | pnpm i 2 | pnpm build 3 | pnpm i --prod 4 | 5 | docker stop wordsfunny-app 6 | docker rm wordsfunny-app 7 | docker rmi wordsfunny-image 8 | 9 | docker build --platform linux/amd64 -t wordsfunny-image . 10 | docker run --name wordsfunny-app -p 3001:3001 -d wordsfunny-image 11 | 12 | sleep 3 13 | open http://localhost:3001 -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgresql://postgres:POSTGRES_PASSWORD_EXAMPLE@localhost:5432/wordsfunny" 2 | 3 | JWT_SECRET="JWT_SECRET_EXAMPLE" 4 | CRYPTO_SECRET="CRYPTO_SECRET_EXAMPLE" 5 | 6 | # optional SMTP email server config, values below can not use directly 7 | EMAIL_SERVER_ADDRESS="EMAIL_SERVER_ADDRESS_EXAMPLE" 8 | EMAIL_SERVER_PASS="EMAIL_SERVER_PASS_EXAMPLE" -------------------------------------------------------------------------------- /app/components/LuIcon.tsx: -------------------------------------------------------------------------------- 1 | import { LucideIcon, LucideProps } from "lucide-react"; 2 | 3 | export const LuIcon = (props: LucideProps & { icon: LucideIcon }) => { 4 | const Icon = props.icon; 5 | 6 | return ( 7 | 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@react-router/dev/config"; 2 | 3 | export default { 4 | ssr: true, 5 | future: { 6 | unstable_splitRouteModules: true, 7 | v8_middleware: true, 8 | unstable_optimizeDeps: true, 9 | unstable_subResourceIntegrity: true, 10 | unstable_viteEnvironmentApi: true, 11 | }, 12 | } satisfies Config; 13 | -------------------------------------------------------------------------------- /app/components/SkeletonBox.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@heroui/react"; 2 | 3 | export const SkeletonBox = () => { 4 | return ( 5 |
6 | 7 | 8 | 9 |
10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /app/routes/$.tsx: -------------------------------------------------------------------------------- 1 | import { SearchX } from "lucide-react"; 2 | import { LuIcon } from "~/components/LuIcon"; 3 | 4 | export default function PageNotFound() { 5 | return ( 6 |
7 | 8 |
页面不存在
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /app/common/constants.ts: -------------------------------------------------------------------------------- 1 | export const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; //7d 2 | export const JWT_KEY = "ACCESS_TOKEN"; 3 | export const IS_PROD = import.meta.env.PROD; 4 | export const PAGE_SIZE = 20; 5 | 6 | const DEV_TRPC_URL = "http://localhost:3001/trpc"; 7 | 8 | // when deploy to prod, change to real domain 9 | const PROD_TRPC_URL = "http://localhost:3001/trpc"; 10 | 11 | export const TRPC_URL = IS_PROD ? PROD_TRPC_URL : DEV_TRPC_URL; 12 | -------------------------------------------------------------------------------- /app/.server/router/action/doneWord.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { p } from "~/.server/common/trpc"; 3 | import { db } from "~/.server/db"; 4 | import { UsersToWords } from "~/.server/db/schema"; 5 | 6 | export const doneWord = p.auth 7 | .input(z.object({ wordSlug: z.string() })) 8 | .mutation(async ({ ctx: { userId }, input: { wordSlug } }) => { 9 | await db.insert(UsersToWords).values({ userId: userId!, wordSlug }); 10 | }); 11 | -------------------------------------------------------------------------------- /app/.server/router/action/starBook.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { p } from "~/.server/common/trpc"; 3 | import { db } from "~/.server/db"; 4 | import { UsersToBooks } from "~/.server/db/schema"; 5 | 6 | export const starBook = p.auth 7 | .input(z.object({ bookSlug: z.string() })) 8 | .mutation(async ({ ctx: { userId }, input: { bookSlug } }) => { 9 | await db.insert(UsersToBooks).values({ userId: userId!, bookSlug }); 10 | }); 11 | -------------------------------------------------------------------------------- /app/.server/router/action/votePost.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { p } from "~/.server/common/trpc"; 3 | import { db } from "~/.server/db"; 4 | import { UsersToPostsVote } from "~/.server/db/schema"; 5 | 6 | export const votePost = p.auth 7 | .input(z.object({ postId: z.number().int() })) 8 | .mutation(async ({ ctx: { userId }, input: { postId } }) => { 9 | await db.insert(UsersToPostsVote).values({ userId: userId!, postId }); 10 | }); 11 | -------------------------------------------------------------------------------- /app/hooks/useAppTheme.ts: -------------------------------------------------------------------------------- 1 | import { useTheme } from "next-themes"; 2 | 3 | enum Theme { 4 | LIGHT = "light", 5 | DARK = "dark", 6 | } 7 | 8 | export const useAppTheme = () => { 9 | const { theme, setTheme } = useTheme(); 10 | 11 | const isDarkMode = theme === Theme.DARK; 12 | 13 | const toggleTheme = () => { 14 | setTheme(isDarkMode ? Theme.LIGHT : Theme.DARK); 15 | }; 16 | 17 | return { isDarkMode, toggleTheme, setTheme }; 18 | }; 19 | -------------------------------------------------------------------------------- /app/hooks/useDebounceSearchWord.ts: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from "jotai"; 2 | import { useDebounceValue } from "usehooks-ts"; 3 | import { searchWordAtom } from "~/common/store"; 4 | 5 | export const useDebounceSearchWord = () => { 6 | const _searchWord = useAtomValue(searchWordAtom); 7 | const [debounceSearchWord] = useDebounceValue(_searchWord, 300); 8 | const searchWord = debounceSearchWord.trim().toLowerCase(); 9 | return { searchWord }; 10 | }; 11 | -------------------------------------------------------------------------------- /app/.server/router/action/sendComment.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { p } from "~/.server/common/trpc"; 3 | import { db } from "~/.server/db"; 4 | import { Post } from "~/.server/db/schema"; 5 | 6 | export const sendComment = p.auth 7 | .input(z.object({ content: z.string(), wordSlug: z.string() })) 8 | .mutation(async ({ ctx: { userId }, input: { content, wordSlug } }) => { 9 | await db.insert(Post).values({ userId: userId!, wordSlug, content }); 10 | }); 11 | -------------------------------------------------------------------------------- /app/components/SignInButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@heroui/react"; 2 | import { useSetAtom } from "jotai"; 3 | import { isSignInModalOpenAtom } from "~/common/store"; 4 | 5 | export const SignInButton = () => { 6 | const setIsSignInModalOpen = useSetAtom(isSignInModalOpenAtom); 7 | 8 | return ( 9 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /app/components/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import { Progress } from "@heroui/react"; 2 | import { useNavigation } from "react-router"; 3 | 4 | export const ProgressBar = () => { 5 | const { state } = useNavigation(); 6 | 7 | const value = 8 | { 9 | idle: 0, 10 | submitting: 50, 11 | loading: 100, 12 | }[state] || 0; 13 | 14 | return ( 15 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /app/components/SettingButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@heroui/react"; 2 | import { useSetAtom } from "jotai"; 3 | import { Settings } from "lucide-react"; 4 | import { isSettingModalOpenAtom } from "~/common/store"; 5 | import { LuIcon } from "./LuIcon"; 6 | 7 | export const SettingButton = () => { 8 | const setIsSettingModalOpen = useSetAtom(isSettingModalOpenAtom); 9 | 10 | return ( 11 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /app/components/GithubIconButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Image } from "@heroui/react"; 2 | import { useAppTheme } from "~/hooks/useAppTheme"; 3 | 4 | export const GithubIconButton = () => { 5 | const { isDarkMode } = useAppTheme(); 6 | 7 | return ( 8 | 9 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /app/hooks/useMyUserInfo.ts: -------------------------------------------------------------------------------- 1 | import { useLoaderData, useOutletContext } from "react-router"; 2 | import { IUserInfo } from "~/common/types"; 3 | import { loader } from "~/root"; 4 | 5 | export const useMyUserInfo = () => { 6 | const ctx = useOutletContext<{ myUserInfo: IUserInfo } | null>(); 7 | const rootLoaderData = useLoaderData() || {}; 8 | 9 | // page use ctx, component use rootLoaderData 10 | const myUserInfo = ctx ? ctx.myUserInfo : rootLoaderData.myUserInfo; 11 | const userId = myUserInfo?.id; 12 | const isLogin = !!myUserInfo; 13 | 14 | return { myUserInfo, userId, isLogin }; 15 | }; 16 | -------------------------------------------------------------------------------- /app/components/SearchButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@heroui/react"; 2 | import { useSetAtom } from "jotai"; 3 | import { Search } from "lucide-react"; 4 | import { isSearchBarOpenAtom } from "~/common/store"; 5 | import { LuIcon } from "./LuIcon"; 6 | 7 | export const SearchButton = () => { 8 | const setIsSearchBarOpenAtom = useSetAtom(isSearchBarOpenAtom); 9 | 10 | return ( 11 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /app/common/queryClient.ts: -------------------------------------------------------------------------------- 1 | import { addToast } from "@heroui/react"; 2 | import { QueryClient } from "@tanstack/react-query"; 3 | import { TRPCError } from "@trpc/server"; 4 | 5 | export const queryClient = new QueryClient({ 6 | defaultOptions: { 7 | queries: { 8 | retry: false, 9 | refetchOnMount: false, 10 | refetchOnReconnect: false, 11 | refetchOnWindowFocus: false, 12 | staleTime: 1000 * 60 * 5, // 5min 13 | }, 14 | mutations: { 15 | onError(error) { 16 | addToast({ title: (error as TRPCError).message, color: "danger" }); 17 | }, 18 | }, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /app/components/GithubButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Image } from "@heroui/react"; 2 | import { useAppTheme } from "~/hooks/useAppTheme"; 3 | 4 | export const GithubButton = () => { 5 | const { isDarkMode } = useAppTheme(); 6 | 7 | return ( 8 | 9 | 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /app/.server/router/action/unDoneWord.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { UsersToWords } from "~/.server/db/schema"; 6 | 7 | export const unDoneWord = p.auth 8 | .input(z.object({ wordSlug: z.string() })) 9 | .mutation(async ({ ctx: { userId }, input: { wordSlug } }) => { 10 | await db 11 | .delete(UsersToWords) 12 | .where( 13 | and( 14 | eq(UsersToWords.userId, userId!), 15 | eq(UsersToWords.wordSlug, wordSlug), 16 | ), 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /app/.server/router/action/unStarBook.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { UsersToBooks } from "~/.server/db/schema"; 6 | 7 | export const unStarBook = p.auth 8 | .input(z.object({ bookSlug: z.string() })) 9 | .mutation(async ({ ctx: { userId }, input: { bookSlug } }) => { 10 | await db 11 | .delete(UsersToBooks) 12 | .where( 13 | and( 14 | eq(UsersToBooks.userId, userId!), 15 | eq(UsersToBooks.bookSlug, bookSlug), 16 | ), 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /app/.server/router/action/unVotePost.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { UsersToPostsVote } from "~/.server/db/schema"; 6 | 7 | export const unVotePost = p.auth 8 | .input(z.object({ postId: z.number().int() })) 9 | .mutation(async ({ ctx: { userId }, input: { postId } }) => { 10 | await db 11 | .delete(UsersToPostsVote) 12 | .where( 13 | and( 14 | eq(UsersToPostsVote.userId, userId!), 15 | eq(UsersToPostsVote.postId, postId), 16 | ), 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /app/.server/router/loader/getWordPhrases.ts: -------------------------------------------------------------------------------- 1 | import { eq, sql } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { Phrase } from "~/.server/db/schema"; 6 | 7 | const prepare = db 8 | .select() 9 | .from(Phrase) 10 | .where(eq(Phrase.wordSlug, sql.placeholder("wordSlug"))) 11 | .prepare("prepare"); 12 | 13 | export const getWordPhrases = p.public 14 | .input(z.object({ wordSlug: z.string() })) 15 | .query(async ({ input: { wordSlug } }) => { 16 | const wordPhrases = await prepare.execute({ wordSlug }); 17 | return { wordPhrases }; 18 | }); 19 | -------------------------------------------------------------------------------- /app/.server/router/loader/getWordCognates.ts: -------------------------------------------------------------------------------- 1 | import { eq, sql } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { Cognate } from "~/.server/db/schema"; 6 | 7 | const prepare = db 8 | .select() 9 | .from(Cognate) 10 | .where(eq(Cognate.wordSlug, sql.placeholder("wordSlug"))) 11 | .prepare("prepare"); 12 | 13 | export const getWordCognates = p.public 14 | .input(z.object({ wordSlug: z.string() })) 15 | .query(async ({ input: { wordSlug } }) => { 16 | const wordCognates = await prepare.execute({ wordSlug }); 17 | return { wordCognates }; 18 | }); 19 | -------------------------------------------------------------------------------- /app/.server/router/loader/getWordSynonyms.ts: -------------------------------------------------------------------------------- 1 | import { eq, sql } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { Synonym } from "~/.server/db/schema"; 6 | 7 | const prepare = db 8 | .select() 9 | .from(Synonym) 10 | .where(eq(Synonym.wordSlug, sql.placeholder("wordSlug"))) 11 | .prepare("prepare"); 12 | 13 | export const getWordSynonyms = p.public 14 | .input(z.object({ wordSlug: z.string() })) 15 | .query(async ({ input: { wordSlug } }) => { 16 | const wordSynonyms = await prepare.execute({ wordSlug }); 17 | 18 | return { wordSynonyms }; 19 | }); 20 | -------------------------------------------------------------------------------- /app/routes/trpc.$trpc.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 | import { createTRPCContext } from "~/.server/common/trpc"; 3 | import { appRouter } from "~/.server/router"; 4 | import { Route } from "./+types/trpc.$trpc"; 5 | 6 | const handleRequest = (args: Route.LoaderArgs | Route.ActionArgs) => { 7 | return fetchRequestHandler({ 8 | endpoint: "/trpc", 9 | req: args.request, 10 | router: appRouter, 11 | createContext: createTRPCContext, 12 | }); 13 | }; 14 | 15 | export const loader = (args: Route.LoaderArgs) => handleRequest(args); 16 | 17 | export const action = (args: Route.ActionArgs) => handleRequest(args); 18 | -------------------------------------------------------------------------------- /app/components/CloseMenuButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@heroui/react"; 2 | import { useSetAtom } from "jotai"; 3 | import { X } from "lucide-react"; 4 | import { isBooksPanelDrawerOpenAtom } from "~/common/store"; 5 | import { LuIcon } from "~/components/LuIcon"; 6 | 7 | export const CloseMenuButton = () => { 8 | const setIsBooksPanelDrawerOpen = useSetAtom(isBooksPanelDrawerOpenAtom); 9 | 10 | return ( 11 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /app/.server/router/loader/getAllBooks.ts: -------------------------------------------------------------------------------- 1 | import { count, eq } from "drizzle-orm"; 2 | import { p } from "~/.server/common/trpc"; 3 | import { db } from "~/.server/db"; 4 | import { Book, Word } from "~/.server/db/schema"; 5 | 6 | const prepare = db 7 | .select({ 8 | id: Book.id, 9 | slug: Book.slug, 10 | cover: Book.cover, 11 | name: Book.name, 12 | wordsCount: count(Word.id), 13 | }) 14 | .from(Book) 15 | .leftJoin(Word, eq(Word.bookSlug, Book.slug)) 16 | .groupBy(Book.id) 17 | .prepare("prepare"); 18 | 19 | export const getAllBooks = p.public.query(async () => { 20 | const allBooks = await prepare.execute(); 21 | return { allBooks }; 22 | }); 23 | -------------------------------------------------------------------------------- /app/hooks/useZodForm.ts: -------------------------------------------------------------------------------- 1 | import { zodResolver } from "@hookform/resolvers/zod"; 2 | import { useForm, UseFormProps, UseFormReturn } from "react-hook-form"; 3 | import { z } from "zod"; 4 | 5 | type IUseZodForm = >( 6 | schema: T, 7 | props?: UseFormProps>, 8 | ) => { 9 | form: UseFormReturn>; 10 | }; 11 | 12 | export const useZodForm: IUseZodForm = (schema, props) => { 13 | type FormType = z.infer; 14 | 15 | const form = useForm({ 16 | resolver: zodResolver(schema as z.ZodType), 17 | mode: "onChange", 18 | ...props, 19 | }); 20 | 21 | return { form }; 22 | }; 23 | -------------------------------------------------------------------------------- /app/common/store.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import { ListTabType } from "./types"; 3 | 4 | export const isBooksPanelDrawerOpenAtom = atom(false); 5 | export const isWordDetailPanelDrawerOpenAtom = atom(false); 6 | export const isSearchBarOpenAtom = atom(false); 7 | export const listTabAtom = atom(ListTabType.ALL); 8 | export const isProfileModalOpenAtom = atom(false); 9 | export const searchWordAtom = atom(""); 10 | export const isSettingModalOpenAtom = atom(false); 11 | export const isSignInModalOpenAtom = atom(false); 12 | export const isSignUpModalOpenAtom = atom(false); 13 | export const isUpdatePasswordModalOpenAtom = atom(false); 14 | export const wordDetailSlugAtom = atom(""); 15 | -------------------------------------------------------------------------------- /app/components/OpenMenuButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@heroui/react"; 2 | import { useSetAtom } from "jotai"; 3 | import { Menu } from "lucide-react"; 4 | import { isBooksPanelDrawerOpenAtom } from "~/common/store"; 5 | import { LuIcon } from "~/components/LuIcon"; 6 | 7 | export const OpenMenuButton = () => { 8 | const setIsBooksPanelDrawerOpen = useSetAtom(isBooksPanelDrawerOpenAtom); 9 | 10 | return ( 11 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /app/.server/router/loader/getBookDetail.ts: -------------------------------------------------------------------------------- 1 | import { eq, sql } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { Book } from "~/.server/db/schema"; 6 | 7 | const prepare = db 8 | .select() 9 | .from(Book) 10 | .where(eq(Book.slug, sql.placeholder("bookSlug"))) 11 | .limit(1) 12 | .prepare("prepare"); 13 | 14 | export const getBookDetail = p.public 15 | .input( 16 | z.object({ 17 | bookSlug: z.string(), 18 | }), 19 | ) 20 | .query(async ({ input: { bookSlug } }) => { 21 | const [bookDetail] = await prepare.execute({ bookSlug }); 22 | return { bookDetail }; 23 | }); 24 | -------------------------------------------------------------------------------- /app/.server/router/loader/getWordSentences.ts: -------------------------------------------------------------------------------- 1 | import { eq, sql } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { Sentence } from "~/.server/db/schema"; 6 | 7 | const prepare = db 8 | .select() 9 | .from(Sentence) 10 | .where(eq(Sentence.wordSlug, sql.placeholder("wordSlug"))) 11 | .prepare("prepare"); 12 | 13 | export const getWordSentences = p.public 14 | .input(z.object({ wordSlug: z.string() })) 15 | .query(async ({ input: { wordSlug } }) => { 16 | const wordSentences = await prepare.execute({ 17 | wordSlug, 18 | }); 19 | 20 | return { wordSentences }; 21 | }); 22 | -------------------------------------------------------------------------------- /app/.server/router/loader/getStarBooks.ts: -------------------------------------------------------------------------------- 1 | import { eq, sql } from "drizzle-orm"; 2 | import { p } from "~/.server/common/trpc"; 3 | import { db } from "~/.server/db"; 4 | import { UsersToBooks } from "~/.server/db/schema"; 5 | 6 | const prepare = db 7 | .select({ 8 | bookSlug: UsersToBooks.bookSlug, 9 | }) 10 | .from(UsersToBooks) 11 | .where(eq(UsersToBooks.userId, sql.placeholder("userId"))) 12 | .prepare("prepare"); 13 | 14 | export const getStarBooks = p.public.query(async ({ ctx: { userId } }) => { 15 | if (!userId) return { starBooks: [] }; 16 | const starBooks = await prepare.execute({ userId }); 17 | return { starBooks: starBooks.map(({ bookSlug }) => bookSlug) }; 18 | }); 19 | -------------------------------------------------------------------------------- /app/components/BooksPanel.tsx: -------------------------------------------------------------------------------- 1 | import { IBookItem } from "~/common/types"; 2 | import { BookPanelItem } from "./BookPanelItem"; 3 | 4 | export const BooksPanel = ({ 5 | allBooks, 6 | starBooks, 7 | }: { 8 | allBooks: IBookItem[]; 9 | starBooks: string[]; 10 | }) => { 11 | const booksList = [ 12 | ...allBooks.filter(({ slug }) => starBooks.includes(slug)), 13 | ...allBooks.filter(({ slug }) => !starBooks.includes(slug)), 14 | ]; 15 | 16 | return ( 17 | <> 18 | {booksList.map((e) => ( 19 | 24 | ))} 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /app/.server/router/loader/getWordDetail.ts: -------------------------------------------------------------------------------- 1 | import { eq, sql } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { Book, Word } from "~/.server/db/schema"; 6 | 7 | const prepare = db 8 | .select() 9 | .from(Word) 10 | .where(eq(Word.slug, sql.placeholder("wordSlug"))) 11 | .innerJoin(Book, eq(Book.slug, Word.bookSlug)) 12 | .limit(1) 13 | .prepare("prepare"); 14 | 15 | export const getWordDetail = p.public 16 | .input(z.object({ wordSlug: z.string() })) 17 | .query(async ({ input: { wordSlug } }) => { 18 | const [wordDetail] = await prepare.execute({ wordSlug }); 19 | return { wordDetail }; 20 | }); 21 | -------------------------------------------------------------------------------- /app/.server/router/loader/getWordTranslations.ts: -------------------------------------------------------------------------------- 1 | import { eq, sql } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { Translation } from "~/.server/db/schema"; 6 | 7 | const prepare = db 8 | .select() 9 | .from(Translation) 10 | .where(eq(Translation.wordSlug, sql.placeholder("wordSlug"))) 11 | .prepare("prepare"); 12 | 13 | export const getWordTranslations = p.public 14 | .input(z.object({ wordSlug: z.string() })) 15 | .query(async ({ input: { wordSlug } }) => { 16 | const wordTranslations = await prepare.execute({ 17 | wordSlug, 18 | }); 19 | 20 | return { wordTranslations }; 21 | }); 22 | -------------------------------------------------------------------------------- /app/.server/router/loader/getPostVote.ts: -------------------------------------------------------------------------------- 1 | import { eq, sql } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { UsersToPostsVote } from "~/.server/db/schema"; 6 | 7 | const prepare = db 8 | .select() 9 | .from(UsersToPostsVote) 10 | .where(eq(UsersToPostsVote.postId, sql.placeholder("postId"))) 11 | .prepare("prepare"); 12 | 13 | export const getPostVote = p.public 14 | .input( 15 | z.object({ 16 | postId: z.number().int(), 17 | }), 18 | ) 19 | .query(async ({ input: { postId } }) => { 20 | const postVotes = await prepare.execute({ postId }); 21 | return { postVotesCount: postVotes.length }; 22 | }); 23 | -------------------------------------------------------------------------------- /app/components/CloseSearchBarButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@heroui/react"; 2 | import { useSetAtom } from "jotai"; 3 | import { X } from "lucide-react"; 4 | import { isSearchBarOpenAtom, searchWordAtom } from "~/common/store"; 5 | import { LuIcon } from "~/components/LuIcon"; 6 | 7 | export const CloseSearchBarButton = () => { 8 | const setIsSearchBarOpenAtom = useSetAtom(isSearchBarOpenAtom); 9 | const setSearchWord = useSetAtom(searchWordAtom); 10 | 11 | return ( 12 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /app/components/CloseWordDetailDrawerButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@heroui/react"; 2 | import { useSetAtom } from "jotai"; 3 | import { X } from "lucide-react"; 4 | import { isWordDetailPanelDrawerOpenAtom } from "~/common/store"; 5 | import { LuIcon } from "~/components/LuIcon"; 6 | 7 | export const CloseWordDetailDrawerButton = () => { 8 | const setIsWordDetailPanelDrawerOpen = useSetAtom( 9 | isWordDetailPanelDrawerOpenAtom, 10 | ); 11 | 12 | return ( 13 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /app/.server/common/mail.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from "nodemailer"; 2 | import { IS_PROD } from "~/common/constants"; 3 | 4 | // you can change to your custom SMTP config 5 | const transporter = nodemailer.createTransport({ 6 | host: "smtp.163.com", 7 | port: IS_PROD ? 465 : 25, 8 | secure: IS_PROD, 9 | auth: { 10 | user: process.env.EMAIL_SERVER_ADDRESS, 11 | pass: process.env.EMAIL_SERVER_PASS, 12 | }, 13 | }); 14 | 15 | export const sendVerifyCodeToEmail = async ({ 16 | email, 17 | verifyCode, 18 | }: { 19 | email: string; 20 | verifyCode: string; 21 | }) => { 22 | return transporter.sendMail({ 23 | from: `"WordsFunny" <${process.env.EMAIL_SERVER_ADDRESS}>`, 24 | to: email, 25 | subject: `验证码: ${verifyCode}`, 26 | text: "请查收您的验证码", 27 | html: `您的验证码是:${verifyCode}`, 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /app/components/UserAvatar.tsx: -------------------------------------------------------------------------------- 1 | import BoringAvatar from "boring-avatars"; 2 | import { useIsClient } from "usehooks-ts"; 3 | import { useMyUserInfo } from "~/hooks/useMyUserInfo"; 4 | 5 | export const UserAvatar = ({ 6 | name, 7 | size = 50, 8 | }: { 9 | name?: string; 10 | size?: number; 11 | }) => { 12 | const { myUserInfo } = useMyUserInfo(); 13 | 14 | const isClient = useIsClient(); 15 | 16 | if ((myUserInfo || name) && isClient) { 17 | return ( 18 | 26 | ); 27 | } 28 | 29 | return
; 30 | }; 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*", 4 | "**/.server/**/*", 5 | "**/.client/**/*", 6 | ".react-router/types/**/*" 7 | ], 8 | "compilerOptions": { 9 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 10 | "types": ["node", "vite/client"], 11 | "target": "ES2022", 12 | "module": "ES2022", 13 | "moduleResolution": "bundler", 14 | "jsx": "react-jsx", 15 | "rootDirs": [".", "./.react-router/types"], 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": ["./app/*"] 19 | }, 20 | "esModuleInterop": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "isolatedModules": true, 23 | "noEmit": true, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "resolveJsonModule": true, 27 | "skipLibCheck": true, 28 | "strict": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/components/GlobalComponents.tsx: -------------------------------------------------------------------------------- 1 | import { ToastProvider } from "@heroui/react"; 2 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 3 | import { ProfileModal } from "./ProfileModal"; 4 | import { ProgressBar } from "./ProgressBar"; 5 | import { SettingModal } from "./SettingModal"; 6 | import { SignInModal } from "./SignInModal"; 7 | import { SignUpModal } from "./SignUpModal"; 8 | import { UpdatePasswordModal } from "./UpdatePasswordModal"; 9 | 10 | export const GlobalComponents = () => { 11 | return ( 12 | <> 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /app/.server/common/auth.ts: -------------------------------------------------------------------------------- 1 | import { eq, sql } from "drizzle-orm"; 2 | import jwt from "jsonwebtoken"; 3 | import { db } from "~/.server/db"; 4 | import { User } from "~/.server/db/schema"; 5 | import { JWT_KEY } from "~/common/constants"; 6 | import { Cookies } from "./cookies"; 7 | 8 | const prepare = db 9 | .select() 10 | .from(User) 11 | .where(eq(User.id, sql.placeholder("id"))) 12 | .limit(1) 13 | .prepare("prepare"); 14 | 15 | export const getMyUserInfo = async (req: Request) => { 16 | const token = Cookies.get(req, JWT_KEY); 17 | if (!token) return undefined; 18 | try { 19 | const { userId } = jwt.verify(token, process.env.JWT_SECRET) as { 20 | userId: string; 21 | }; 22 | const [user] = await prepare.execute({ id: Number(userId) }); 23 | return user; 24 | } catch (error) { 25 | return undefined; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /app/.server/router/loader/getIsWordDone.ts: -------------------------------------------------------------------------------- 1 | import { and, eq, sql } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { UsersToWords } from "~/.server/db/schema"; 6 | 7 | const prepare = db 8 | .select() 9 | .from(UsersToWords) 10 | .where( 11 | and( 12 | eq(UsersToWords.wordSlug, sql.placeholder("wordSlug")), 13 | eq(UsersToWords.userId, sql.placeholder("userId")), 14 | ), 15 | ) 16 | .limit(1) 17 | .prepare("prepare"); 18 | 19 | export const getIsWordDone = p.public 20 | .input(z.object({ wordSlug: z.string() })) 21 | .query(async ({ ctx: { userId }, input: { wordSlug } }) => { 22 | if (!userId) return { isWordDone: false }; 23 | 24 | const [item] = await prepare.execute({ wordSlug, userId }); 25 | 26 | return { isWordDone: !!item }; 27 | }); 28 | -------------------------------------------------------------------------------- /app/common/types.ts: -------------------------------------------------------------------------------- 1 | import * as schema from "~/.server/db/schema"; 2 | 3 | export type IUserInfo = 4 | | { 5 | id: number; 6 | name: string; 7 | email: string; 8 | avatar: string; 9 | createdAt: Date; 10 | updatedAt: Date; 11 | } 12 | | undefined; 13 | 14 | export type IBookItem = { 15 | id: number; 16 | slug: string; 17 | cover: string; 18 | name: string; 19 | wordsCount: number; 20 | }; 21 | 22 | export type IPageWordsParams = { bookSlug: string }; 23 | 24 | export type ICommentItem = { 25 | User: typeof schema.User.$inferSelect; 26 | Post: typeof schema.Post.$inferSelect; 27 | }; 28 | 29 | export type IWordItem = { 30 | Book?: typeof schema.Book.$inferSelect; 31 | Word: typeof schema.Word.$inferSelect; 32 | }; 33 | 34 | export enum ListTabType { 35 | ALL = "ALL", 36 | DONE = "DONE", 37 | UNDONE = "UNDONE", 38 | } 39 | -------------------------------------------------------------------------------- /app/.server/router/loader/getIsPostVote.ts: -------------------------------------------------------------------------------- 1 | import { and, eq, sql } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { UsersToPostsVote } from "~/.server/db/schema"; 6 | 7 | const prepare = db 8 | .select() 9 | .from(UsersToPostsVote) 10 | .where( 11 | and( 12 | eq(UsersToPostsVote.postId, sql.placeholder("postId")), 13 | eq(UsersToPostsVote.userId, sql.placeholder("userId")), 14 | ), 15 | ) 16 | .limit(1) 17 | .prepare("prepare"); 18 | 19 | export const getIsPostVote = p.public 20 | .input( 21 | z.object({ 22 | postId: z.number().int(), 23 | }), 24 | ) 25 | .query(async ({ ctx: { userId }, input: { postId } }) => { 26 | if (!userId) return { isPostVote: false }; 27 | 28 | const [item] = await prepare.execute({ postId, userId }); 29 | return { isPostVote: !!item }; 30 | }); 31 | -------------------------------------------------------------------------------- /app/components/PasswordInput.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Input, InputProps } from "@heroui/react"; 2 | import { Eye, EyeOff } from "lucide-react"; 3 | import { useState } from "react"; 4 | import { LuIcon } from "./LuIcon"; 5 | 6 | export const PasswordInput = (props: InputProps) => { 7 | const [showPassword, setShowPassword] = useState(false); 8 | 9 | return ( 10 | { 23 | setShowPassword(!showPassword); 24 | }} 25 | > 26 | 27 | 28 | } 29 | {...props} 30 | /> 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /app/.server/router/loader/getStudyCalendar.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { and, eq, gte, lte, sql } from "drizzle-orm"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { UsersToWords } from "~/.server/db/schema"; 6 | 7 | const prepare = db 8 | .select({ 9 | wordSlug: UsersToWords.wordSlug, 10 | updatedAt: UsersToWords.updatedAt, 11 | }) 12 | .from(UsersToWords) 13 | .where( 14 | and( 15 | eq(UsersToWords.userId, sql.placeholder("userId")), 16 | gte(UsersToWords.updatedAt, dayjs().subtract(6, "month").toDate()), 17 | lte(UsersToWords.updatedAt, dayjs().add(1, "day").toDate()), 18 | ), 19 | ) 20 | .prepare("prepare"); 21 | 22 | export const getStudyCalendar = p.auth.query(async ({ ctx: { userId } }) => { 23 | if (!userId) return { isWordDone: false }; 24 | const studyCalendar = await prepare.execute({ userId }); 25 | return { studyCalendar }; 26 | }); 27 | -------------------------------------------------------------------------------- /app/components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "@heroui/react"; 2 | import { useAtom } from "jotai"; 3 | import { Search } from "lucide-react"; 4 | import { searchWordAtom } from "~/common/store"; 5 | import { CloseSearchBarButton } from "./CloseSearchBarButton"; 6 | import { LuIcon } from "./LuIcon"; 7 | 8 | export const SearchBar = () => { 9 | const [searchWord, setSearchWord] = useAtom(searchWordAtom); 10 | 11 | return ( 12 |
13 | 16 | } 17 | placeholder="全站搜索" 18 | autoComplete="off" 19 | autoFocus 20 | classNames={{ 21 | input: "text-medium", 22 | }} 23 | value={searchWord} 24 | onChange={(e) => setSearchWord(e.target.value)} 25 | /> 26 | 27 |
28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /public/svgs/github_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /public/svgs/github_light.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { reactRouter } from "@react-router/dev/vite"; 2 | import tailwindcss from "@tailwindcss/vite"; 3 | import { defineConfig } from "vite"; 4 | import tsconfigPaths from "vite-tsconfig-paths"; 5 | 6 | export default defineConfig({ 7 | server: { port: 3001, strictPort: true }, 8 | plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], 9 | build: { 10 | rollupOptions: { 11 | output: { 12 | manualChunks(id) { 13 | if (id.includes("node_modules")) { 14 | // split large chunk 15 | const names = [ 16 | "react-router", 17 | "react-dom", 18 | "react-hook-form", 19 | "react", 20 | "chance", 21 | "zod", 22 | "tailwind-merge", 23 | ]; 24 | for (const name of names) { 25 | if (id.includes(name)) return `vendor-${name}`; 26 | } 27 | return null; 28 | } 29 | }, 30 | }, 31 | }, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /app/components/DoneWordButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@heroui/react"; 2 | import { useMutation } from "@tanstack/react-query"; 3 | import { Check } from "lucide-react"; 4 | import { trpcClient } from "~/common/trpc"; 5 | import { useMyUserInfo } from "~/hooks/useMyUserInfo"; 6 | import { LuIcon } from "./LuIcon"; 7 | 8 | export const DoneWordButton = ({ 9 | wordSlug, 10 | onPress, 11 | }: { 12 | wordSlug: string; 13 | onPress?: Function; 14 | }) => { 15 | const { isLogin } = useMyUserInfo(); 16 | const doneWordMutation = useMutation( 17 | trpcClient.action.doneWord.mutationOptions(), 18 | ); 19 | 20 | return ( 21 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /app/.server/router/loader/getWordsOfBook.ts: -------------------------------------------------------------------------------- 1 | import { eq, sql } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { Word } from "~/.server/db/schema"; 6 | import { PAGE_SIZE } from "~/common/constants"; 7 | 8 | const prepare = db 9 | .select({ Word }) 10 | .from(Word) 11 | .where(eq(Word.bookSlug, sql.placeholder("bookSlug"))) 12 | .offset(sql.placeholder("offset")) 13 | .limit(sql.placeholder("limit")) 14 | .orderBy(Word.id) 15 | .prepare("prepare"); 16 | 17 | export const getWordsOfBook = p.public 18 | .input( 19 | z.object({ 20 | bookSlug: z.string(), 21 | cursor: z.number().int().default(0), 22 | }), 23 | ) 24 | .query(async ({ input: { bookSlug, cursor } }) => { 25 | const wordsOfBook = await prepare.execute({ 26 | bookSlug, 27 | offset: PAGE_SIZE * cursor, 28 | limit: PAGE_SIZE, 29 | }); 30 | 31 | const nextCursor = wordsOfBook.length ? cursor + 1 : undefined; 32 | 33 | return { wordsOfBook, nextCursor }; 34 | }); 35 | -------------------------------------------------------------------------------- /app/components/UnDoneWordButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@heroui/react"; 2 | import { useMutation } from "@tanstack/react-query"; 3 | import { Check } from "lucide-react"; 4 | import { trpcClient } from "~/common/trpc"; 5 | import { useMyUserInfo } from "~/hooks/useMyUserInfo"; 6 | import { LuIcon } from "./LuIcon"; 7 | 8 | export const UnDoneWordButton = ({ 9 | wordSlug, 10 | onPress, 11 | }: { 12 | wordSlug: string; 13 | onPress?: Function; 14 | }) => { 15 | const { isLogin } = useMyUserInfo(); 16 | const unDoneWordMutation = useMutation( 17 | trpcClient.action.unDoneWord.mutationOptions(), 18 | ); 19 | 20 | return ( 21 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /app/.server/router/loader/getWordComments.ts: -------------------------------------------------------------------------------- 1 | import { desc, eq, sql } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { Post, User } from "~/.server/db/schema"; 6 | import { PAGE_SIZE } from "~/common/constants"; 7 | 8 | const prepare = db 9 | .select() 10 | .from(Post) 11 | .where(eq(Post.wordSlug, sql.placeholder("wordSlug"))) 12 | .innerJoin(User, eq(User.id, Post.userId)) 13 | .offset(sql.placeholder("offset")) 14 | .limit(sql.placeholder("limit")) 15 | .orderBy(desc(Post.id)) 16 | .prepare("prepare"); 17 | 18 | export const getWordComments = p.public 19 | .input( 20 | z.object({ 21 | wordSlug: z.string(), 22 | cursor: z.number().int().default(0), 23 | }), 24 | ) 25 | .query(async ({ input: { wordSlug, cursor } }) => { 26 | const wordComments = await prepare.execute({ 27 | wordSlug, 28 | offset: PAGE_SIZE * cursor, 29 | limit: PAGE_SIZE, 30 | }); 31 | 32 | const nextCursor = wordComments.length ? cursor + 1 : undefined; 33 | 34 | return { wordComments, nextCursor }; 35 | }); 36 | -------------------------------------------------------------------------------- /app/.server/router/loader/getWordsOfKeyword.ts: -------------------------------------------------------------------------------- 1 | import { eq, like, sql } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { Book, Word } from "~/.server/db/schema"; 6 | import { PAGE_SIZE } from "~/common/constants"; 7 | 8 | const prepare = db 9 | .select() 10 | .from(Word) 11 | .where(like(Word.word, sql.placeholder("keyword"))) 12 | .innerJoin(Book, eq(Book.slug, Word.bookSlug)) 13 | .offset(sql.placeholder("offset")) 14 | .limit(sql.placeholder("limit")) 15 | .prepare("prepare"); 16 | 17 | export const getWordsOfKeyword = p.public 18 | .input( 19 | z.object({ 20 | keyword: z.string(), 21 | cursor: z.number().int().default(0), 22 | }), 23 | ) 24 | .query(async ({ input: { keyword, cursor } }) => { 25 | const wordsOfKeyword = await prepare.execute({ 26 | keyword: `%${keyword.trim().toLowerCase()}%`, 27 | offset: PAGE_SIZE * cursor, 28 | limit: PAGE_SIZE, 29 | }); 30 | 31 | const nextCursor = wordsOfKeyword.length ? cursor + 1 : undefined; 32 | 33 | return { wordsOfKeyword, nextCursor }; 34 | }); 35 | -------------------------------------------------------------------------------- /app/components/LinkWord.tsx: -------------------------------------------------------------------------------- 1 | import { useSetAtom } from "jotai"; 2 | import { 3 | isSearchBarOpenAtom, 4 | isWordDetailPanelDrawerOpenAtom, 5 | searchWordAtom, 6 | } from "~/common/store"; 7 | 8 | export const LinkWord = ({ word }: { word: string }) => { 9 | const setSearchWord = useSetAtom(searchWordAtom); 10 | const setIsWordDetailPanelDrawerOpen = useSetAtom( 11 | isWordDetailPanelDrawerOpenAtom, 12 | ); 13 | const setIsSearchBarOpenAtom = useSetAtom(isSearchBarOpenAtom); 14 | 15 | return ( 16 |
17 | {word.split(" ").map((item, index) => ( 18 |
{ 22 | setSearchWord( 23 | item 24 | .trim() 25 | .toLowerCase() 26 | .match(/[a-z]+/i)?.[0] || "", 27 | ); 28 | setIsWordDetailPanelDrawerOpen(false); 29 | setIsSearchBarOpenAtom(true); 30 | }} 31 | > 32 | {item} 33 |
34 | ))} 35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /app/components/ListTabs.tsx: -------------------------------------------------------------------------------- 1 | import { Tab, Tabs } from "@heroui/react"; 2 | import { useAtom } from "jotai"; 3 | import { listTabAtom } from "~/common/store"; 4 | import { ListTabType } from "~/common/types"; 5 | import { useMyUserInfo } from "~/hooks/useMyUserInfo"; 6 | 7 | export const ListTabs = () => { 8 | const [listTab, setListTab] = useAtom(listTabAtom); 9 | 10 | const { isLogin } = useMyUserInfo(); 11 | 12 | const tabs = [ 13 | { key: ListTabType.ALL, label: "全部", disabled: false }, 14 | { key: ListTabType.DONE, label: "已掌握", disabled: !isLogin }, 15 | { key: ListTabType.UNDONE, label: "未掌握", disabled: !isLogin }, 16 | ]; 17 | 18 | return ( 19 | { 24 | setListTab(key as ListTabType); 25 | }} 26 | > 27 | {tabs.map(({ key, label, disabled }) => ( 28 | 34 | ))} 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /app/routes/$bookSlug.words.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from "jotai"; 2 | import { isSearchBarOpenAtom } from "~/common/store"; 3 | import { BookWordsList } from "~/components/BookWordsList"; 4 | import { ListTabs } from "~/components/ListTabs"; 5 | import { OpenMenuButton } from "~/components/OpenMenuButton"; 6 | import { SearchBar } from "~/components/SearchBar"; 7 | import { SearchButton } from "~/components/SearchButton"; 8 | import { SearchWordsList } from "~/components/SearchWordsList"; 9 | import { useDebounceSearchWord } from "~/hooks/useDebounceSearchWord"; 10 | 11 | export default function PageWords() { 12 | const { searchWord } = useDebounceSearchWord(); 13 | const isSearchBarOpen = useAtomValue(isSearchBarOpenAtom); 14 | 15 | return ( 16 |
17 |
18 | {isSearchBarOpen ? ( 19 | 20 | ) : ( 21 |
22 | 23 | 24 | 25 |
26 | )} 27 |
28 | {searchWord ? : } 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/common/trpc.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCClient, httpBatchLink } from "@trpc/client"; 2 | import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query"; 3 | import SuperJSON from "superjson"; 4 | import { AppRouter } from "~/.server/router"; 5 | import { TRPC_URL } from "./constants"; 6 | import { queryClient } from "./queryClient"; 7 | 8 | // use trpcServer to fetch in server environment, like in loader, for passing cookies to trpc endpoint 9 | export const trpcServer = (request?: Request) => { 10 | return createTRPCClient({ 11 | links: [ 12 | httpBatchLink({ 13 | url: TRPC_URL, 14 | transformer: SuperJSON, 15 | headers: () => { 16 | const cookie = request?.headers.get("Cookie") || ""; 17 | return { cookie }; 18 | }, 19 | }), 20 | ], 21 | }); 22 | }; 23 | 24 | // use trpcClient to fetch in frontend environment, like in react component, cookies will be passing to trpc endpoint automatically 25 | export const trpcClient = createTRPCOptionsProxy({ 26 | queryClient, 27 | client: createTRPCClient({ 28 | links: [ 29 | httpBatchLink({ 30 | url: TRPC_URL, 31 | transformer: SuperJSON, 32 | }), 33 | ], 34 | }), 35 | }); 36 | -------------------------------------------------------------------------------- /app/.server/common/cookies.ts: -------------------------------------------------------------------------------- 1 | import cookie, { SerializeOptions } from "cookie"; 2 | import { COOKIE_MAX_AGE, IS_PROD } from "~/common/constants"; 3 | 4 | const getCookie = (req: Request, name: string) => { 5 | const cookieHeader = req.headers.get("Cookie"); 6 | if (!cookieHeader) return ""; 7 | const cookies = cookie.parse(cookieHeader); 8 | return cookies[name] as string; 9 | }; 10 | 11 | const setCookie = ( 12 | resHeaders: Headers, 13 | name: string, 14 | value: string, 15 | options?: SerializeOptions, 16 | ) => { 17 | resHeaders.set( 18 | "Set-Cookie", 19 | cookie.serialize(name, value, { 20 | maxAge: COOKIE_MAX_AGE, 21 | httpOnly: true, 22 | secure: IS_PROD, 23 | path: "/", 24 | sameSite: "lax", 25 | ...options, 26 | }), 27 | ); 28 | }; 29 | 30 | const deleteCookie = ( 31 | resHeaders: Headers, 32 | name: string, 33 | options?: SerializeOptions, 34 | ) => { 35 | resHeaders.set( 36 | "Set-Cookie", 37 | cookie.serialize(name, "", { 38 | maxAge: 0, 39 | httpOnly: true, 40 | secure: IS_PROD, 41 | path: "/", 42 | sameSite: "lax", 43 | ...options, 44 | }), 45 | ); 46 | }; 47 | 48 | export const Cookies = { 49 | get: getCookie, 50 | set: setCookie, 51 | delete: deleteCookie, 52 | }; 53 | -------------------------------------------------------------------------------- /app/components/WordPhrases.tsx: -------------------------------------------------------------------------------- 1 | import { Divider } from "@heroui/react"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | import { useAtomValue } from "jotai"; 4 | import { wordDetailSlugAtom } from "~/common/store"; 5 | import { trpcClient } from "~/common/trpc"; 6 | import { LinkWord } from "./LinkWord"; 7 | import { SkeletonBox } from "./SkeletonBox"; 8 | 9 | export const WordPhrases = () => { 10 | const wordDetailSlug = useAtomValue(wordDetailSlugAtom); 11 | 12 | const getWordPhrasesQuery = useQuery( 13 | trpcClient.loader.getWordPhrases.queryOptions( 14 | { 15 | wordSlug: wordDetailSlug, 16 | }, 17 | { enabled: !!wordDetailSlug }, 18 | ), 19 | ); 20 | 21 | const { wordPhrases = [] } = getWordPhrasesQuery.data || {}; 22 | 23 | if (getWordPhrasesQuery.isFetching) return ; 24 | 25 | if (wordPhrases.length === 0) return null; 26 | 27 | return ( 28 |
29 | 30 |
短语
31 |
32 | {wordPhrases.map(({ id, content, transCn }) => ( 33 |
34 | 35 |
{transCn}
36 |
37 | ))} 38 |
39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /app/common/formSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | // basic fields 4 | export const email = z.string().email("无效的邮箱格式"); 5 | 6 | export const password = z 7 | .string() 8 | .min(8, "密码长度不能少于8位") 9 | .max(30, "密码长度不能多于30位"); 10 | 11 | export const keepAlive = z.boolean(); 12 | 13 | export const verifyCode = z.string().length(6, "验证码为6位数字"); 14 | 15 | export const name = z 16 | .string() 17 | .min(3, "名字长度不能少于3位") 18 | .max(16, "名字长度不能多于16位"); 19 | 20 | export const comment = z 21 | .string() 22 | .min(3, "评论长度不能少于3位") 23 | .max(1000, "评论长度不能多于1000位"); 24 | 25 | const updatePasswordFormFields = { 26 | email, 27 | password, 28 | password2: password, 29 | verifyCode, 30 | }; 31 | 32 | // signInForm 33 | export const signInForm = z.object({ email, password, keepAlive }); 34 | 35 | // signUpForm 36 | export const signUpForm = z 37 | .object({ ...updatePasswordFormFields, name }) 38 | .refine((data) => data.password === data.password2, { 39 | message: "两次密码输入不一致", 40 | path: ["password2"], 41 | }); 42 | 43 | // updatePasswordForm 44 | export const updatePasswordForm = z 45 | .object(updatePasswordFormFields) 46 | .refine((data) => data.password === data.password2, { 47 | message: "两次密码输入不一致", 48 | path: ["password2"], 49 | }); 50 | 51 | // commentForm 52 | export const commentForm = z.object({ comment }); 53 | -------------------------------------------------------------------------------- /app/components/WordSentences.tsx: -------------------------------------------------------------------------------- 1 | import { Divider } from "@heroui/react"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | import { useAtomValue } from "jotai"; 4 | import { wordDetailSlugAtom } from "~/common/store"; 5 | import { trpcClient } from "~/common/trpc"; 6 | import { LinkWord } from "./LinkWord"; 7 | import { SkeletonBox } from "./SkeletonBox"; 8 | 9 | export const WordSentences = () => { 10 | const wordDetailSlug = useAtomValue(wordDetailSlugAtom); 11 | 12 | const getWordSentencesQuery = useQuery( 13 | trpcClient.loader.getWordSentences.queryOptions( 14 | { 15 | wordSlug: wordDetailSlug, 16 | }, 17 | { enabled: !!wordDetailSlug }, 18 | ), 19 | ); 20 | 21 | const { wordSentences = [] } = getWordSentencesQuery.data || {}; 22 | 23 | if (getWordSentencesQuery.isFetching) return ; 24 | 25 | if (wordSentences.length === 0) return null; 26 | 27 | return ( 28 |
29 | 30 |
句子
31 |
32 | {wordSentences.map(({ id, content, transCn }) => ( 33 |
34 | 35 |
{transCn}
36 |
37 | ))} 38 |
39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /app/.server/router/loader/getDoneWordsOfBook.ts: -------------------------------------------------------------------------------- 1 | import { and, eq, sql } from "drizzle-orm"; 2 | import { z } from "zod"; 3 | import { p } from "~/.server/common/trpc"; 4 | import { db } from "~/.server/db"; 5 | import { Book, UsersToWords, Word } from "~/.server/db/schema"; 6 | import { PAGE_SIZE } from "~/common/constants"; 7 | 8 | const prepare = db 9 | .select({ Word }) 10 | .from(Word) 11 | .innerJoin(Book, eq(Book.slug, Word.bookSlug)) 12 | .innerJoin(UsersToWords, eq(UsersToWords.wordSlug, Word.slug)) 13 | .where( 14 | and( 15 | eq(Word.bookSlug, sql.placeholder("bookSlug")), 16 | eq(UsersToWords.userId, sql.placeholder("userId")), 17 | ), 18 | ) 19 | .limit(sql.placeholder("limit")) 20 | .offset(sql.placeholder("offset")) 21 | .orderBy(Word.id) 22 | .prepare("prepare"); 23 | 24 | export const getDoneWordsOfBook = p.auth 25 | .input( 26 | z.object({ 27 | bookSlug: z.string(), 28 | cursor: z.number().int().default(0), 29 | }), 30 | ) 31 | .query(async ({ ctx: { userId }, input: { bookSlug, cursor } }) => { 32 | const doneWordsOfBook = await prepare.execute({ 33 | bookSlug, 34 | userId, 35 | offset: PAGE_SIZE * cursor, 36 | limit: PAGE_SIZE, 37 | }); 38 | 39 | const nextCursor = doneWordsOfBook.length ? cursor + 1 : undefined; 40 | 41 | return { doneWordsOfBook, nextCursor }; 42 | }); 43 | -------------------------------------------------------------------------------- /app/components/WordCommentItem.tsx: -------------------------------------------------------------------------------- 1 | import { Divider } from "@heroui/react"; 2 | import dayjs from "dayjs"; 3 | import { ICommentItem } from "~/common/types"; 4 | import { CommentVoteButton } from "./CommentVoteButton"; 5 | import { UserAvatar } from "./UserAvatar"; 6 | 7 | export const WordCommentItem = ({ 8 | comment: { 9 | User: { name }, 10 | Post: { content, updatedAt, id: postId }, 11 | }, 12 | }: { 13 | comment: ICommentItem; 14 | }) => { 15 | return ( 16 |
17 |
18 |
19 |
20 |
21 | 22 |
{name}
23 |
24 | 25 | {dayjs(updatedAt).format("YYYY-MM-DD HH:mm")} 26 | 27 |
28 | 29 |
{content}
30 | 31 |
32 | 33 |
34 | 35 | 36 |
37 |
38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /app/components/WordAudioButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@heroui/react"; 2 | import { useAtomValue } from "jotai"; 3 | import { PauseCircle, PlayCircle } from "lucide-react"; 4 | import { useEffect, useRef, useState } from "react"; 5 | import { wordDetailSlugAtom } from "~/common/store"; 6 | import { LuIcon } from "./LuIcon"; 7 | 8 | export const WordAudioButton = ({ word }: { word: string }) => { 9 | const wordDetailSlug = useAtomValue(wordDetailSlugAtom); 10 | const audioRef = useRef(null); 11 | const [isPlaying, setIsPlaying] = useState(false); 12 | 13 | useEffect(() => { 14 | setIsPlaying(false); 15 | }, [wordDetailSlug]); 16 | 17 | return ( 18 | <> 19 | 33 |