├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── components.json ├── eslint.config.js ├── humans.txt ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── robots.txt ├── sitemap.xml ├── src ├── api │ ├── backend │ │ ├── auth │ │ │ ├── signin.ts │ │ │ ├── signup.ts │ │ │ ├── sync.ts │ │ │ └── types.ts │ │ ├── base.ts │ │ ├── downloads │ │ │ ├── external.ts │ │ │ └── types.ts │ │ ├── search │ │ │ ├── books.ts │ │ │ ├── search.ts │ │ │ └── types.ts │ │ ├── trending │ │ │ └── trending.ts │ │ └── types.ts │ └── words.ts ├── assets │ ├── ads │ │ └── snowcore-purple.gif │ ├── apple_cat.png │ ├── discord_logo.svg │ ├── email_logo.png │ ├── github_logo.svg │ ├── loading.png │ ├── logo.svg │ ├── logo_header.png │ ├── logo_header.svg │ ├── logo_header_dark.svg │ ├── placeholder.png │ └── x_logo.svg ├── components │ ├── books │ │ ├── book-gallery.tsx │ │ ├── book-item.tsx │ │ ├── book-list.tsx │ │ ├── bookmark.tsx │ │ ├── download-button.tsx │ │ └── filters.tsx │ ├── epub-reader │ │ ├── epub-reader.tsx │ │ ├── epub-view.tsx │ │ └── toc-sheet.tsx │ ├── layout │ │ ├── clipboard-button.tsx │ │ ├── collapse-menu-button.tsx │ │ ├── footer.tsx │ │ ├── menu.tsx │ │ ├── navbar.tsx │ │ ├── scroll-to-top-button.tsx │ │ ├── sheet-menu.tsx │ │ ├── sidebar.tsx │ │ ├── theme-toggle.tsx │ │ ├── turnstile.tsx │ │ └── user-nav.tsx │ └── ui │ │ ├── alert.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── collapsible.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── image-upload-field.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── nav-link.tsx │ │ ├── progress.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ └── tooltip.tsx ├── constants.ts ├── hooks │ ├── auth │ │ ├── use-auth.ts │ │ └── use-user-data-sync.ts │ ├── use-debounce.ts │ ├── use-ismobile.ts │ └── use-layout.ts ├── lib │ ├── file.ts │ ├── layout.ts │ ├── saveAs.ts │ ├── string.ts │ ├── sync │ │ ├── index.ts │ │ └── user-data.ts │ └── utils.ts ├── main.tsx ├── routeTree.gen.ts ├── routes │ ├── __root.tsx │ ├── about.tsx │ ├── account.tsx │ ├── contact.tsx │ ├── featured.tsx │ ├── index.tsx │ ├── library.tsx │ ├── lists.tsx │ ├── login.tsx │ ├── register.tsx │ ├── settings.tsx │ └── upload.tsx ├── stores │ ├── auth.ts │ ├── bookmarks.ts │ ├── layout.ts │ ├── progress.ts │ └── settings.ts └── styles │ └── global.css ├── tailwind.config.ts ├── tooling └── github │ └── action.yml ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── types ├── global.d.ts └── vite-env.d.ts └── vite.config.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: ["*"] 6 | push: 7 | branches: ["main"] 8 | merge_group: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} 13 | 14 | jobs: 15 | lint: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Setup 21 | uses: ./tooling/github 22 | 23 | - name: Lint 24 | run: pnpm lint 25 | 26 | format: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v4 30 | 31 | - name: Setup 32 | uses: ./tooling/github 33 | 34 | - name: Format 35 | run: pnpm format 36 | 37 | typecheck: 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | 42 | - name: Setup 43 | uses: ./tooling/github 44 | 45 | - name: Typecheck 46 | run: pnpm typecheck 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | !.vscode/extensions.json 17 | .idea 18 | .DS_Store 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | src/routeTree.gen.ts 2 | pnpm-lock.yaml -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": true, 4 | "singleQuote": false, 5 | "trailingComma": "all", 6 | "printWidth": 200, 7 | "plugins": ["prettier-plugin-tailwindcss"] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "bradlc.vscode-tailwindcss"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib", 3 | "tailwindCSS.experimental.classRegex": [ 4 | ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], 5 | ["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"] 6 | ], 7 | "terminal.integrated.shellIntegration.enabled": false, 8 | "editor.codeActionsOnSave": { 9 | "source.fixAll.eslint": "explicit" 10 | }, 11 | "editor.formatOnSave": true, 12 | "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, 13 | "[typescriptreact]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode" 15 | }, 16 | "[javascript]": { 17 | "editor.defaultFormatter": "esbenp.prettier-vscode" 18 | }, 19 | "[json]": { 20 | "editor.defaultFormatter": "esbenp.prettier-vscode" 21 | }, 22 | "javascript.updateImportsOnFileMove.enabled": "always", 23 | "typescript.updateImportsOnFileMove.enabled": "always" 24 | } 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | Bookracy logo 5 | 6 | 7 | # Bookracy [Library](https://bookracy.org) 8 | 9 | ### A Shadow Library for the Digital Age 10 | 11 | Explore a vast collection of books, articles, and documents – easily accessible on any device. 12 | 13 | [![Discord server](https://img.shields.io/discord/1195734228319617024.svg?label=&labelColor=6A7EC2&color=7389D8&logo=discord&logoColor=FFFFFF)](https://discord.gg/bookracy) 14 | [![GitHub downloads](https://img.shields.io/github/downloads/bookracy/frontend/total?label=downloads&labelColor=27303D&color=0D1117&logo=github&logoColor=FFFFFF&style=flat)](https://github.com/bookracy/frontend/releases) 15 | 16 | [![CI](https://img.shields.io/github/actions/workflow/status/bookracy/frontend/build_push.yml?labelColor=27303D)](https://github.com/bookracy/frontend/actions/workflows/build_push.yml) 17 | [![License: Apache-2.0](https://img.shields.io/github/license/bookracy/frontend?labelColor=27303D&color=0877d2)](/LICENSE) 18 | [![Translation status](https://img.shields.io/weblate/progress/bookracy?labelColor=27303D&color=946300)](https://hosted.weblate.org/engage/bookracy/) 19 | 20 | ## Access Library 21 | 22 | [![Bookracy Stable](https://img.shields.io/github/release/bookracy/frontend.svg?maxAge=3600&label=Stable&labelColor=06599d&color=043b69)](https://github.com/bookracy/frontend/releases) 23 | [![Bookracy Beta](https://img.shields.io/github/v/release/bookracy/frontend-lite.svg?maxAge=3600&label=Beta&labelColor=2c2c47&color=1c1c39)](https://github.com/bookracy/frontend-lite/releases) 24 | 25 | _Compatible with all modern browsers._ 26 | 27 |
28 | 29 | ## Features 30 | 31 | - Browse and download a wide range of books, scientific documents, manga and more. 32 | - Customizable reading experience with built in epub reader, adjustable fonts, themes, and more. 33 | - Sync across devices to access your library from anywhere. 34 | - Tag, organize and share your collections 35 | - Regular updates to the catalog with new additions every hour. 36 | - Download your epubs, pdfs and mobi to use how you want offline. 37 | - Advanced search filters for easy discovery. 38 | - Plus much more... 39 | 40 | ## Getting Started 41 | 42 | ### Prerequisites 43 | 44 | Ensure you have the following installed on your local machine: 45 | 46 | - [Node.js](https://nodejs.org/) (v16 or higher recommended) 47 | - [PNPM](https://pnpm.io/) (v7 or higher recommended) 48 | 49 | ### Installation 50 | 51 | 1. Clone the repository: 52 | 53 | ```sh 54 | git clone https://github.com/bookracy/frontend.git 55 | cd frontend 56 | ``` 57 | 58 | 2. Install dependencies using PNPM: 59 | 60 | ```sh 61 | pnpm install 62 | ``` 63 | 64 | ### Running the Application 65 | 66 | Start the development server: 67 | 68 | ```sh 69 | pnpm run dev 70 | ``` 71 | 72 | The application will be available at `http://localhost:5173`. 73 | 74 | ### Building for Production 75 | 76 | To build the project for production: 77 | 78 | ```sh 79 | pnpm run build 80 | ``` 81 | 82 | This will create an optimized build in the `dist` directory. 83 | 84 | ## Contributing 85 | 86 | We welcome contributions! Please read the [Code of Conduct](./CODE_OF_CONDUCT.md) and [Contributing Guide](./CONTRIBUTING.md) before making any contributions. 87 | 88 | ### Repositories 89 | 90 | [![bookracy/frontend - GitHub](https://github-readme-stats.vercel.app/api/pin/?username=bookracy&repo=frontend&bg_color=161B22&text_color=c9d1d9&title_color=0877d2&icon_color=0877d2&border_radius=8&hide_border=true&description_lines_count=2)](https://github.com/bookracy/frontend/) 91 | [![bookracy/frontend-lite - GitHub](https://github-readme-stats.vercel.app/api/pin/?username=bookracy&repo=frontend-lite&bg_color=161B22&text_color=c9d1d9&title_color=0877d2&icon_color=0877d2&border_radius=8&hide_border=true&description_lines_count=2)](https://github.com/bookracy/frontend-lite/) 92 | 93 | ## Credits 94 | 95 | Thank you to everyone who has contributed to Bookracy! 96 | 97 | 98 | Bookracy contributors 99 | 100 | 101 | ## Disclaimer 102 | 103 | The developer(s) of this project have no affiliation with the content providers, and this library hosts no copyrighted material. 104 | 105 | ## License 106 | 107 |
108 | Copyright © 2024 The Bookracy Open Source Project
109 | 
110 | Licensed under the Apache License, Version 2.0 (the "License");
111 | you may not use this file except in compliance with the License.
112 | You may obtain a copy of the License at
113 | 
114 | http://www.apache.org/licenses/LICENSE-2.0
115 | 
116 | Unless required by applicable law or agreed to in writing, software
117 | distributed under the License is distributed on an "AS IS" BASIS,
118 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
119 | See the License for the specific language governing permissions and
120 | limitations under the License.
121 | 
122 | 123 | 124 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "./src/styles/global.css", 9 | "baseColor": "stone", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import reactHooks from "eslint-plugin-react-hooks"; 3 | import reactRefresh from "eslint-plugin-react-refresh"; 4 | import tseslint from "typescript-eslint"; 5 | import prettier from "eslint-config-prettier"; 6 | 7 | export default tseslint.config({ 8 | extends: [js.configs.recommended, ...tseslint.configs.recommended, prettier], 9 | files: ["**/*.{ts,tsx}"], 10 | ignores: ["dist"], 11 | languageOptions: { 12 | ecmaVersion: 2020, 13 | }, 14 | plugins: { 15 | "react-hooks": reactHooks, 16 | "react-refresh": reactRefresh, 17 | }, 18 | rules: { 19 | ...reactHooks.configs.recommended.rules, 20 | "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], 21 | "@typescript-eslint/no-require-imports": "off", 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /humans.txt: -------------------------------------------------------------------------------- 1 | rdwxth 2 | JorrinKievit 3 | Baddev 4 | AbdullahDaGoat -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bookracy", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint src/", 10 | "lint:fix": "eslint --fix src/", 11 | "format": "prettier --check .", 12 | "format:fix": "prettier --write .", 13 | "typecheck": "tsc --noEmit", 14 | "preview": "vite preview" 15 | }, 16 | "dependencies": { 17 | "@hookform/resolvers": "3.10.0", 18 | "@marsidev/react-turnstile": "^1.0.2", 19 | "@radix-ui/react-aspect-ratio": "^1.1.0", 20 | "@radix-ui/react-avatar": "^1.1.0", 21 | "@radix-ui/react-collapsible": "^1.1.0", 22 | "@radix-ui/react-dialog": "^1.1.1", 23 | "@radix-ui/react-dropdown-menu": "^2.1.1", 24 | "@radix-ui/react-label": "^2.1.0", 25 | "@radix-ui/react-progress": "^1.1.6", 26 | "@radix-ui/react-scroll-area": "^1.2.0-rc.7", 27 | "@radix-ui/react-select": "^2.1.1", 28 | "@radix-ui/react-slot": "^1.1.0", 29 | "@radix-ui/react-switch": "^1.1.0", 30 | "@radix-ui/react-tooltip": "^1.1.2", 31 | "@radix-ui/react-visually-hidden": "^1.1.0", 32 | "@tanstack/react-query": "^5.56.2", 33 | "@tanstack/react-router": "^1.57.13", 34 | "class-variance-authority": "^0.7.0", 35 | "clsx": "^2.1.1", 36 | "epubjs": "^0.3.93", 37 | "input-otp": "^1.2.4", 38 | "jose": "^5.9.2", 39 | "lucide-react": "^0.441.0", 40 | "ofetch": "^1.4.1", 41 | "react": "^18.3.1", 42 | "react-dom": "^18.3.1", 43 | "react-dropzone": "^14.3.8", 44 | "react-hook-form": "7.54.2", 45 | "react-spring": "^9.7.4", 46 | "react-swipeable": "^7.0.1", 47 | "sonner": "^1.5.0", 48 | "tailwind-merge": "^2.5.2", 49 | "tailwindcss-animate": "^1.0.7", 50 | "typescript-eslint": "^8.26.0", 51 | "zod": "3.24.2", 52 | "zustand": "^4.5.6" 53 | }, 54 | "devDependencies": { 55 | "@tanstack/react-query-devtools": "^5.67.2", 56 | "@tanstack/router-devtools": "^1.114.12", 57 | "@tanstack/router-plugin": "^1.114.12", 58 | "@types/node": "^22.13.10", 59 | "@types/react": "^18.3.18", 60 | "@types/react-dom": "^18.3.5", 61 | "@typescript-eslint/eslint-plugin": "^8.26.0", 62 | "@typescript-eslint/parser": "^8.26.0", 63 | "@vitejs/plugin-react": "^4.3.4", 64 | "autoprefixer": "^10.4.21", 65 | "eslint": "^9.22.0", 66 | "eslint-config-prettier": "^9.1.0", 67 | "eslint-plugin-node": "^11.1.0", 68 | "eslint-plugin-react-hooks": "5.1.0-beta-26f2496093-20240514", 69 | "eslint-plugin-react-refresh": "^0.4.19", 70 | "postcss": "^8.5.3", 71 | "prettier": "^3.5.3", 72 | "prettier-plugin-tailwindcss": "^0.6.11", 73 | "tailwindcss": "^3.4.17", 74 | "typescript": "^5.8.2", 75 | "vite": "^5.4.14" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /api/ 3 | Disallow: /private/ 4 | Disallow: /admin/ 5 | Allow: / 6 | 7 | User-agent: Googlebot 8 | Allow: / 9 | 10 | User-agent: Bingbot 11 | Allow: / 12 | 13 | User-agent: * 14 | 15 | Disallow: /scraper/ 16 | Disallow: /crawler/ 17 | Disallow: /bot/ 18 | -------------------------------------------------------------------------------- /sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | https://www.bookracy.org/ 6 | 2024-08-14 7 | daily 8 | 1.0 9 | 10 | 11 | 12 | 13 | https://www.bookracy.org/ 14 | 2024-08-14 15 | daily 16 | 0.8 17 | 18 | 19 | 20 | 21 | https://www.bookracy.org/featured 22 | 2024-08-14 23 | weekly 24 | 0.7 25 | 26 | 27 | 28 | 29 | https://www.bookracy.org/about 30 | 2024-08-14 31 | monthly 32 | 0.6 33 | 34 | 35 | 36 | 37 | https://www.bookracy.org/library 38 | 2024-08-14 39 | daily 40 | 0.5 41 | 42 | 43 | 44 | 45 | https://www.bookracy.org/settings 46 | 2024-08-14 47 | monthly 48 | 0.5 49 | 50 | 51 | 52 | 53 | https://www.bookracy.org/account 54 | 2024-08-14 55 | monthly 56 | 0.5 57 | 58 | 59 | 60 | 61 | https://www.bookracy.org/contact 62 | 2024-08-14 63 | monthly 64 | 0.6 65 | 66 | 67 | 68 | 69 | https://www.bookracy.org/upload 70 | 2024-08-14 71 | monthly 72 | 0.4 73 | 74 | 75 | 76 | 77 | https://www.bookracy.org/discord 78 | 2024-08-14 79 | monthly 80 | 0.6 81 | 82 | 83 | -------------------------------------------------------------------------------- /src/api/backend/auth/signin.ts: -------------------------------------------------------------------------------- 1 | import { client } from "../base"; 2 | import { LoginResponse } from "./types"; 3 | 4 | export const login = (body: { code: string; ttkn: string }) => { 5 | return client("/_secure/signin/identifier", { 6 | method: "POST", 7 | body, 8 | }); 9 | }; 10 | 11 | export const refresh = (refreshToken: string) => { 12 | return client("/_secure/refresh", { 13 | method: "POST", 14 | headers: { 15 | Authorization: `Bearer ${refreshToken}`, 16 | }, 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /src/api/backend/auth/signup.ts: -------------------------------------------------------------------------------- 1 | import { getPfpInBase64 } from "@/lib/file"; 2 | import { client } from "../base"; 3 | import { GenerateUserResponse, VerifyAuthKeyResponse } from "./types"; 4 | 5 | export const verifyAuthKey = (ttkn: string) => { 6 | return client("/_secure/signup/verify", { 7 | method: "POST", 8 | body: { 9 | ttkn, 10 | }, 11 | }); 12 | }; 13 | 14 | export const generateUser = async ({ username, pfp, ttkn }: { username: string; pfp?: File; ttkn: string }) => { 15 | const verifyAuthKeyResponse = await verifyAuthKey(ttkn); 16 | if (!verifyAuthKeyResponse?.stk) { 17 | throw new Error("Invalid auth key"); 18 | } 19 | 20 | let pfpInBase64: string | ArrayBuffer | null = null; 21 | if (pfp) { 22 | pfpInBase64 = await getPfpInBase64(pfp); 23 | if (!pfpInBase64) { 24 | throw new Error("Failed to read file"); 25 | } 26 | } 27 | 28 | return client("/_secure/signup/generate", { 29 | method: "POST", 30 | body: { 31 | stk: verifyAuthKeyResponse.stk, 32 | username, 33 | ...(pfpInBase64 ? { pfp: pfpInBase64 } : {}), 34 | }, 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /src/api/backend/auth/sync.ts: -------------------------------------------------------------------------------- 1 | import { getPfpInBase64 } from "@/lib/file"; 2 | import { authClient } from "../base"; 3 | 4 | export type UserData = { 5 | username: string; 6 | pfp: string; 7 | bookmarks: string[]; 8 | preferences: Record; 9 | reading_lists: Record; 10 | }; 11 | 12 | export const syncUserData = async ( 13 | data: Partial< 14 | | UserData 15 | | { 16 | pfp: File | string; 17 | } 18 | >, 19 | ) => { 20 | if (data.pfp && data.pfp instanceof File) { 21 | const pfpInBase64 = await getPfpInBase64(data.pfp); 22 | 23 | if (!pfpInBase64) { 24 | throw new Error("Failed to read file"); 25 | } 26 | 27 | data.pfp = pfpInBase64.toString(); 28 | } 29 | 30 | await authClient<{ message: string }>("/_secure/sync", { 31 | method: "POST", 32 | body: data, 33 | }); 34 | }; 35 | 36 | export const getUserData = async () => { 37 | return authClient("/_secure/get", { 38 | method: "GET", 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /src/api/backend/auth/types.ts: -------------------------------------------------------------------------------- 1 | export interface LoginResponse { 2 | access_token: string; 3 | refresh_token: string; 4 | } 5 | 6 | export interface VerifyAuthKeyResponse { 7 | stk: string; 8 | } 9 | 10 | export interface GenerateUserResponse { 11 | code: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/api/backend/base.ts: -------------------------------------------------------------------------------- 1 | import { useAuthStore } from "@/stores/auth"; 2 | import { useSettingsStore } from "@/stores/settings"; 3 | import { ofetch } from "ofetch"; 4 | import { refresh } from "./auth/signin"; 5 | import { router } from "@/main"; 6 | 7 | export type BaseResponse = { 8 | results: T[]; 9 | }; 10 | 11 | const backendURL = useSettingsStore.getState().backendURL; 12 | 13 | export const client = ofetch.create({ 14 | baseURL: backendURL + `/api`, 15 | }); 16 | 17 | export const authClient = ofetch.create({ 18 | baseURL: backendURL + `/api`, 19 | async onRequest(context) { 20 | const { accessToken, refreshToken, tokenInfo } = useAuthStore.getState(); 21 | 22 | let accessTokenToSend = accessToken; 23 | 24 | if (!tokenInfo) { 25 | useAuthStore.getState().reset(); 26 | router.invalidate(); 27 | return; 28 | } 29 | 30 | const currentTime = Math.floor(Date.now() / 1000); 31 | const expirationTime = tokenInfo.exp; 32 | const timeLeft = expirationTime - currentTime; 33 | 34 | if (timeLeft <= 10) { 35 | try { 36 | const response = await refresh(refreshToken); 37 | if (response.access_token && response.refresh_token) { 38 | const valid = useAuthStore.getState().setTokens(response.access_token, response.refresh_token); 39 | 40 | if (!valid) { 41 | useAuthStore.getState().reset(); 42 | router.invalidate(); 43 | return; 44 | } 45 | } 46 | accessTokenToSend = response.access_token; 47 | } catch { 48 | useAuthStore.getState().reset(); 49 | router.invalidate(); 50 | return; 51 | } 52 | } 53 | 54 | context.options.headers.set("Authorization", `Bearer ${accessTokenToSend}`); 55 | }, 56 | onResponseError(context) { 57 | if (context.response?.status === 401) { 58 | useAuthStore.getState().reset(); 59 | router.invalidate(); 60 | } 61 | }, 62 | }); 63 | -------------------------------------------------------------------------------- /src/api/backend/downloads/external.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery } from "@tanstack/react-query"; 2 | import { client } from "../base"; 3 | import { ExternalDownloadResponse } from "./types"; 4 | import { ofetch } from "ofetch"; 5 | 6 | export const getExternalDownloads = (md5s: string[]) => { 7 | if (md5s.length === 0) return Promise.resolve([]); 8 | return client("/books/external_downloads", { 9 | query: { 10 | md5: md5s.join(","), 11 | }, 12 | }); 13 | }; 14 | 15 | export const useExternalDownloadsQuery = (md5s: string[]) => { 16 | return useQuery({ 17 | queryKey: ["external_downloads", md5s], 18 | queryFn: () => getExternalDownloads(md5s), 19 | }); 20 | }; 21 | 22 | export const useDownloadMutation = () => { 23 | return useMutation({ 24 | mutationKey: ["download"], 25 | mutationFn: async (link: string): Promise => { 26 | if (link.includes("ipfs")) { 27 | return link; 28 | } 29 | 30 | const response = await ofetch(link, { 31 | responseType: "blob", 32 | }); 33 | 34 | return URL.createObjectURL(response); 35 | }, 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /src/api/backend/downloads/types.ts: -------------------------------------------------------------------------------- 1 | export interface ExternalDownloadLink { 2 | link: string; 3 | name: string; 4 | } 5 | 6 | export type ExternalDownloadResponse = { 7 | external_downloads: ExternalDownloadLink[]; 8 | ipfs: string[]; 9 | md5: string; 10 | }[]; 11 | -------------------------------------------------------------------------------- /src/api/backend/search/books.ts: -------------------------------------------------------------------------------- 1 | import { queryOptions } from "@tanstack/react-query"; 2 | import { BaseResponse, client } from "../base"; 3 | import { BookItem } from "../types"; 4 | 5 | export const searchBooksByMd5 = async (md5s: string[]) => { 6 | if (!md5s.length) return null; 7 | return client>("/_secure/translate", { 8 | query: { 9 | md5: md5s.join(","), 10 | }, 11 | }); 12 | }; 13 | 14 | export const searchBooksByMd5QueryOptions = (md5s: string[]) => 15 | queryOptions({ 16 | queryKey: ["search", "books", md5s], 17 | queryFn: () => searchBooksByMd5(md5s), 18 | enabled: !!md5s.length, 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/backend/search/search.ts: -------------------------------------------------------------------------------- 1 | import { queryOptions, useQuery } from "@tanstack/react-query"; 2 | import { BaseResponse, client } from "../base"; 3 | import { BookItem } from "../types"; 4 | import { SearchParams } from "./types"; 5 | import { getExternalDownloads } from "../downloads/external"; 6 | import { ExternalDownloadResponse } from "../downloads/types"; 7 | 8 | export const getBooks = (params: SearchParams) => { 9 | return client>("/books", { 10 | query: params, 11 | }); 12 | }; 13 | 14 | export const useGetBooksQuery = (params: SearchParams) => 15 | useQuery({ 16 | queryKey: ["search", params], 17 | queryFn: () => getBooks(params), 18 | enabled: params.query !== "", 19 | }); 20 | 21 | export const useGetBooksQueryWithExternalDownloads = (params: SearchParams) => { 22 | return useQuery({ 23 | queryKey: ["search", params], 24 | queryFn: async () => { 25 | const books = await getBooks(params); 26 | const externalDownloads: ExternalDownloadResponse = []; 27 | for (let i = 0; i < params.limit; i += 10) { 28 | const batch = books.results.slice(i, i + 3).map((book) => book.md5); 29 | const batchExternalDownloads = await getExternalDownloads(batch); 30 | externalDownloads.push(...batchExternalDownloads); 31 | } 32 | return { 33 | ...books, 34 | results: books.results.slice(0, params.limit).map((book) => ({ 35 | ...book, 36 | externalDownloads: externalDownloads.find((b) => b.md5 === book.md5)?.external_downloads, 37 | ipfs: externalDownloads.find((b) => b.md5 === book.md5)?.ipfs, 38 | })), 39 | }; 40 | }, 41 | enabled: params.query !== "", 42 | }); 43 | }; 44 | 45 | export const useGetBooksByMd5sQuery = (md5s: string[]) => { 46 | return useQuery(getBooksByMd5sQueryOptions(md5s)); 47 | }; 48 | 49 | export const getBooksByMd5sQueryOptions = (md5s: string[]) => { 50 | return queryOptions({ 51 | queryKey: ["search", md5s], 52 | queryFn: async () => { 53 | const books: BookItem[] = []; 54 | for (const md5 of md5s) { 55 | const response = await getBooks({ query: md5, lang: "all", limit: 1 }); 56 | books.push(response.results[0]); 57 | } 58 | return books; 59 | }, 60 | enabled: md5s.length > 0, 61 | }); 62 | }; 63 | -------------------------------------------------------------------------------- /src/api/backend/search/types.ts: -------------------------------------------------------------------------------- 1 | export interface SearchParams { 2 | query: string; 3 | lang: string; 4 | limit: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/api/backend/trending/trending.ts: -------------------------------------------------------------------------------- 1 | import { queryOptions } from "@tanstack/react-query"; 2 | import { ofetch } from "ofetch"; 3 | import { BookItem } from "../types"; 4 | import { getExternalDownloads } from "../downloads/external"; 5 | 6 | export const getTrending = async () => { 7 | return ofetch>("https://raw.githubusercontent.com/bookracy/static/main/trending.json", { 8 | parseResponse: (response) => JSON.parse(response), 9 | }); 10 | }; 11 | 12 | export const getTrendingQueryOptions = queryOptions({ 13 | queryKey: ["trending"], 14 | queryFn: async () => { 15 | const categoriesWithBooks = await getTrending(); 16 | const md5s = Object.values(categoriesWithBooks) 17 | .flat() 18 | .map((book) => book.md5); 19 | const externalDownloads = await getExternalDownloads(md5s); 20 | 21 | return Object.fromEntries( 22 | Object.entries(categoriesWithBooks).map(([category, books]) => [ 23 | category, 24 | books.map((book) => ({ 25 | ...book, 26 | externalDownloads: externalDownloads.find((b) => b.md5 === book.md5)?.external_downloads, 27 | ipfs: externalDownloads.find((b) => b.md5 === book.md5)?.ipfs, 28 | })), 29 | ]), 30 | ); 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /src/api/backend/types.ts: -------------------------------------------------------------------------------- 1 | import { ExternalDownloadLink } from "./downloads/types"; 2 | 3 | export interface BookItem { 4 | author: string; 5 | book_filetype: string; 6 | book_image: string; 7 | book_lang: string; 8 | book_length: string; 9 | book_size: string; 10 | cid: string; 11 | description: string; 12 | external_cover_url: string; 13 | id: number; 14 | isbn: string; 15 | link: string; 16 | md5: string; 17 | other_titles: string; 18 | publisher: string; 19 | series: string; 20 | title: string; 21 | year: string; 22 | } 23 | 24 | export interface BookItemWithExternalDownloads extends BookItem { 25 | externalDownloads?: ExternalDownloadLink[]; 26 | } 27 | -------------------------------------------------------------------------------- /src/api/words.ts: -------------------------------------------------------------------------------- 1 | import { queryOptions } from "@tanstack/react-query"; 2 | import { ofetch } from "ofetch"; 3 | 4 | export const fetchWords = async () => { 5 | const url = "https://preeminent-fudge-2849a2.netlify.app/?destination=https://www.mit.edu/~ecprice/wordlist.100000"; 6 | return ofetch(url); 7 | }; 8 | 9 | export const fetchWordsWithNumber = async () => { 10 | const words = (await fetchWords()).split("\n"); 11 | const wordList = []; 12 | for (let i = 0; i < 2; i++) { 13 | const randomIndex = Math.floor(Math.random() * words.length); 14 | wordList.push(words[randomIndex]); 15 | } 16 | const capitalizedWords = wordList.map((word) => word.charAt(0).toUpperCase() + word.slice(1)); 17 | const randomNumber = Math.floor(Math.random() * 101); 18 | return `${capitalizedWords.join("")}${randomNumber}`; 19 | }; 20 | 21 | export const randomWordsWithNumberQueryOptions = queryOptions({ 22 | queryKey: ["words"], 23 | queryFn: fetchWordsWithNumber, 24 | staleTime: 0, 25 | }); 26 | -------------------------------------------------------------------------------- /src/assets/ads/snowcore-purple.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookracy/frontend/326dce804df2acbac4ba473ee79f1dbedba1c3f4/src/assets/ads/snowcore-purple.gif -------------------------------------------------------------------------------- /src/assets/apple_cat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookracy/frontend/326dce804df2acbac4ba473ee79f1dbedba1c3f4/src/assets/apple_cat.png -------------------------------------------------------------------------------- /src/assets/discord_logo.svg: -------------------------------------------------------------------------------- 1 | Discord -------------------------------------------------------------------------------- /src/assets/email_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookracy/frontend/326dce804df2acbac4ba473ee79f1dbedba1c3f4/src/assets/email_logo.png -------------------------------------------------------------------------------- /src/assets/github_logo.svg: -------------------------------------------------------------------------------- 1 | GitHub -------------------------------------------------------------------------------- /src/assets/loading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookracy/frontend/326dce804df2acbac4ba473ee79f1dbedba1c3f4/src/assets/loading.png -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/logo_header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookracy/frontend/326dce804df2acbac4ba473ee79f1dbedba1c3f4/src/assets/logo_header.png -------------------------------------------------------------------------------- /src/assets/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookracy/frontend/326dce804df2acbac4ba473ee79f1dbedba1c3f4/src/assets/placeholder.png -------------------------------------------------------------------------------- /src/assets/x_logo.svg: -------------------------------------------------------------------------------- 1 | X -------------------------------------------------------------------------------- /src/components/books/book-gallery.tsx: -------------------------------------------------------------------------------- 1 | import { BookItem, BookItemWithExternalDownloads } from "@/api/backend/types"; 2 | import { BookItemDialog } from "./book-item"; 3 | 4 | interface BookGalleryProps { 5 | books: BookItemWithExternalDownloads[] | BookItem[]; 6 | } 7 | 8 | export function BookGallery({ books }: BookGalleryProps) { 9 | return ( 10 |
11 | {books.map((book) => ( 12 | 13 | ))} 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/books/book-item.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useMemo } from "react"; 2 | import { BookItem, BookItemWithExternalDownloads } from "@/api/backend/types"; 3 | import { Card, CardContent } from "../ui/card"; 4 | import PlaceholderImage from "@/assets/placeholder.png"; 5 | import { AspectRatio } from "../ui/aspect-ratio"; 6 | import { Skeleton } from "../ui/skeleton"; 7 | import { EpubReader } from "../epub-reader/epub-reader"; 8 | import { BookmarkButton } from "./bookmark"; 9 | import { BookDownloadButton } from "./download-button"; 10 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog"; 11 | import { ScrollArea } from "../ui/scroll-area"; 12 | import { Progress } from "../ui/progress"; 13 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; 14 | import { useReadingProgressStore } from "@/stores/progress"; 15 | 16 | type BookItemProps = BookItemWithExternalDownloads | BookItem; 17 | 18 | export function BookItemCard(props: BookItemProps) { 19 | const [isReaderOpen, setIsReaderOpen] = useState(false); 20 | const findReadingProgress = useReadingProgressStore((state) => state.findReadingProgress); 21 | 22 | const isEpub = Boolean(props.link?.toLowerCase().endsWith(".epub")); 23 | 24 | const progress = useMemo(() => { 25 | const progress = findReadingProgress(props.md5); 26 | if (progress && progress.totalPages > 0) { 27 | return (progress.currentPage / progress.totalPages) * 100; 28 | } 29 | }, [props.md5, findReadingProgress]); 30 | 31 | return ( 32 | 33 | 34 |
35 | 36 |
37 | 38 |
39 |
40 | 41 | {props.title} { 46 | e.currentTarget.src = PlaceholderImage; 47 | }} 48 | onClick={() => setIsReaderOpen(true)} 49 | /> 50 | 51 | {progress != null && ( 52 | 53 | 54 | 55 | 56 | 57 | 58 |

Progress: {progress!.toFixed(2)}%

59 |
60 |
61 |
62 | )} 63 |
64 |
65 |
66 |
67 |

{props.title}

68 |

By {props.author}

69 |
70 |

{props.description}

71 |

File size: {props.book_size}

72 |

File type: {props.book_filetype}

73 |

MD5: {props.md5}

74 |
75 |
76 | {"externalDownloads" in props && } 77 | {isEpub && } 78 |
79 |
80 |
81 |
82 |
83 | ); 84 | } 85 | 86 | export function SkeletonBookItem() { 87 | return ( 88 | 89 | 90 |
91 | 92 |
93 |
94 | 95 | 96 | 97 | 98 |
99 |
100 | 101 | 102 |
103 |
104 |
105 |
106 |
107 | ); 108 | } 109 | 110 | export function SkeletonBookItemGrid() { 111 | return ( 112 | 113 | 114 |
115 | 116 |
117 | 118 |
119 |
120 | 121 | 122 | 123 |
124 |
125 |
126 |
127 | ); 128 | } 129 | 130 | export function BookItemDialog(props: BookItemProps) { 131 | const [isReaderOpen, setIsReaderOpen] = useState(false); 132 | 133 | const isEpub = Boolean(props.link?.toLowerCase().endsWith(".epub")); 134 | 135 | return ( 136 | 137 |
138 | 139 | 140 |
141 | 142 | 143 | {props.title} { 148 | e.currentTarget.src = PlaceholderImage; 149 | }} 150 | /> 151 | 152 | 153 |
154 | 155 |
156 |
157 |

{props.title}

158 |

By {props.author}

159 |

{props.book_filetype}

160 |
161 |
162 |
163 |
164 |
165 | 166 | 167 | {props.title} 168 | By {props.author} 169 | 170 | 171 |
172 |

File size: {props.book_size}

173 |

File type: {props.book_filetype}

174 |

MD5: {props.md5}

175 |

{props.description}

176 |
177 |
178 | 179 | {"externalDownloads" in props && } 180 | 181 | {isEpub && } 182 | 183 |
184 |
185 | ); 186 | } 187 | -------------------------------------------------------------------------------- /src/components/books/book-list.tsx: -------------------------------------------------------------------------------- 1 | import { BookItem, BookItemWithExternalDownloads } from "@/api/backend/types"; 2 | import { BookItemCard } from "./book-item"; 3 | 4 | interface BookListProps { 5 | books: BookItemWithExternalDownloads[] | BookItem[]; 6 | } 7 | 8 | export function BookList({ books }: BookListProps) { 9 | return ( 10 |
11 | {books.map((book) => ( 12 | 13 | ))} 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/books/bookmark.tsx: -------------------------------------------------------------------------------- 1 | import { BookItem } from "@/api/backend/types"; 2 | import { useBookmarksStore } from "@/stores/bookmarks"; 3 | import { Button } from "../ui/button"; 4 | import { useMemo } from "react"; 5 | import { BookmarkMinus, BookmarkPlus } from "lucide-react"; 6 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; 7 | 8 | export function BookmarkButton({ book }: { book: BookItem }) { 9 | const bookmarks = useBookmarksStore((state) => state.bookmarks); 10 | const addBookmark = useBookmarksStore((state) => state.addBookmark); 11 | const removeBookmark = useBookmarksStore((state) => state.removeBookmark); 12 | 13 | const bookMarkedBook = useMemo(() => bookmarks.find((b) => b === book.md5), [bookmarks, book.md5]); 14 | 15 | return ( 16 | 17 | 18 | 19 | 22 | 23 | {bookMarkedBook ? "Remove bookmark" : "Add bookmark"} 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/books/download-button.tsx: -------------------------------------------------------------------------------- 1 | import { useDownloadMutation } from "@/api/backend/downloads/external"; 2 | import { Button } from "../ui/button"; 3 | import { ChevronDown, Loader2 } from "lucide-react"; 4 | import { saveAs } from "@/lib/saveAs"; 5 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu"; 6 | import { cn } from "@/lib/utils"; 7 | import { ExternalDownloadResponse } from "@/api/backend/downloads/types"; 8 | import { toast } from "sonner"; 9 | import { titleToSlug } from "@/lib/string"; 10 | 11 | interface BookDownloadButtonProps { 12 | title: string; 13 | extension: string; 14 | primaryLink?: string; 15 | externalDownloads?: ExternalDownloadResponse[number]["external_downloads"]; 16 | } 17 | 18 | export function BookDownloadButton(props: BookDownloadButtonProps) { 19 | const { mutate, isPending: isDownloading } = useDownloadMutation(); 20 | if (props.externalDownloads?.length === 0 || !props.primaryLink) return null; 21 | 22 | const handleDownload = (link?: string) => { 23 | if (!link) return; 24 | mutate(link, { 25 | onSuccess: (url) => saveAs(url, `${titleToSlug(props.title)}.${props.extension}`, link.includes("ipfs")), 26 | 27 | onError: () => toast.error("Failed to download file"), 28 | }); 29 | }; 30 | 31 | return ( 32 |
33 | 42 | {(props.externalDownloads?.length ?? 0 > 0) && ( 43 | 44 | 45 | 48 | 49 | 50 | {props.externalDownloads?.map((download) => ( 51 | handleDownload(download.link)} className="w-full text-left"> 52 | {download.name} 53 | 54 | ))} 55 | 56 | 57 | )} 58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/components/books/filters.tsx: -------------------------------------------------------------------------------- 1 | import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "../ui/select"; 2 | import { Button } from "../ui/button"; 3 | import { LayoutGrid, LayoutList } from "lucide-react"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | interface PerPageSelectProps { 7 | perPage: number; 8 | setPerPage: (perPage: number) => void; 9 | } 10 | 11 | const PER_PAGE_OPTIONS = [5, 10, 15, 30, 50] as const; 12 | 13 | export function PerPageSelect({ perPage, setPerPage }: PerPageSelectProps) { 14 | return ( 15 | 30 | ); 31 | } 32 | 33 | interface ResultViewSelectProps { 34 | view: ResultViewOptions; 35 | setView: (view: ResultViewOptions) => void; 36 | } 37 | 38 | export type ResultViewOptions = "list" | "grid"; 39 | 40 | export function ResultViewSelect({ view, setView }: ResultViewSelectProps) { 41 | return ( 42 |
43 | 75 | ); 76 | } 77 | 78 | export interface FilterProps { 79 | filters: { 80 | view: ResultViewOptions; 81 | perPage: number; 82 | }; 83 | setFilters: (filters: { view: ResultViewOptions; perPage: number }) => void; 84 | } 85 | 86 | export function Filters({ filters, setFilters }: FilterProps) { 87 | return ( 88 |
89 | setFilters({ ...filters, view })} /> 90 | setFilters({ ...filters, perPage })} /> 91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/components/epub-reader/epub-reader.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from "react"; 2 | import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; 3 | import { Button } from "../ui/button"; 4 | import { AArrowDown, AArrowUp, BookOpen, DownloadIcon, X, Loader2 } from "lucide-react"; 5 | import { ThemeToggle } from "../layout/theme-toggle"; 6 | import { useSettingsStore } from "@/stores/settings"; 7 | import { saveAs } from "@/lib/saveAs"; 8 | import Rendition from "epubjs/types/rendition"; 9 | import { ClipBoardButton } from "../layout/clipboard-button"; 10 | import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; 11 | import { TocSheet } from "./toc-sheet"; 12 | import { NavItem } from "epubjs"; 13 | import { EpubView, EpubViewInstance } from "./epub-view"; 14 | import { cn } from "@/lib/utils"; 15 | import { useSwipeable } from "react-swipeable"; 16 | import { useReadingProgressStore } from "@/stores/progress"; 17 | 18 | interface EpubReaderProps { 19 | title: string; 20 | link: string; 21 | md5: string; 22 | open: boolean; 23 | setIsOpen: (isOpen: boolean) => void; 24 | } 25 | 26 | export function EpubReader(props: EpubReaderProps) { 27 | const readerRef = useRef(null); 28 | const renditionRef = useRef(null); 29 | 30 | const findReadingProgress = useReadingProgressStore((state) => state.findReadingProgress); 31 | const setReadingProgress = useReadingProgressStore((state) => state.setReadingProgress); 32 | 33 | const [toc, setToc] = useState([]); 34 | const [location, setLocation] = useState(1); 35 | const [fontSize, setFontSize] = useState(16); 36 | const [page, setPage] = useState({ 37 | current: 0, 38 | total: 0, 39 | }); 40 | const [loading, setLoading] = useState(true); 41 | const [progress, setProgress] = useState(0); 42 | 43 | const theme = useSettingsStore((state) => state.theme); 44 | 45 | const handlers = useSwipeable({ 46 | onSwipedRight: () => readerRef.current?.prevPage(), 47 | onSwipedLeft: () => readerRef.current?.nextPage(), 48 | trackMouse: true, 49 | }); 50 | 51 | const adjustFontSize = (adjustment: number) => { 52 | setFontSize((prev) => { 53 | const newSize = Math.max(12, Math.min(36, prev + adjustment)); 54 | if (renditionRef.current) { 55 | renditionRef.current.themes.fontSize(`${newSize}px`); 56 | } 57 | return newSize; 58 | }); 59 | }; 60 | 61 | useEffect(() => { 62 | if (renditionRef.current) { 63 | renditionRef.current.themes.override("background", theme === "dark" ? "#050505" : "#fff"); 64 | renditionRef.current.themes.override("color", theme === "dark" ? "#fff" : "#050505"); 65 | } 66 | }, [theme]); 67 | 68 | useEffect(() => { 69 | if (renditionRef.current) { 70 | renditionRef.current.themes.fontSize(`${fontSize}px`); 71 | } 72 | }, [fontSize]); 73 | 74 | const handleProgress = (loaded: number, total: number) => { 75 | setProgress(Math.round((loaded / total) * 100)); 76 | }; 77 | 78 | const handleLocationChanged = useCallback( 79 | async (loc: string) => { 80 | if (renditionRef.current) { 81 | if (!renditionRef.current.book.locations.length()) { 82 | await renditionRef.current.book.locations.generate(1600); 83 | } 84 | /* @ts-expect-error missing epub types */ 85 | const currentPage = renditionRef.current.book.locations.locationFromCfi(loc) + 1; 86 | /* @ts-expect-error missing epub types */ 87 | const totalPages = renditionRef.current.book.locations.total; 88 | setPage({ 89 | current: currentPage, 90 | total: totalPages, 91 | }); 92 | 93 | if (currentPage > 0 && totalPages > 0) { 94 | setReadingProgress({ 95 | md5: props.md5, 96 | currentPage, 97 | totalPages, 98 | location: loc, 99 | }); 100 | } 101 | } 102 | }, 103 | [props.md5, setReadingProgress], 104 | ); 105 | 106 | const handleRendition = useCallback( 107 | (rendition: Rendition) => { 108 | rendition.themes.override("color", theme === "dark" ? "#fff" : "#050505"); 109 | rendition.themes.override("background", theme === "dark" ? "#050505" : "#fff"); 110 | renditionRef.current = rendition; 111 | const eventsToStopLoading = ["rendered", "relocated", "displayError", "displayed", "layout", "started"]; 112 | eventsToStopLoading.forEach((event) => { 113 | rendition.on(event, () => setLoading(false)); 114 | }); 115 | rendition.on("loading", (loaded: number, total: number) => handleProgress(loaded, total)); 116 | }, 117 | [theme], 118 | ); 119 | 120 | useEffect(() => { 121 | const readingProgress = findReadingProgress(props.md5); 122 | if (readingProgress) { 123 | setLocation(readingProgress.location); 124 | } 125 | }, [findReadingProgress, props.md5]); 126 | 127 | return ( 128 | 129 | 130 | 134 | 135 | 136 | {props.title} 137 | Read {props.title} in an interactive reader 138 | 139 | 140 |
141 |
142 |
143 | 144 |

{props.title}

145 |
146 | 147 |
148 | 149 | 152 | 155 | 158 | 159 | 162 |
163 |
164 |
165 | {/* Hack to have swipe events for the iframe */} 166 |
167 | {loading && ( 168 |
169 | 170 |

{progress}%

171 |
172 | )} 173 |
179 | 180 |
181 | 182 |
183 | {page.current === 0 || page.total === 0 ? : `${page.current}/${page.total}`} 184 |
185 | 186 |
187 | 190 | 193 |
194 |
195 |
196 | 197 |
198 | ); 199 | } 200 | -------------------------------------------------------------------------------- /src/components/epub-reader/epub-view.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from "react"; 2 | import Epub, { Book } from "epubjs"; 3 | import type { NavItem, Rendition, Location } from "epubjs"; 4 | import type { BookOptions } from "epubjs/types/book"; 5 | 6 | export type IEpubViewProps = { 7 | url: string | ArrayBuffer; 8 | epubInitOptions?: Partial; 9 | location: string | number; 10 | locationChanged(value: string): void; 11 | tocChanged?(value: NavItem[]): void; 12 | getRendition?(rendition: Rendition): void; 13 | }; 14 | 15 | export interface EpubViewInstance { 16 | nextPage: () => void; 17 | prevPage: () => void; 18 | } 19 | 20 | export const EpubView = forwardRef(({ url, epubInitOptions = {}, location, locationChanged, tocChanged, getRendition }, ref) => { 21 | const viewerRef = useRef(null); 22 | const bookRef = useRef(null); 23 | const renditionRef = useRef(null); 24 | 25 | const onLocationChange = useCallback( 26 | (loc: Location) => { 27 | locationChanged?.(`${loc.start}`); 28 | }, 29 | [locationChanged], 30 | ); 31 | 32 | const prevPage = useCallback(() => { 33 | renditionRef.current?.prev(); 34 | }, []); 35 | 36 | const nextPage = useCallback(() => { 37 | renditionRef.current?.next(); 38 | }, []); 39 | 40 | const handleKeys = useCallback( 41 | (event: KeyboardEvent) => { 42 | if (event.key === "ArrowRight") { 43 | nextPage(); 44 | } else if (event.key === "ArrowLeft") { 45 | prevPage(); 46 | } 47 | }, 48 | [prevPage, nextPage], 49 | ); 50 | 51 | const registerEvents = useCallback( 52 | (rendition: Rendition) => { 53 | rendition.on("locationChanged", onLocationChange); 54 | }, 55 | [onLocationChange], 56 | ); 57 | 58 | const initReader = useCallback(async () => { 59 | if (viewerRef.current && bookRef.current) { 60 | const rendition = bookRef.current.renderTo(viewerRef.current, { 61 | width: "100%", 62 | height: "100%", 63 | }); 64 | 65 | renditionRef.current = rendition; 66 | 67 | const { toc } = await bookRef.current.loaded.navigation; 68 | tocChanged?.(toc); 69 | 70 | if (typeof location === "string" || typeof location === "number") { 71 | rendition.display(`${location}`); 72 | } else if (toc.length > 0 && toc[0].href) { 73 | rendition.display(toc[0].href); 74 | } else { 75 | rendition.display(); 76 | } 77 | 78 | registerEvents(rendition); 79 | getRendition?.(rendition); 80 | } 81 | }, [tocChanged, location, registerEvents, getRendition]); 82 | 83 | const initBook = useCallback(() => { 84 | if (bookRef.current) { 85 | bookRef.current.destroy(); 86 | } 87 | 88 | const book = Epub(url, epubInitOptions); 89 | bookRef.current = book; 90 | initReader(); 91 | // eslint-disable-next-line react-hooks/exhaustive-deps 92 | }, []); 93 | 94 | useEffect(() => { 95 | initBook(); 96 | document.addEventListener("keyup", handleKeys); 97 | 98 | return () => { 99 | bookRef.current?.destroy(); 100 | bookRef.current = null; 101 | renditionRef.current = null; 102 | document.removeEventListener("keyup", handleKeys); 103 | }; 104 | }, [handleKeys, initBook]); 105 | 106 | useEffect(() => { 107 | if (renditionRef.current && location) { 108 | renditionRef.current.display(`${location}`); 109 | } 110 | }, [location]); 111 | 112 | useImperativeHandle(ref, () => ({ 113 | nextPage, 114 | prevPage, 115 | })); 116 | 117 | return ( 118 |
119 |
120 |
121 | ); 122 | }); 123 | -------------------------------------------------------------------------------- /src/components/epub-reader/toc-sheet.tsx: -------------------------------------------------------------------------------- 1 | import { NavItem } from "epubjs"; 2 | import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "../ui/sheet"; 3 | import { TableOfContents } from "lucide-react"; 4 | import { ScrollArea } from "../ui/scroll-area"; 5 | import { useState } from "react"; 6 | import { Button } from "../ui/button"; 7 | 8 | type TocSheetProps = { 9 | toc: NavItem[]; 10 | setLocation: (value: string) => void; 11 | }; 12 | 13 | type TocSheetItemProps = { 14 | data: NavItem; 15 | setLocation: (value: string) => void; 16 | setIsOpen: (value: boolean) => void; 17 | }; 18 | 19 | const TocSheetItem = ({ data, setLocation, setIsOpen }: TocSheetItemProps) => ( 20 |
21 | 31 | {data.subitems && data.subitems.length > 0 && ( 32 |
33 | {data.subitems.map((item, i) => ( 34 | 35 | ))} 36 |
37 | )} 38 |
39 | ); 40 | 41 | export const TocSheet = ({ toc, setLocation }: TocSheetProps) => { 42 | const [isOpen, setIsOpen] = useState(false); 43 | 44 | return ( 45 | 46 | 47 | 48 | 49 | 50 | Table of contents 51 | 52 |
53 | {toc.map((item, i) => ( 54 | 55 | ))} 56 |
57 |
58 |
59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/components/layout/clipboard-button.tsx: -------------------------------------------------------------------------------- 1 | import { ClipboardCheck, Clipboard } from "lucide-react"; 2 | import { Button } from "../ui/button"; 3 | import { cn } from "@/lib/utils"; 4 | import { useState } from "react"; 5 | import { toast } from "sonner"; 6 | 7 | interface ClipBoardButtonProps { 8 | content: string; 9 | onClick?: () => void; 10 | className?: string; 11 | } 12 | 13 | export function ClipBoardButton(props: ClipBoardButtonProps) { 14 | const [clickedOnClipBoard, setClickedOnClipBoard] = useState(false); 15 | 16 | const handleClick = () => { 17 | navigator.clipboard.writeText(props.content); 18 | setClickedOnClipBoard(true); 19 | props.onClick?.(); 20 | toast.success("Copied to clipboard"); 21 | 22 | setTimeout(() => { 23 | setClickedOnClipBoard(false); 24 | }, 2000); 25 | }; 26 | 27 | return ( 28 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/layout/collapse-menu-button.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { ChevronDown, Dot, LucideIcon } from "lucide-react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; 7 | import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip"; 8 | import { DropdownMenu, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; 9 | import { Link } from "@tanstack/react-router"; 10 | import { DropdownMenuArrow } from "@radix-ui/react-dropdown-menu"; 11 | import { Submenu } from "@/lib/layout"; 12 | 13 | interface CollapseMenuButtonProps { 14 | icon: LucideIcon; 15 | label: string; 16 | active: boolean; 17 | submenus: Submenu[]; 18 | isOpen: boolean | undefined; 19 | } 20 | 21 | export function CollapseMenuButton({ icon: Icon, label, active, submenus, isOpen }: CollapseMenuButtonProps) { 22 | const isSubmenuActive = submenus.some((submenu) => submenu.active); 23 | const [isCollapsed, setIsCollapsed] = useState(isSubmenuActive); 24 | 25 | return isOpen ? ( 26 | 27 | 28 | 41 | 42 | 43 | {submenus.map(({ href, label, active }, index) => ( 44 | 52 | ))} 53 | 54 | 55 | ) : ( 56 | 57 | 58 | 59 | 60 | 61 | 71 | 72 | 73 | 74 | {label} 75 | 76 | 77 | 78 | 79 | {label} 80 | 81 | {submenus.map(({ href, label }, index) => ( 82 | 83 | 84 |

{label}

85 | 86 |
87 | ))} 88 | 89 |
90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /src/components/layout/footer.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@tanstack/react-router"; 2 | import { MailPlus } from "lucide-react"; 3 | import GitHubLogo from "@/assets/github_logo.svg"; 4 | import DiscordLogo from "@/assets/discord_logo.svg"; 5 | import XLogo from "@/assets/x_logo.svg"; 6 | import { DISCORD_URL, GITHUB_URL, X_URL } from "@/constants"; 7 | 8 | export function Footer() { 9 | return ( 10 |
11 |
12 | 13 |

© {new Date().getFullYear()} Bookracy

14 | 15 |
16 | 17 | 18 | Github 19 | 20 | 21 | Discord 22 | 23 | 24 | Twitter 25 | 26 | 27 | 28 | 29 | 30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/layout/menu.tsx: -------------------------------------------------------------------------------- 1 | import { LogOut } from "lucide-react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { useLayout } from "@/hooks/use-layout"; 5 | import { Button } from "@/components/ui/button"; 6 | import { ScrollArea } from "@/components/ui/scroll-area"; 7 | import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip"; 8 | import { Link, useRouteContext } from "@tanstack/react-router"; 9 | import { CollapseMenuButton } from "./collapse-menu-button"; 10 | import { useAuth } from "@/hooks/auth/use-auth"; 11 | 12 | interface MenuProps { 13 | isOpen: boolean | undefined; 14 | closeSheetMenu?: () => void; 15 | } 16 | 17 | export function Menu({ isOpen, closeSheetMenu }: MenuProps) { 18 | const { menuList } = useLayout(); 19 | const { handleLogout } = useAuth(); 20 | const routeContext = useRouteContext({ 21 | from: "__root__", 22 | }); 23 | 24 | return ( 25 | 26 | 90 | 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /src/components/layout/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { SheetMenu } from "./sheet-menu"; 3 | import { useLayout } from "@/hooks/use-layout"; 4 | import { useLayoutStore } from "@/stores/layout"; 5 | import { ThemeToggle } from "./theme-toggle"; 6 | import { UserNav } from "./user-nav"; 7 | 8 | export function Navbar() { 9 | const { pageTitle } = useLayout(); 10 | const pageTitleFromStore = useLayoutStore((state) => state.page.title); 11 | const setPageTitle = useLayoutStore((state) => state.page.setTitle); 12 | 13 | useEffect(() => { 14 | setPageTitle(pageTitle ?? ""); 15 | }, [pageTitle, setPageTitle]); 16 | 17 | return ( 18 |
19 |
20 |
21 | 22 |

{pageTitle ?? pageTitleFromStore}

23 |
24 |
25 | 26 | 27 |
28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/layout/scroll-to-top-button.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Button } from "../ui/button"; 3 | import { ArrowUpIcon } from "lucide-react"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | export function ScrollToTopButton() { 7 | const [isVisible, setIsVisible] = useState(false); 8 | 9 | const scrollToTop = () => { 10 | window.scrollTo({ 11 | top: 0, 12 | behavior: "smooth", 13 | }); 14 | }; 15 | 16 | useEffect(() => { 17 | const toggleVisibility = () => { 18 | setIsVisible(window.scrollY > 300); 19 | }; 20 | window.addEventListener("scroll", toggleVisibility); 21 | return () => window.removeEventListener("scroll", toggleVisibility); 22 | }, []); 23 | 24 | return ( 25 |
31 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/layout/sheet-menu.tsx: -------------------------------------------------------------------------------- 1 | import { MenuIcon } from "lucide-react"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Sheet, SheetHeader, SheetContent, SheetTrigger } from "@/components/ui/sheet"; 4 | import { Link } from "@tanstack/react-router"; 5 | import { Menu } from "./menu"; 6 | 7 | import LogoHeader from "@/assets/logo_header.svg"; 8 | import LogoHeaderDark from "@/assets/logo_header_dark.svg"; 9 | import { useSettingsStore } from "@/stores/settings"; 10 | import { useState } from "react"; 11 | 12 | export function SheetMenu() { 13 | const [isOpen, setIsOpen] = useState(false); 14 | const theme = useSettingsStore((state) => state.theme); 15 | 16 | return ( 17 | 18 | 19 | 22 | 23 | 24 | 25 | 30 | 31 | setIsOpen(false)} /> 32 |
33 | 36 |
37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/layout/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronLeft } from "lucide-react"; 2 | import { cn } from "@/lib/utils"; 3 | import { Button } from "@/components/ui/button"; 4 | import { useLayoutStore } from "@/stores/layout"; 5 | import { Link } from "@tanstack/react-router"; 6 | import { Menu } from "./menu"; 7 | import { useSettingsStore } from "@/stores/settings"; 8 | 9 | import Logo from "@/assets/logo.svg"; 10 | 11 | interface SidebarToggleProps { 12 | isOpen: boolean | undefined; 13 | setIsOpen?: () => void; 14 | } 15 | 16 | function SidebarToggle({ isOpen, setIsOpen }: SidebarToggleProps) { 17 | return ( 18 |
19 | 22 |
23 | ); 24 | } 25 | 26 | export function Sidebar() { 27 | const theme = useSettingsStore((state) => state.theme); 28 | const sidebar = useLayoutStore((state) => state.sidebar); 29 | 30 | return ( 31 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/components/layout/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { Moon, Sun } from "lucide-react"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip"; 4 | import { useSettingsStore } from "@/stores/settings"; 5 | import { cn } from "@/lib/utils"; 6 | import { useRouteContext } from "@tanstack/react-router"; 7 | 8 | interface ThemeToggleProps { 9 | className?: string; 10 | } 11 | 12 | export function ThemeToggle(props: ThemeToggleProps) { 13 | const theme = useSettingsStore((state) => state.theme); 14 | const setTheme = useSettingsStore((state) => state.setTheme); 15 | const auth = useRouteContext({ 16 | from: "__root__", 17 | }).auth; 18 | 19 | if (auth.isLoggedIn) return null; 20 | 21 | return ( 22 | 23 | 24 | 25 | 30 | 31 | Switch Theme 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/layout/turnstile.tsx: -------------------------------------------------------------------------------- 1 | import { useSettingsStore } from "@/stores/settings"; 2 | import { Turnstile, TurnstileInstance, TurnstileProps } from "@marsidev/react-turnstile"; 3 | import { Optional } from "@tanstack/react-query"; 4 | import * as React from "react"; 5 | 6 | type TurnstileWidgetProps = Optional; 7 | 8 | export const TurnstileWidget = React.forwardRef((props, ref) => { 9 | const theme = useSettingsStore((state) => state.theme); 10 | 11 | return ( 12 | 20 | ); 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/layout/user-nav.tsx: -------------------------------------------------------------------------------- 1 | import { LogOut, Moon, Sun } from "lucide-react"; 2 | import { useEffect } from "react"; 3 | import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger } from "../ui/dropdown-menu"; 4 | import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; 5 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; 6 | import { Button } from "../ui/button"; 7 | import { useAuth } from "@/hooks/auth/use-auth"; 8 | import Logo from "@/assets/logo.svg"; 9 | import { useRouteContext } from "@tanstack/react-router"; 10 | import { useSettingsStore } from "@/stores/settings"; 11 | 12 | export function UserNav() { 13 | const { handleLogout } = useAuth(); 14 | const theme = useSettingsStore((state) => state.theme); 15 | const setTheme = useSettingsStore((state) => state.setTheme); 16 | 17 | const auth = useRouteContext({ 18 | from: "__root__", 19 | }).auth; 20 | 21 | useEffect(() => { 22 | window.addEventListener("keydown", (e) => { 23 | if (e.key === "q" && (e.ctrlKey || e.metaKey)) { 24 | handleLogout(); 25 | } 26 | }); 27 | 28 | return () => { 29 | window.removeEventListener("keydown", () => {}); 30 | }; 31 | }); 32 | 33 | if (!auth.isLoggedIn) return null; 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | 49 | 50 | 51 | Profile 52 | 53 | 54 | 55 | 56 | 57 |
58 |

{auth.user?.username}

59 |
60 |
61 | 62 | 63 | setTheme(theme === "dark" ? "light" : "dark")}> 64 | {theme === "dark" ? : } 65 | Switch theme 66 | 67 | 68 | 69 | Log out 70 | ⇧⌘Q 71 | 72 | 73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const alertVariants = cva("relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", { 7 | variants: { 8 | variant: { 9 | default: "bg-background text-foreground", 10 | destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 11 | }, 12 | }, 13 | defaultVariants: { 14 | variant: "default", 15 | }, 16 | }); 17 | 18 | const Alert = React.forwardRef & VariantProps>(({ className, variant, ...props }, ref) => ( 19 |
20 | )); 21 | Alert.displayName = "Alert"; 22 | 23 | const AlertTitle = React.forwardRef>(({ className, ...props }, ref) => ( 24 |
25 | )); 26 | AlertTitle.displayName = "AlertTitle"; 27 | 28 | const AlertDescription = React.forwardRef>(({ className, ...props }, ref) => ( 29 |
30 | )); 31 | AlertDescription.displayName = "AlertDescription"; 32 | 33 | export { Alert, AlertTitle, AlertDescription }; 34 | -------------------------------------------------------------------------------- /src/components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; 2 | 3 | const AspectRatio = AspectRatioPrimitive.Root; 4 | 5 | export { AspectRatio }; 6 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Avatar = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( 7 | 8 | )); 9 | Avatar.displayName = AvatarPrimitive.Root.displayName; 10 | 11 | const AvatarImage = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( 12 | 13 | )); 14 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 15 | 16 | const AvatarFallback = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( 17 | 18 | )); 19 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 20 | 21 | export { Avatar, AvatarImage, AvatarFallback }; 22 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | import { Loader2 } from "lucide-react"; 7 | 8 | const buttonVariants = cva( 9 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background hover:scale-[101%] transition-transform duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 10 | { 11 | variants: { 12 | variant: { 13 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 14 | destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 16 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", 17 | ghost: "hover:bg-accent hover:text-accent-foreground", 18 | link: "text-primary underline-offset-4 hover:underline", 19 | confirm: "bg-green-500 text-white hover:bg-green-600", 20 | blue: "bg-blue-500 text-primary-foreground hover:bg-blue-600", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | }, 34 | ); 35 | 36 | export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { 37 | asChild?: boolean; 38 | loading?: boolean; 39 | } 40 | 41 | const Button = React.forwardRef(({ className, loading, variant, size, asChild = false, children, ...props }, ref) => { 42 | if (asChild) { 43 | return ( 44 | 45 | <> 46 | {React.Children.map(children as React.ReactElement, (child: React.ReactElement) => { 47 | return React.cloneElement(child, { 48 | className: cn(buttonVariants({ variant, size }), className), 49 | children: ( 50 | <> 51 | {loading && } 52 | {child.props.children} 53 | 54 | ), 55 | }); 56 | })} 57 | 58 | 59 | ); 60 | } 61 | 62 | return ( 63 | 69 | ); 70 | }); 71 | Button.displayName = "Button"; 72 | 73 | export { Button, buttonVariants }; 74 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Card = React.forwardRef>(({ className, ...props }, ref) => ( 6 |
7 | )); 8 | Card.displayName = "Card"; 9 | 10 | const CardHeader = React.forwardRef>(({ className, ...props }, ref) => ( 11 |
12 | )); 13 | CardHeader.displayName = "CardHeader"; 14 | 15 | const CardTitle = React.forwardRef>(({ className, ...props }, ref) => ( 16 |

17 | )); 18 | CardTitle.displayName = "CardTitle"; 19 | 20 | const CardDescription = React.forwardRef>(({ className, ...props }, ref) => ( 21 |

22 | )); 23 | CardDescription.displayName = "CardDescription"; 24 | 25 | const CardContent = React.forwardRef>(({ className, ...props }, ref) =>

); 26 | CardContent.displayName = "CardContent"; 27 | 28 | const CardFooter = React.forwardRef>(({ className, ...props }, ref) => ( 29 |
30 | )); 31 | CardFooter.displayName = "CardFooter"; 32 | 33 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; 34 | -------------------------------------------------------------------------------- /src/components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; 2 | 3 | const Collapsible = CollapsiblePrimitive.Root; 4 | 5 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; 6 | 7 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; 8 | 9 | export { Collapsible, CollapsibleTrigger, CollapsibleContent }; 10 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 3 | import { X } from "lucide-react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const Dialog = DialogPrimitive.Root; 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger; 10 | 11 | const DialogPortal = DialogPrimitive.Portal; 12 | 13 | const DialogClose = DialogPrimitive.Close; 14 | 15 | const DialogOverlay = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( 16 | 21 | )); 22 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 23 | 24 | const DialogContent = React.forwardRef< 25 | React.ElementRef, 26 | React.ComponentPropsWithoutRef & { 27 | includeClose?: boolean; 28 | } 29 | >(({ className, includeClose = true, children, ...props }, ref) => ( 30 | 31 | 32 | 40 | {children} 41 | {includeClose && ( 42 | 43 | 44 | Close 45 | 46 | )} 47 | 48 | 49 | )); 50 | DialogContent.displayName = DialogPrimitive.Content.displayName; 51 | 52 | const DialogHeader = ({ className, ...props }: React.HTMLAttributes) =>
; 53 | DialogHeader.displayName = "DialogHeader"; 54 | 55 | const DialogFooter = ({ className, ...props }: React.HTMLAttributes) =>
; 56 | DialogFooter.displayName = "DialogFooter"; 57 | 58 | const DialogTitle = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( 59 | 60 | )); 61 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 62 | 63 | const DialogDescription = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( 64 | 65 | )); 66 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 67 | 68 | export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription }; 69 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; 3 | import { Check, ChevronRight, Circle } from "lucide-react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const DropdownMenu = DropdownMenuPrimitive.Root; 8 | 9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 10 | 11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group; 12 | 13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 14 | 15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub; 16 | 17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 18 | 19 | const DropdownMenuSubTrigger = React.forwardRef< 20 | React.ElementRef, 21 | React.ComponentPropsWithoutRef & { 22 | inset?: boolean; 23 | } 24 | >(({ className, inset, children, ...props }, ref) => ( 25 | 30 | {children} 31 | 32 | 33 | )); 34 | DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; 35 | 36 | const DropdownMenuSubContent = React.forwardRef, React.ComponentPropsWithoutRef>( 37 | ({ className, ...props }, ref) => ( 38 | 46 | ), 47 | ); 48 | DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; 49 | 50 | const DropdownMenuContent = React.forwardRef, React.ComponentPropsWithoutRef>( 51 | ({ className, sideOffset = 4, ...props }, ref) => ( 52 | 53 | 62 | 63 | ), 64 | ); 65 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; 66 | 67 | const DropdownMenuItem = React.forwardRef< 68 | React.ElementRef, 69 | React.ComponentPropsWithoutRef & { 70 | inset?: boolean; 71 | } 72 | >(({ className, inset, ...props }, ref) => ( 73 | 82 | )); 83 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; 84 | 85 | const DropdownMenuCheckboxItem = React.forwardRef, React.ComponentPropsWithoutRef>( 86 | ({ className, children, checked, ...props }, ref) => ( 87 | 96 | 97 | 98 | 99 | 100 | 101 | {children} 102 | 103 | ), 104 | ); 105 | DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; 106 | 107 | const DropdownMenuRadioItem = React.forwardRef, React.ComponentPropsWithoutRef>( 108 | ({ className, children, ...props }, ref) => ( 109 | 117 | 118 | 119 | 120 | 121 | 122 | {children} 123 | 124 | ), 125 | ); 126 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; 127 | 128 | const DropdownMenuLabel = React.forwardRef< 129 | React.ElementRef, 130 | React.ComponentPropsWithoutRef & { 131 | inset?: boolean; 132 | } 133 | >(({ className, inset, ...props }, ref) => ); 134 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; 135 | 136 | const DropdownMenuSeparator = React.forwardRef, React.ComponentPropsWithoutRef>( 137 | ({ className, ...props }, ref) => , 138 | ); 139 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; 140 | 141 | const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { 142 | return ; 143 | }; 144 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; 145 | 146 | export { 147 | DropdownMenu, 148 | DropdownMenuTrigger, 149 | DropdownMenuContent, 150 | DropdownMenuItem, 151 | DropdownMenuCheckboxItem, 152 | DropdownMenuRadioItem, 153 | DropdownMenuLabel, 154 | DropdownMenuSeparator, 155 | DropdownMenuShortcut, 156 | DropdownMenuGroup, 157 | DropdownMenuPortal, 158 | DropdownMenuSub, 159 | DropdownMenuSubContent, 160 | DropdownMenuSubTrigger, 161 | DropdownMenuRadioGroup, 162 | }; 163 | -------------------------------------------------------------------------------- /src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as LabelPrimitive from "@radix-ui/react-label"; 3 | import { Slot } from "@radix-ui/react-slot"; 4 | import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from "react-hook-form"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | import { Label } from "@/components/ui/label"; 8 | 9 | const Form = FormProvider; 10 | 11 | type FormFieldContextValue = FieldPath> = { 12 | name: TName; 13 | }; 14 | 15 | const FormFieldContext = React.createContext({} as FormFieldContextValue); 16 | 17 | const FormField = = FieldPath>({ ...props }: ControllerProps) => { 18 | return ( 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | const useFormField = () => { 26 | const fieldContext = React.useContext(FormFieldContext); 27 | const itemContext = React.useContext(FormItemContext); 28 | const { getFieldState, formState } = useFormContext(); 29 | 30 | const fieldState = getFieldState(fieldContext.name, formState); 31 | 32 | if (!fieldContext) { 33 | throw new Error("useFormField should be used within "); 34 | } 35 | 36 | const { id } = itemContext; 37 | 38 | return { 39 | id, 40 | name: fieldContext.name, 41 | formItemId: `${id}-form-item`, 42 | formDescriptionId: `${id}-form-item-description`, 43 | formMessageId: `${id}-form-item-message`, 44 | ...fieldState, 45 | }; 46 | }; 47 | 48 | type FormItemContextValue = { 49 | id: string; 50 | }; 51 | 52 | const FormItemContext = React.createContext({} as FormItemContextValue); 53 | 54 | const FormItem = React.forwardRef>(({ className, ...props }, ref) => { 55 | const id = React.useId(); 56 | 57 | return ( 58 | 59 |
60 | 61 | ); 62 | }); 63 | FormItem.displayName = "FormItem"; 64 | 65 | const FormLabel = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => { 66 | const { error, formItemId } = useFormField(); 67 | 68 | return