├── .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 |
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 | [](https://discord.gg/bookracy)
14 | [](https://github.com/bookracy/frontend/releases)
15 |
16 | [](https://github.com/bookracy/frontend/actions/workflows/build_push.yml)
17 | [](/LICENSE)
18 | [](https://hosted.weblate.org/engage/bookracy/)
19 |
20 | ## Access Library
21 |
22 | [](https://github.com/bookracy/frontend/releases)
23 | [](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 | [](https://github.com/bookracy/frontend/)
91 | [](https://github.com/bookracy/frontend-lite/)
92 |
93 | ## Credits
94 |
95 | Thank you to everyone who has contributed to Bookracy!
96 |
97 |
98 |
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 | {
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 | {
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 | (bookMarkedBook ? removeBookmark(bookMarkedBook) : addBookmark(book.md5))} className="flex items-center gap-2">
20 | {bookMarkedBook ? : }
21 |
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 | 0,
36 | })}
37 | onClick={() => handleDownload(props.primaryLink)}
38 | >
39 | {isDownloading && }
40 | {!isDownloading ? "Download" : ""}
41 |
42 | {(props.externalDownloads?.length ?? 0 > 0) && (
43 |
44 |
45 |
46 |
47 |
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 | setPerPage(Number(value))}>
16 |
17 |
18 |
19 |
20 |
21 | Per page
22 | {PER_PAGE_OPTIONS.map((option) => (
23 |
24 | {option}
25 |
26 | ))}
27 |
28 |
29 |
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 |
50 |
setView("list")}
58 | aria-pressed={view === "list"}
59 | >
60 |
61 |
62 |
setView("grid")}
70 | aria-pressed={view === "grid"}
71 | >
72 |
73 |
74 |
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 |
131 |
132 | Open
133 |
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 |
saveAs(props.link)} variant="outline" size="icon">
150 |
151 |
152 |
adjustFontSize(4)} className="flex items-center" variant="outline" size="icon">
153 |
154 |
155 |
adjustFontSize(-4)} className="flex items-center" variant="outline" size="icon">
156 |
157 |
158 |
159 |
props.setIsOpen(false)} variant="outline" size="icon">
160 |
161 |
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 | readerRef.current?.prevPage()} className="w-32">
188 | Previous
189 |
190 | readerRef.current?.nextPage()} className="w-32">
191 | Next
192 |
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 |
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 |
{
23 | setLocation(data.href);
24 | setIsOpen(false);
25 | }}
26 | variant="ghost"
27 | className="w-full justify-start"
28 | >
29 | {data.label}
30 |
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 |
29 |
34 |
39 | Copy to clipboard
40 |
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 |
29 |
30 |
31 |
32 |
33 |
34 |
{label}
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | {submenus.map(({ href, label, active }, index) => (
44 |
45 |
46 |
47 |
48 |
49 | {label}
50 |
51 |
52 | ))}
53 |
54 |
55 | ) : (
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
{label}
68 |
69 |
70 |
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 |
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 |
27 |
28 | {menuList.map(({ groupLabel, menus }, index) => (
29 |
30 | {isOpen && {groupLabel}
}
31 | {menus.map(({ href, label, icon: Icon, active, submenus, disabled }, index) =>
32 | submenus.length === 0 ? (
33 |
34 |
35 |
36 |
37 | closeSheetMenu?.()}>
38 |
39 |
44 |
45 |
46 |
52 | {label}
53 |
54 |
55 |
56 |
57 | {isOpen === false && {label} }
58 | {disabled && Coming soon }
59 |
60 |
61 |
62 | ) : (
63 |
64 |
65 |
66 | ),
67 | )}
68 |
69 | ))}
70 |
71 | {routeContext.auth.isLoggedIn ? (
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | Log out
81 |
82 |
83 | {isOpen === false && Log out }
84 |
85 |
86 |
87 | ) : null}
88 |
89 |
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 |
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 |
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 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | setIsOpen(false)}>
27 | {theme === "dark" ? : }
28 |
29 |
30 |
31 | setIsOpen(false)} />
32 |
33 |
window.open("https://snowcore.io/ref?bookracy", "_blank")}>
34 |
35 |
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 | setIsOpen?.()} className="h-8 w-8 rounded-md" variant="outline" size="icon">
20 |
21 |
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 |
32 |
33 |
34 |
35 |
36 | {theme === "dark" ? (
37 |
38 |
39 | {sidebar.isOpen && (
40 |
41 |
Bookracy
42 |
Why pay for knowledge?
43 |
44 | )}
45 |
46 | ) : (
47 |
48 |
49 | {sidebar.isOpen && (
50 |
51 |
Bookracy
52 |
Why pay for knowledge?
53 |
54 | )}
55 |
56 | )}
57 |
58 |
59 |
60 |
61 |
62 |
window.open("https://snowcore.io/ref?bookracy", "_blank")}>
63 |
64 |
65 |
66 |
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 | setTheme(theme === "dark" ? "light" : "dark")}>
26 |
27 |
28 | Switch Theme
29 |
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 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
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 |
64 | <>
65 | {loading && }
66 | {children}
67 | >
68 |
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 ;
69 | });
70 | FormLabel.displayName = "FormLabel";
71 |
72 | const FormControl = React.forwardRef, React.ComponentPropsWithoutRef>(({ ...props }, ref) => {
73 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
74 |
75 | return ;
76 | });
77 | FormControl.displayName = "FormControl";
78 |
79 | const FormDescription = React.forwardRef>(({ className, ...props }, ref) => {
80 | const { formDescriptionId } = useFormField();
81 |
82 | return
;
83 | });
84 | FormDescription.displayName = "FormDescription";
85 |
86 | const FormMessage = React.forwardRef>(({ className, children, ...props }, ref) => {
87 | const { error, formMessageId } = useFormField();
88 | const body = error ? String(error?.message) : children;
89 |
90 | if (!body) {
91 | return null;
92 | }
93 |
94 | return (
95 |
96 | {body}
97 |
98 | );
99 | });
100 | FormMessage.displayName = "FormMessage";
101 |
102 | export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };
103 |
--------------------------------------------------------------------------------
/src/components/ui/image-upload-field.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from "react";
2 | import { useDropzone } from "react-dropzone";
3 | import { UploadCloud, X } from "lucide-react";
4 | import { cn } from "@/lib/utils";
5 | import { Button } from "@/components/ui/button";
6 |
7 | interface ImageUploaderProps {
8 | value?: File | string;
9 | onChange: (file?: File) => void;
10 | disabled?: boolean;
11 | className?: string;
12 | }
13 |
14 | export function ImageUploader({ value, onChange, disabled = false, className }: ImageUploaderProps) {
15 | const [preview, setPreview] = useState(typeof value === "string" ? value : undefined);
16 |
17 | const onDrop = useCallback(
18 | (acceptedFiles: File[]) => {
19 | if (acceptedFiles?.length > 0) {
20 | const file = acceptedFiles[0];
21 | onChange(file);
22 |
23 | setPreview(URL.createObjectURL(file));
24 | return () => {
25 | URL.revokeObjectURL(preview || "");
26 | };
27 | }
28 | },
29 | [onChange, preview],
30 | );
31 |
32 | const { getRootProps, getInputProps, isDragActive } = useDropzone({
33 | onDrop,
34 | accept: {
35 | "image/*": [".jpeg", ".jpg", ".png", ".gif", ".webp"],
36 | },
37 | maxFiles: 1,
38 | disabled,
39 | });
40 |
41 | const removeImage = () => {
42 | setPreview(undefined);
43 | onChange(undefined);
44 | };
45 |
46 | return (
47 |
48 | {preview ? (
49 |
50 |
{
55 | const input = document.createElement("input");
56 | input.type = "file";
57 | input.accept = "image/*";
58 | input.onchange = (e) => {
59 | const file = (e.target as HTMLInputElement).files?.[0];
60 | if (file) {
61 | onDrop([file]);
62 | }
63 | };
64 | input.click();
65 | }}
66 | />
67 |
68 |
69 |
70 | Remove image
71 |
72 |
73 |
74 | ) : (
75 |
83 |
84 |
85 |
{isDragActive ? "Drop image here" : "Drag and drop an image, or click to select"}
86 |
JPG, PNG, GIF or WEBP (max. 1MB)
87 |
88 | )}
89 |
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/src/components/ui/input-otp.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { OTPInput, OTPInputContext } from "input-otp";
3 | import { Dot } from "lucide-react";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const InputOTP = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, containerClassName, ...props }, ref) => (
8 |
9 | ));
10 | InputOTP.displayName = "InputOTP";
11 |
12 | const InputOTPGroup = React.forwardRef, React.ComponentPropsWithoutRef<"div">>(({ className, ...props }, ref) => (
13 |
14 | ));
15 | InputOTPGroup.displayName = "InputOTPGroup";
16 |
17 | const InputOTPSlot = React.forwardRef, React.ComponentPropsWithoutRef<"div"> & { index: number }>(({ index, className, ...props }, ref) => {
18 | const inputOTPContext = React.useContext(OTPInputContext);
19 | const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
20 |
21 | return (
22 |
31 | {char}
32 | {hasFakeCaret && (
33 |
36 | )}
37 |
38 | );
39 | });
40 | InputOTPSlot.displayName = "InputOTPSlot";
41 |
42 | const InputOTPSeparator = React.forwardRef, React.ComponentPropsWithoutRef<"div">>(({ ...props }, ref) => (
43 |
44 |
45 |
46 | ));
47 | InputOTPSeparator.displayName = "InputOTPSeparator";
48 |
49 | export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
50 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cn } from "@/lib/utils";
3 |
4 | export type InputProps = React.InputHTMLAttributes & {
5 | iconLeft?: React.ReactNode;
6 | iconRight?: React.ReactNode;
7 | };
8 |
9 | const Input = React.forwardRef(({ className, type = "text", iconLeft, iconRight, ...props }, ref) => {
10 | return (
11 |
12 | {iconLeft &&
{iconLeft}
}
13 | {iconRight &&
{iconRight}
}
14 |
24 |
25 | );
26 | });
27 |
28 | Input.displayName = "Input";
29 |
30 | export { Input };
31 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as LabelPrimitive from "@radix-ui/react-label";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
8 |
9 | const Label = React.forwardRef, React.ComponentPropsWithoutRef & VariantProps>(
10 | ({ className, ...props }, ref) => ,
11 | );
12 | Label.displayName = LabelPrimitive.Root.displayName;
13 |
14 | export { Label };
15 |
--------------------------------------------------------------------------------
/src/components/ui/nav-link.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@tanstack/react-router";
2 | import { ReactNode } from "react";
3 | import { FileRouteTypes } from "@/routeTree.gen";
4 |
5 | interface NavLinkProps {
6 | to: FileRouteTypes["fullPaths"] | (string & {});
7 | target?: string;
8 | children: ReactNode;
9 | }
10 |
11 | function isExternalLink(url: string): boolean {
12 | return /^(https?:)?\/\//.test(url);
13 | }
14 |
15 | export function NavLink({ to, target, children }: NavLinkProps) {
16 | if (isExternalLink(to)) {
17 | return (
18 |
19 | {children}
20 |
21 | );
22 | }
23 |
24 | return (
25 |
26 |
27 | {children}
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ProgressPrimitive from "@radix-ui/react-progress";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const Progress = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, value, ...props }, ref) => (
7 |
8 |
9 |
10 | ));
11 | Progress.displayName = ProgressPrimitive.Root.displayName;
12 |
13 | export { Progress };
14 |
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const ScrollArea = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, children, ...props }, ref) => (
7 |
8 | {children}
9 |
10 |
11 |
12 | ));
13 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
14 |
15 | const ScrollBar = React.forwardRef, React.ComponentPropsWithoutRef>(
16 | ({ className, orientation = "vertical", ...props }, ref) => (
17 |
28 |
29 |
30 | ),
31 | );
32 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
33 |
34 | export { ScrollArea, ScrollBar };
35 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as SelectPrimitive from "@radix-ui/react-select";
3 | import { Check, ChevronDown, ChevronUp } from "lucide-react";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const Select = SelectPrimitive.Root;
8 |
9 | const SelectGroup = SelectPrimitive.Group;
10 |
11 | const SelectValue = SelectPrimitive.Value;
12 |
13 | const SelectTrigger = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, children, ...props }, ref) => (
14 | span]:line-clamp-1",
18 | className,
19 | )}
20 | {...props}
21 | >
22 | {children}
23 |
24 |
25 |
26 |
27 | ));
28 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
29 |
30 | const SelectScrollUpButton = React.forwardRef, React.ComponentPropsWithoutRef>(
31 | ({ className, ...props }, ref) => (
32 |
33 |
34 |
35 | ),
36 | );
37 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
38 |
39 | const SelectScrollDownButton = React.forwardRef, React.ComponentPropsWithoutRef>(
40 | ({ className, ...props }, ref) => (
41 |
42 |
43 |
44 | ),
45 | );
46 | SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
47 |
48 | const SelectContent = React.forwardRef, React.ComponentPropsWithoutRef>(
49 | ({ className, children, position = "popper", ...props }, ref) => (
50 |
51 |
61 |
62 |
63 | {children}
64 |
65 |
66 |
67 |
68 | ),
69 | );
70 | SelectContent.displayName = SelectPrimitive.Content.displayName;
71 |
72 | const SelectLabel = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => (
73 |
74 | ));
75 | SelectLabel.displayName = SelectPrimitive.Label.displayName;
76 |
77 | const SelectItem = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, children, ...props }, ref) => (
78 |
86 |
87 |
88 |
89 |
90 |
91 |
92 | {children}
93 |
94 | ));
95 | SelectItem.displayName = SelectPrimitive.Item.displayName;
96 |
97 | const SelectSeparator = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => (
98 |
99 | ));
100 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
101 |
102 | export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton };
103 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as SheetPrimitive from "@radix-ui/react-dialog";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 | import { X } from "lucide-react";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Sheet = SheetPrimitive.Root;
9 |
10 | const SheetTrigger = SheetPrimitive.Trigger;
11 |
12 | const SheetClose = SheetPrimitive.Close;
13 |
14 | const SheetPortal = SheetPrimitive.Portal;
15 |
16 | const SheetOverlay = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => (
17 |
22 | ));
23 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
24 |
25 | const sheetVariants = cva(
26 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
27 | {
28 | variants: {
29 | side: {
30 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
31 | bottom: "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
32 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
33 | right: "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
34 | },
35 | },
36 | defaultVariants: {
37 | side: "right",
38 | },
39 | },
40 | );
41 |
42 | interface SheetContentProps extends React.ComponentPropsWithoutRef, VariantProps {}
43 |
44 | const SheetContent = React.forwardRef, SheetContentProps>(({ side = "right", className, children, ...props }, ref) => (
45 |
46 |
47 |
48 | {children}
49 |
50 |
51 | Close
52 |
53 |
54 |
55 | ));
56 | SheetContent.displayName = SheetPrimitive.Content.displayName;
57 |
58 | const SheetHeader = ({ className, ...props }: React.HTMLAttributes) =>
;
59 | SheetHeader.displayName = "SheetHeader";
60 |
61 | const SheetFooter = ({ className, ...props }: React.HTMLAttributes) =>
;
62 | SheetFooter.displayName = "SheetFooter";
63 |
64 | const SheetTitle = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => (
65 |
66 | ));
67 | SheetTitle.displayName = SheetPrimitive.Title.displayName;
68 |
69 | const SheetDescription = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => (
70 |
71 | ));
72 | SheetDescription.displayName = SheetPrimitive.Description.displayName;
73 |
74 | export { Sheet, SheetPortal, SheetOverlay, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };
75 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | function Skeleton({ className, ...props }: React.HTMLAttributes) {
4 | return
;
5 | }
6 |
7 | export { Skeleton };
8 |
--------------------------------------------------------------------------------
/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Toaster as Sonner } from "sonner";
3 |
4 | type ToasterProps = React.ComponentProps;
5 |
6 | const Toaster = ({ ...props }: ToasterProps) => {
7 | return (
8 |
21 | );
22 | };
23 |
24 | export { Toaster };
25 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as SwitchPrimitives from "@radix-ui/react-switch";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const Switch = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => (
7 |
15 |
18 |
19 | ));
20 | Switch.displayName = SwitchPrimitives.Root.displayName;
21 |
22 | export { Switch };
23 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider;
7 |
8 | const Tooltip = TooltipPrimitive.Root;
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger;
11 |
12 | const TooltipContent = React.forwardRef, React.ComponentPropsWithoutRef>(
13 | ({ className, sideOffset = 4, ...props }, ref) => (
14 |
23 | ),
24 | );
25 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
26 |
27 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
28 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | import { FileRouteTypes } from "./routeTree.gen";
2 |
3 | export const GITHUB_URL = "https://github.com/bookracy";
4 | export const DISCORD_URL = "https://discord.gg/bookracy";
5 | export const X_URL = "https://x.com/bookracy";
6 |
7 | export const PAGE_TITLES: Partial> = {
8 | "/": "Home",
9 | "/about": "About",
10 | "/account": "Account",
11 | "/lists": "Lists",
12 | "/contact": "Contact",
13 | "/featured": "Featured",
14 | "/library": "Library",
15 | "/settings": "Settings",
16 | "/upload": "Upload",
17 | "/login": "Login",
18 | "/register": "Register",
19 | };
20 |
21 | export const LANGUAGES = [
22 | { label: "English", value: "en" },
23 | { label: "Russian", value: "ru" },
24 | { label: "German", value: "de" },
25 | { label: "Spanish", value: "es" },
26 | { label: "Italian", value: "it" },
27 | { label: "Chinese", value: "zh" },
28 | { label: "French", value: "fr" },
29 | ];
30 |
--------------------------------------------------------------------------------
/src/hooks/auth/use-auth.ts:
--------------------------------------------------------------------------------
1 | import { useAuthStore } from "@/stores/auth";
2 | import { useNavigate, useRouter } from "@tanstack/react-router";
3 | import { useCallback } from "react";
4 |
5 | export const useAuth = () => {
6 | const router = useRouter();
7 | const reset = useAuthStore((state) => state.reset);
8 | const navigate = useNavigate();
9 |
10 | const handleLogout = useCallback(() => {
11 | reset();
12 | router.invalidate();
13 | navigate({
14 | to: "/login",
15 | search: {
16 | redirect: "/",
17 | },
18 | replace: true,
19 | });
20 | }, [reset, navigate, router]);
21 |
22 | return { handleLogout } as const;
23 | };
24 |
--------------------------------------------------------------------------------
/src/hooks/auth/use-user-data-sync.ts:
--------------------------------------------------------------------------------
1 | import { getUserData, syncUserData as syncUserDataApi } from "@/api/backend/auth/sync";
2 | import { useBookmarksStore } from "@/stores/bookmarks";
3 | import { useCallback } from "react";
4 |
5 | export const useUserDataSync = () => {
6 | const bookmarks = useBookmarksStore((state) => state.bookmarks);
7 | const setBookmarks = useBookmarksStore((state) => state.setBookmarks);
8 |
9 | const syncUserData = useCallback(async () => {
10 | try {
11 | const userData = await getUserData().catch(() => ({ bookmarks: [] }));
12 | setBookmarks([...new Set([...bookmarks, ...userData.bookmarks])]);
13 | await syncUserDataApi({
14 | bookmarks: bookmarks,
15 | });
16 | } catch {
17 | // Do nothing, let login continue
18 | }
19 | }, [bookmarks, setBookmarks]);
20 |
21 | return { syncUserData } as const;
22 | };
23 |
--------------------------------------------------------------------------------
/src/hooks/use-debounce.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export const useDebounce = (value: T, delay: number): T => {
4 | const [debouncedValue, setDebouncedValue] = useState(value);
5 |
6 | useEffect(() => {
7 | const handler = setTimeout(() => {
8 | setDebouncedValue(value);
9 | }, delay);
10 |
11 | return () => {
12 | clearTimeout(handler);
13 | };
14 | }, [value, delay]);
15 |
16 | return debouncedValue;
17 | };
18 |
--------------------------------------------------------------------------------
/src/hooks/use-ismobile.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 |
3 | export function useIsMobile(horizontal?: boolean) {
4 | const [isMobile, setIsMobile] = useState(false);
5 | const isMobileCurrent = useRef(false);
6 |
7 | useEffect(() => {
8 | function onResize() {
9 | const value = horizontal ? window.innerHeight < 600 : window.innerWidth < 1024;
10 | const isChanged = isMobileCurrent.current !== value;
11 | if (!isChanged) return;
12 |
13 | isMobileCurrent.current = value;
14 | setIsMobile(value);
15 | }
16 |
17 | onResize();
18 | window.addEventListener("resize", onResize);
19 |
20 | return () => {
21 | window.removeEventListener("resize", onResize);
22 | };
23 | }, [horizontal]);
24 |
25 | return {
26 | isMobile,
27 | };
28 | }
29 |
--------------------------------------------------------------------------------
/src/hooks/use-layout.ts:
--------------------------------------------------------------------------------
1 | import { PAGE_TITLES } from "@/constants";
2 | import { useMatches } from "@tanstack/react-router";
3 | import { useMemo } from "react";
4 | import { getMenuList } from "../lib/layout";
5 | import { useSettingsStore } from "@/stores/settings";
6 |
7 | export function useLayout() {
8 | const beta = useSettingsStore((state) => state.beta);
9 | const matches = useMatches();
10 | const currentMatch = matches.at(-1);
11 |
12 | const routeId = useMemo(() => {
13 | // replace all routes that have double underscores, e.g. /orders/__orderId/update -> /orders/update
14 | // Also replace all routes with one underscore, e.g. /orders/_orderId/update -> /orders/update
15 | // Also remove trailing slash if it exists
16 | return currentMatch?.routeId
17 | .replace(/\/__\w+/g, "")
18 | .replace(/\/_\w+/g, "")
19 | .replace(/\/$/, "");
20 | }, [currentMatch]);
21 |
22 | const memoizedMenuList = useMemo(() => getMenuList(routeId ?? "", beta), [routeId, beta]);
23 |
24 | const pageTitle = useMemo(() => {
25 | return PAGE_TITLES[routeId as keyof typeof PAGE_TITLES];
26 | }, [routeId]);
27 |
28 | return {
29 | menuList: memoizedMenuList,
30 | pageTitle,
31 | };
32 | }
33 |
--------------------------------------------------------------------------------
/src/lib/file.ts:
--------------------------------------------------------------------------------
1 | export const getPfpInBase64 = async (pfp: File): Promise => {
2 | return new Promise((resolve) => {
3 | const fileReader = new FileReader();
4 | fileReader.readAsDataURL(pfp);
5 | fileReader.onload = () => {
6 | resolve(fileReader.result);
7 | };
8 | fileReader.onerror = () => {
9 | resolve(null);
10 | };
11 | });
12 | };
13 |
--------------------------------------------------------------------------------
/src/lib/layout.ts:
--------------------------------------------------------------------------------
1 | import { FileRouteTypes } from "@/routeTree.gen";
2 | import { Blocks, House, LucideIcon, BookMarked, Star, Upload, BookOpenText, SearchIcon } from "lucide-react";
3 |
4 | export type Submenu = {
5 | href: FileRouteTypes["fullPaths"];
6 | label: string;
7 | active: boolean;
8 | };
9 |
10 | type Menu = {
11 | href: FileRouteTypes["fullPaths"];
12 | label: string;
13 | active: boolean;
14 | icon: LucideIcon;
15 | submenus: Submenu[];
16 | disabled?: boolean;
17 | };
18 |
19 | type Group = {
20 | groupLabel: string;
21 | menus: Menu[];
22 | };
23 |
24 | export function getMenuList(pathname: FileRouteTypes["fullPaths"] | string, beta: boolean): Group[] {
25 | return [
26 | {
27 | groupLabel: "General",
28 | menus: [
29 | {
30 | href: "/",
31 | label: "Search",
32 | active: pathname === "/",
33 | icon: SearchIcon,
34 | submenus: [],
35 | },
36 | {
37 | href: "/featured",
38 | label: "Featured",
39 | active: pathname === "/featured",
40 | icon: Star,
41 | submenus: [],
42 | },
43 | {
44 | href: "/library",
45 | label: "Library",
46 | active: pathname === "/library",
47 | icon: BookMarked,
48 | submenus: [],
49 | disabled: import.meta.env.PROD,
50 | },
51 | {
52 | href: "/upload",
53 | label: "Upload",
54 | active: pathname === "/upload",
55 | icon: Upload,
56 | submenus: [],
57 | disabled: import.meta.env.PROD,
58 | },
59 | ],
60 | },
61 | {
62 | groupLabel: "Account",
63 | menus: [
64 | {
65 | href: "/account",
66 | label: "Account",
67 | active: pathname === "/account",
68 | icon: House,
69 | submenus: [],
70 | disabled: import.meta.env.DEV ? false : !beta,
71 | },
72 | {
73 | href: "/lists",
74 | label: "Lists",
75 | active: pathname === "/lists",
76 | icon: BookOpenText,
77 | submenus: [],
78 | },
79 | {
80 | href: "/settings",
81 | label: "Settings",
82 | active: pathname === "/settings",
83 | icon: Blocks,
84 | submenus: [],
85 | },
86 | ],
87 | },
88 | ];
89 | }
90 |
--------------------------------------------------------------------------------
/src/lib/saveAs.ts:
--------------------------------------------------------------------------------
1 | export const saveAs = (url?: string, fileName?: string, openInNewTab = false) => {
2 | if (!url) return;
3 | const link = document.createElement("a");
4 | link.href = url;
5 |
6 | if (openInNewTab) {
7 | link.target = "_blank";
8 | } else {
9 | link.download = fileName || url.split("/").pop() || "download";
10 | }
11 | document.body.appendChild(link);
12 | link.click();
13 | document.body.removeChild(link);
14 | };
15 |
--------------------------------------------------------------------------------
/src/lib/string.ts:
--------------------------------------------------------------------------------
1 | export const titleToSlug = (title: string) => {
2 | return title
3 | .toLowerCase()
4 | .replace(/ /g, "-")
5 | .replace(/[^\w-]+/g, "");
6 | };
7 |
--------------------------------------------------------------------------------
/src/lib/sync/index.ts:
--------------------------------------------------------------------------------
1 | // This file should only be imported once in the root of the application
2 | import "./user-data";
3 |
--------------------------------------------------------------------------------
/src/lib/sync/user-data.ts:
--------------------------------------------------------------------------------
1 | import { syncUserData } from "@/api/backend/auth/sync";
2 | import { useAuthStore } from "@/stores/auth";
3 | import { useBookmarksStore } from "@/stores/bookmarks";
4 |
5 | useBookmarksStore.subscribe(
6 | (state) => state.bookmarks,
7 | async (bookmarks) => {
8 | try {
9 | const accessToken = useAuthStore.getState().accessToken;
10 | if (!accessToken) return bookmarks;
11 |
12 | await syncUserData({
13 | bookmarks,
14 | });
15 | } catch (error) {
16 | console.error("Failed to sync user data", error);
17 | }
18 | return bookmarks;
19 | },
20 | );
21 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode, useEffect } from "react";
2 | import ReactDOM from "react-dom/client";
3 | import { RouterProvider, createRouter } from "@tanstack/react-router";
4 | import { routeTree } from "./routeTree.gen";
5 | import { Toaster } from "@/components/ui/sonner";
6 | import { useSettingsStore } from "./stores/settings";
7 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
8 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
9 | import { Loader2 } from "lucide-react";
10 | import { ScrollToTopButton } from "./components/layout/scroll-to-top-button";
11 |
12 | import "./lib/sync";
13 | import "./styles/global.css";
14 |
15 | const queryClient = new QueryClient({
16 | defaultOptions: {
17 | queries: {
18 | retry(failureCount, error) {
19 | if ("status" in error && error.status === 404) return false;
20 | if (failureCount < 2) return true;
21 | return false;
22 | },
23 | },
24 | mutations: {
25 | retry(failureCount, error) {
26 | if ("status" in error && error.status === 404) return false;
27 | if (failureCount < 2) return true;
28 | return false;
29 | },
30 | },
31 | },
32 | });
33 |
34 | export const router = createRouter({
35 | routeTree,
36 | context: {
37 | auth: {
38 | isLoggedIn: false,
39 | user: null,
40 | },
41 | queryClient: queryClient,
42 | },
43 | defaultPreload: "intent",
44 | defaultPreloadStaleTime: 0,
45 | defaultErrorComponent: () => 404
,
46 | defaultPendingMinMs: 1,
47 | defaultPendingMs: 100,
48 | defaultPendingComponent: () => {
49 | return (
50 |
51 |
52 |
53 | );
54 | },
55 | });
56 |
57 | declare module "@tanstack/react-router" {
58 | interface Register {
59 | router: typeof router;
60 | }
61 | }
62 |
63 | export function App() {
64 | const theme = useSettingsStore((state) => state.theme);
65 |
66 | useEffect(() => {
67 | const root = window.document.documentElement;
68 | root.classList.remove("light", "dark");
69 | root.classList.add(theme);
70 | }, [theme]);
71 |
72 | return (
73 |
74 | {import.meta.env.DEV && (
75 |
76 |
77 |
78 | )}
79 |
80 |
81 |
82 |
83 | );
84 | }
85 |
86 | // Render the app
87 | const rootElement = document.getElementById("root")!;
88 | if (!rootElement.innerHTML) {
89 | const root = ReactDOM.createRoot(rootElement);
90 | root.render(
91 |
92 |
93 | ,
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/src/routes/__root.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Footer } from "@/components/layout/footer";
3 | import { Navbar } from "@/components/layout/navbar";
4 | import { Sidebar } from "@/components/layout/sidebar";
5 | import { cn } from "@/lib/utils";
6 | import { useLayoutStore } from "@/stores/layout";
7 | import { createRootRouteWithContext, Link, Outlet } from "@tanstack/react-router";
8 | import { QueryClient } from "@tanstack/react-query";
9 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
10 | import { AlertCircle, Loader2 } from "lucide-react";
11 | import { getUserData, UserData } from "@/api/backend/auth/sync";
12 | import { useAuthStore } from "@/stores/auth";
13 |
14 | const TanStackRouterDevtools = import.meta.env.PROD
15 | ? () => null
16 | : React.lazy(() =>
17 | import("@tanstack/router-devtools").then((res) => ({
18 | default: res.TanStackRouterDevtools,
19 | })),
20 | );
21 |
22 | export const Route = createRootRouteWithContext<{
23 | auth: {
24 | isLoggedIn: boolean;
25 | user: UserData | null;
26 | };
27 | queryClient: QueryClient;
28 | }>()({
29 | component: Root,
30 | notFoundComponent: () => (
31 |
32 |
33 |
34 | Page not found
35 |
36 | You have reached a page that does not exist.{" "}
37 |
38 | Click here to go back to the main page.
39 |
40 |
41 |
42 |
43 | ),
44 | pendingMinMs: 1,
45 | wrapInSuspense: true,
46 | pendingComponent: () => (
47 |
48 |
49 |
50 | ),
51 | async beforeLoad(ctx) {
52 | const authState = useAuthStore.getState();
53 |
54 | if (!authState.accessToken && !authState.refreshToken) {
55 | ctx.context.auth.isLoggedIn = false;
56 | ctx.context.auth.user = null;
57 | return;
58 | }
59 |
60 | if (authState.accessToken && !ctx.context.auth.user) {
61 | try {
62 | const user = await ctx.context.queryClient.fetchQuery({
63 | queryKey: ["userData"],
64 | queryFn: getUserData,
65 | staleTime: 1000 * 60 * 5, // Cache for 5 minutes
66 | });
67 |
68 | ctx.context.auth.isLoggedIn = true;
69 | ctx.context.auth.user = user;
70 | return;
71 | } catch (error) {
72 | console.error("Error fetching user data: ", error);
73 | authState.reset();
74 | ctx.context.auth.isLoggedIn = false;
75 | ctx.context.auth.user = null;
76 | }
77 | return;
78 | }
79 | },
80 | });
81 |
82 | function Root() {
83 | const sidebar = useLayoutStore((state) => state.sidebar);
84 |
85 | return (
86 | <>
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
97 |
98 | >
99 | );
100 | }
101 |
--------------------------------------------------------------------------------
/src/routes/about.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
2 | import { NavLink } from "@/components/ui/nav-link";
3 | import { createFileRoute } from "@tanstack/react-router";
4 | import { DISCORD_URL, GITHUB_URL } from "@/constants";
5 |
6 | export const Route = createFileRoute("/about")({
7 | component: About,
8 | });
9 |
10 | function About() {
11 | return (
12 |
13 |
14 |
15 |
16 | About Bookracy 🌟
17 |
18 | Bookracy is a open-source project that aims to provide a platform for sharing and discovering books for free built with shadcn. Bookracy is currently a work in progress while we build
19 | out the features and functionality. We hope you enjoy the platform and find it useful. If you have any feedback or suggestions, please feel free to reach out to us.
20 |
21 | Github Repository |{" "}
22 |
23 | Discord
24 | {" "}
25 | | Contact
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/routes/account.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
2 | import { Button } from "@/components/ui/button";
3 | import { createFileRoute, redirect, useRouteContext } from "@tanstack/react-router";
4 | import { z } from "zod";
5 | import { useForm } from "react-hook-form";
6 | import { zodResolver } from "@hookform/resolvers/zod";
7 | import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
8 | import { Input } from "@/components/ui/input";
9 | import { useSettingsStore } from "@/stores/settings";
10 | import { useMutation } from "@tanstack/react-query";
11 | import { syncUserData } from "@/api/backend/auth/sync";
12 | import { toast } from "sonner";
13 | import { ImageUploader } from "@/components/ui/image-upload-field";
14 |
15 | export const Route = createFileRoute("/account")({
16 | component: Account,
17 | beforeLoad: (ctx) => {
18 | const beta = useSettingsStore.getState().beta;
19 | if (import.meta.env.PROD && !beta) throw redirect({ to: "/", search: { q: "" } });
20 | if (!ctx.context.auth.isLoggedIn) throw redirect({ to: "/login" });
21 | },
22 | });
23 |
24 | const updateAccountSchema = z.object({
25 | displayName: z.string().min(1, { message: "Display name is required" }),
26 | profilePicture: z
27 | .union([z.string(), z.instanceof(File)])
28 | .refine(
29 | (file) => {
30 | if (typeof file === "string") return true;
31 | return file.type.startsWith("image/");
32 | },
33 | {
34 | message: "Please upload a valid image file",
35 | },
36 | )
37 | .refine(
38 | (file) => {
39 | if (typeof file === "string") return true;
40 | return file.size <= 1024 * 1024;
41 | },
42 | {
43 | message: "Please upload a file smaller than 1MB",
44 | },
45 | )
46 | .optional(),
47 | });
48 |
49 | function Account() {
50 | const user = useRouteContext({
51 | from: "__root__",
52 | }).auth.user;
53 |
54 | const form = useForm>({
55 | resolver: zodResolver(updateAccountSchema),
56 | defaultValues: {
57 | displayName: user?.username || "",
58 | profilePicture: user?.pfp || "",
59 | },
60 | });
61 |
62 | const { mutate, isPending } = useMutation({
63 | mutationKey: ["updateDisplayName"],
64 | mutationFn: syncUserData,
65 | onSuccess: () => {
66 | toast.success("Account updated");
67 | },
68 | onError: () => {
69 | toast.error("Failed to update account");
70 | },
71 | });
72 |
73 | const handleSubmit = (data: z.infer) => {
74 | mutate({ username: data.displayName, pfp: data.profilePicture ?? "" });
75 | };
76 |
77 | return (
78 |
79 |
80 |
81 | Account
82 | Manage your account
83 |
84 |
124 |
125 |
126 |
127 | );
128 | }
129 |
--------------------------------------------------------------------------------
/src/routes/contact.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
2 | import { Button } from "@/components/ui/button";
3 | import { createFileRoute } from "@tanstack/react-router";
4 |
5 | export const Route = createFileRoute("/contact")({
6 | component: Contact,
7 | });
8 |
9 | function Contact() {
10 | return (
11 |
12 |
13 |
14 |
15 | Contact Us
16 |
17 | Bookracy is an open-source project driven by the community. The project is maintained by a group of developers and sending an email below will reach the maintainers (if checked).
18 |
19 | DMCA takedown requests will be ignored. If you have a DMCA request, please contact the hosting provider.
20 | window.open("mailto:dev@bookracy.org", "_blank")}>Email Us
21 |
22 |
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/routes/featured.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from "@tanstack/react-router";
2 | import { useMemo } from "react";
3 | import { getTrendingQueryOptions } from "@/api/backend/trending/trending";
4 | import { useSuspenseQuery } from "@tanstack/react-query";
5 | import { BookList } from "@/components/books/book-list";
6 |
7 | export const Route = createFileRoute("/featured")({
8 | component: Feature,
9 | async beforeLoad(ctx) {
10 | await ctx.context.queryClient.ensureQueryData(getTrendingQueryOptions);
11 | },
12 | });
13 |
14 | function Feature() {
15 | const { data } = useSuspenseQuery(getTrendingQueryOptions);
16 |
17 | const categories = useMemo(() => Object.keys(data ?? {}), [data]);
18 |
19 | return (
20 |
21 |
22 | {categories.map((category: string) => (
23 |
24 |
25 | {category
26 | .replace("_", " ")
27 | .split(" ")
28 | .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
29 | .join(" ")}
30 |
31 |
{data[category].length > 0 ? : null}
32 |
33 | ))}
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import { useGetBooksQueryWithExternalDownloads } from "@/api/backend/search/search";
2 | import { SkeletonBookItem, SkeletonBookItemGrid } from "@/components/books/book-item";
3 | import { useDebounce } from "@/hooks/use-debounce";
4 | import { useSettingsStore } from "@/stores/settings";
5 | import { createFileRoute, useNavigate } from "@tanstack/react-router";
6 | import { NavLink } from "@/components/ui/nav-link";
7 | import { Input } from "@/components/ui/input";
8 | import { SearchIcon } from "lucide-react";
9 | import { useState, useMemo } from "react";
10 | import { Filters, FilterProps } from "@/components/books/filters";
11 | import { BookList } from "@/components/books/book-list";
12 | import { BookGallery } from "@/components/books/book-gallery";
13 | import { useSuspenseQuery } from "@tanstack/react-query";
14 | import { getTrendingQueryOptions } from "@/api/backend/trending/trending";
15 |
16 | export const Route = createFileRoute("/")({
17 | component: Index,
18 | validateSearch: (search) => {
19 | if (!search.q) {
20 | return { q: "" };
21 | }
22 | if (typeof search.q !== "string") {
23 | return { q: search.q.toString() };
24 | }
25 | return { q: search.q };
26 | },
27 | });
28 |
29 | function Index() {
30 | const navigate = useNavigate({ from: Route.fullPath });
31 | const { q } = Route.useSearch();
32 |
33 | const [filters, setFilters] = useState({
34 | view: "list",
35 | perPage: 10,
36 | });
37 |
38 | const language = useSettingsStore((state) => state.language);
39 | const debouncedQ = useDebounce(q, 500);
40 |
41 | const {
42 | data: searchData,
43 | error: searchError,
44 | isLoading: isSearchLoading,
45 | } = useGetBooksQueryWithExternalDownloads({
46 | query: debouncedQ,
47 | lang: language,
48 | limit: filters.perPage,
49 | });
50 |
51 | const { data: trendingData } = useSuspenseQuery(getTrendingQueryOptions);
52 | const categories = useMemo(() => Object.keys(trendingData ?? {}), [trendingData]);
53 |
54 | return (
55 |
56 |
57 |
58 | Welcome to Bookracy 📚
59 |
60 |
61 |
62 | Bookracy is a free and open-source web app that allows you to read and download your favorite books, comics, and manga.
63 |
64 | To get started, either search below or navigate the site using the sidebar.
65 |
66 |
67 | About Us
68 |
69 |
70 |
71 |
72 |
73 |
74 | }
76 | placeholder="Search for books, comics, or manga..."
77 | value={q}
78 | onChange={(e) =>
79 | navigate({
80 | search: {
81 | q: e.target.value,
82 | },
83 | })
84 | }
85 | className="h-14 text-lg"
86 | />
87 |
88 |
89 | {q && (
90 |
91 |
92 |
93 | {isSearchLoading && (
94 |
95 | {Array.from({ length: filters.perPage }).map((_, i) => (filters.view === "grid" ? : ))}
96 |
97 | )}
98 | {searchError &&
Error: {searchError.message}
}
99 |
100 | {searchData && filters.view === "list" &&
}
101 | {searchData && filters.view === "grid" &&
}
102 |
103 | )}
104 |
105 | {!q && trendingData && (
106 |
107 | {categories.map((category: string) => (
108 |
109 |
110 | {category
111 | .replace("_", " ")
112 | .split(" ")
113 | .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
114 | .join(" ")}
115 |
116 | {trendingData[category].length > 0 && }
117 |
118 | ))}
119 |
120 | )}
121 |
122 |
123 | );
124 | }
125 |
--------------------------------------------------------------------------------
/src/routes/library.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
2 | import { Button } from "@/components/ui/button";
3 | import { createFileRoute, Link, redirect } from "@tanstack/react-router";
4 |
5 | export const Route = createFileRoute("/library")({
6 | component: Library,
7 | beforeLoad: () => {
8 | if (import.meta.env.PROD) throw redirect({ to: "/", search: { q: "" } });
9 | },
10 | });
11 |
12 | function Library() {
13 | return (
14 |
15 |
16 |
17 |
18 | Coming soon! ⏱️
19 |
20 | Sorry, Bookracy is a work in progress and this feature is not yet available. Come back later and maybe it will be 😉
21 |
22 | Go Back
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/routes/lists.tsx:
--------------------------------------------------------------------------------
1 | import { searchBooksByMd5QueryOptions } from "@/api/backend/search/books";
2 | import { useQuery } from "@tanstack/react-query";
3 | import { getBooksByMd5sQueryOptions } from "@/api/backend/search/search";
4 | import { BookList } from "@/components/books/book-list";
5 | import { NavLink } from "@/components/ui/nav-link";
6 | import { useBookmarksStore } from "@/stores/bookmarks";
7 | import { useReadingProgressStore } from "@/stores/progress";
8 | import { createFileRoute } from "@tanstack/react-router";
9 |
10 | export const Route = createFileRoute("/lists")({
11 | component: Lists,
12 | async beforeLoad(ctx) {
13 | const bookmarks = useBookmarksStore.getState().bookmarks;
14 | await ctx.context.queryClient.ensureQueryData(searchBooksByMd5QueryOptions(bookmarks));
15 | const readingProgress = useReadingProgressStore
16 | .getState()
17 | .readingProgress.filter((p) => p.totalPages > 0)
18 | .filter((p) => p.currentPage < p.totalPages);
19 | await ctx.context.queryClient.ensureQueryData(getBooksByMd5sQueryOptions(readingProgress.map((p) => p.md5)));
20 | },
21 | });
22 |
23 | export function Lists() {
24 | const bookmarks = useBookmarksStore((state) => state.bookmarks);
25 | const readingProgress = useReadingProgressStore((state) => state.readingProgress)
26 | .filter((p) => p.totalPages > 0)
27 | .filter((p) => p.currentPage < p.totalPages);
28 |
29 | const { data } = useQuery(getBooksByMd5sQueryOptions(readingProgress.map((p) => p.md5)));
30 |
31 | const { data: bookmarksData } = useQuery(searchBooksByMd5QueryOptions(bookmarks));
32 |
33 | return (
34 |
35 |
36 | {bookmarks.length > 0 ?
Bookmarks : null}
37 |
38 | {bookmarks.length === 0 && (
39 |
40 |
No Bookmarks
41 |
42 | Start adding some books using the bookmark button. Start searching
43 | here
44 |
45 |
46 | )}
47 |
48 |
49 |
50 |
51 |
52 | {data?.length && data?.length > 0 ?
Reading Progress : null}
53 | {readingProgress.length === 0 && (
54 |
55 |
No Reading Progress
56 |
Start reading some books and your progress will show up here.
57 |
58 | )}
59 | {data?.length && data?.length > 0 ?
: null}
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/routes/login.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
2 | import { Button } from "@/components/ui/button";
3 | import { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } from "@/components/ui/input-otp";
4 | import { useIsMobile } from "@/hooks/use-ismobile";
5 | import { createFileRoute, redirect } from "@tanstack/react-router";
6 | import { NavLink } from "@/components/ui/nav-link";
7 | import { useRouter } from "@tanstack/react-router";
8 | import { z } from "zod";
9 | import { useForm } from "react-hook-form";
10 | import { zodResolver } from "@hookform/resolvers/zod";
11 | import { useMutation } from "@tanstack/react-query";
12 | import { login } from "@/api/backend/auth/signin";
13 | import { useAuthStore } from "@/stores/auth";
14 | import { toast } from "sonner";
15 | import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
16 | import { TurnstileWidget } from "@/components/layout/turnstile";
17 | import { useSettingsStore } from "@/stores/settings";
18 | import { useUserDataSync } from "@/hooks/auth/use-user-data-sync";
19 |
20 | export const Route = createFileRoute("/login")({
21 | component: Login,
22 | beforeLoad: (opts) => {
23 | const beta = useSettingsStore.getState().beta;
24 | if (!beta) throw redirect({ to: "/", search: { q: "" } });
25 |
26 | if (opts.context.auth.isLoggedIn) throw redirect({ to: "/account" });
27 | },
28 | });
29 |
30 | const loginFormSchema = z.object({
31 | code: z.string({ message: "Code must be 12 digits" }).min(12, { message: "Code must be 12 digits" }).max(12, { message: "Code must be 12 digits" }),
32 | ttkn: z.string({ message: "Captcha not completed" }),
33 | });
34 |
35 | function InputOTPGroups() {
36 | const { isMobile } = useIsMobile();
37 |
38 | const createInputOTPGroups = (breakPoint: number) => {
39 | const groups = [];
40 | for (let i = 0; i < 12; i += breakPoint) {
41 | groups.push(
42 |
43 | {[...Array(breakPoint)].map((_, j) => (
44 |
45 | ))}
46 | ,
47 | );
48 | groups.push( );
49 | }
50 | return groups.map((group, i) => (i === groups.length - 1 ? null : group));
51 | };
52 |
53 | return {isMobile ? createInputOTPGroups(4) : createInputOTPGroups(6)}
;
54 | }
55 |
56 | function Login() {
57 | const { navigate } = useRouter();
58 | const setTokens = useAuthStore((state) => state.setTokens);
59 | const { syncUserData } = useUserDataSync();
60 |
61 | const { mutate, isPending } = useMutation({
62 | mutationKey: ["login"],
63 | mutationFn: login,
64 | onSuccess: async (data) => {
65 | toast.success("Logged in successfully");
66 | setTokens(data.access_token, data.refresh_token);
67 | await syncUserData();
68 |
69 | navigate({ to: "/", search: { q: "" } });
70 | },
71 | onError: () => {
72 | toast.error("Failed to login", { duration: 10000 });
73 | },
74 | });
75 |
76 | const form = useForm>({
77 | resolver: zodResolver(loginFormSchema),
78 | defaultValues: {
79 | code: "",
80 | },
81 | });
82 |
83 | function onSubmit(data: z.infer) {
84 | mutate(data);
85 | }
86 |
87 | return (
88 |
147 | );
148 | }
149 |
--------------------------------------------------------------------------------
/src/routes/register.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { redirect, useNavigate } from "@tanstack/react-router";
3 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4 | import { Label } from "@/components/ui/label";
5 | import { Button } from "@/components/ui/button";
6 | import { Input } from "@/components/ui/input";
7 | import { createFileRoute } from "@tanstack/react-router";
8 | import { randomWordsWithNumberQueryOptions } from "@/api/words";
9 | import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
10 | import { generateUser } from "@/api/backend/auth/signup";
11 | import { z } from "zod";
12 | import { useForm } from "react-hook-form";
13 | import { zodResolver } from "@hookform/resolvers/zod";
14 | import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
15 | import { toast } from "sonner";
16 | import { ClipBoardButton } from "@/components/layout/clipboard-button";
17 | import { TurnstileWidget } from "@/components/layout/turnstile";
18 | import { useSettingsStore } from "@/stores/settings";
19 | import { ImageUploader } from "@/components/ui/image-upload-field";
20 |
21 | export const Route = createFileRoute("/register")({
22 | component: Register,
23 | beforeLoad: async (opts) => {
24 | const beta = useSettingsStore.getState().beta;
25 | if (!beta) throw redirect({ to: "/", search: { q: "" } });
26 |
27 | if (opts.context.auth.isLoggedIn) throw redirect({ to: "/account" });
28 | await opts.context.queryClient.ensureQueryData(randomWordsWithNumberQueryOptions);
29 | },
30 | });
31 |
32 | const profileRegistrationSchema = z.object({
33 | displayName: z.string().min(1, { message: "Display name is required" }),
34 | profilePicture: z
35 | .instanceof(File, { message: "Invalid file" })
36 | .refine((file) => file.type.startsWith("image/"), {
37 | message: "Please upload an image file",
38 | })
39 | .refine((file) => file.size <= 1024 * 1024, {
40 | message: "Please upload a file smaller than 1MB",
41 | })
42 | .optional(),
43 | ttkn: z.string({ message: "Captcha not completed" }),
44 | });
45 |
46 | function Register() {
47 | const navigate = useNavigate();
48 | const [isCopied, setIsCopied] = useState(false);
49 | const [uuid, setUuid] = useState("");
50 |
51 | const { data } = useSuspenseQuery(randomWordsWithNumberQueryOptions);
52 |
53 | const { mutate, isPending } = useMutation({
54 | mutationKey: ["signup"],
55 | mutationFn: generateUser,
56 | onSuccess: (data) => {
57 | setUuid(data.code.replace(/\s/g, ""));
58 | },
59 | onError: () => {
60 | toast.error("Failed to create user", { duration: 10000 });
61 | },
62 | });
63 |
64 | const form = useForm({
65 | resolver: zodResolver(profileRegistrationSchema),
66 | defaultValues: {
67 | displayName: data ?? "",
68 | },
69 | });
70 |
71 | useEffect(() => {
72 | if (data) {
73 | form.reset({ displayName: data, profilePicture: undefined });
74 | }
75 | }, [data, form]);
76 |
77 | const handleSubmit = (data: z.infer) => {
78 | mutate({
79 | username: data.displayName,
80 | pfp: data.profilePicture,
81 | ttkn: data.ttkn,
82 | });
83 | };
84 |
85 | return (
86 |
87 |
88 |
89 | Register
90 |
91 |
92 | {uuid ? (
93 |
94 |
95 |
Generated Identifier
96 |
97 | setIsCopied(true)} />} />
98 |
99 |
Copy the identifier above and use it to login
100 |
101 |
102 |
navigate({ to: "/login" })}>
103 | Continue
104 |
105 |
106 | ) : (
107 |
165 |
166 | )}
167 |
168 |
169 |
170 | );
171 | }
172 |
--------------------------------------------------------------------------------
/src/routes/settings.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
2 | import { Input } from "@/components/ui/input";
3 | import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select";
4 | import { Switch } from "@/components/ui/switch";
5 | import { LANGUAGES } from "@/constants";
6 | import { useSettingsStore } from "@/stores/settings";
7 | import { createFileRoute } from "@tanstack/react-router";
8 | import { Lock } from "lucide-react";
9 | import { useForm } from "react-hook-form";
10 | import { z } from "zod";
11 | import { zodResolver } from "@hookform/resolvers/zod";
12 | import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
13 | import { Button } from "@/components/ui/button";
14 | import { toast } from "sonner";
15 | import { useEffect, useState } from "react";
16 | import { cn } from "@/lib/utils";
17 |
18 | const settingsFormSchema = z.object({
19 | language: z.string(),
20 | backendURL: z.string().url(),
21 | beta: z.boolean(),
22 | });
23 |
24 | export const Route = createFileRoute("/settings")({
25 | component: Settings,
26 | });
27 |
28 | function Settings() {
29 | const [showSave, setShowSave] = useState(false);
30 | const { language, backendURL } = useSettingsStore();
31 |
32 | const form = useForm>({
33 | resolver: zodResolver(settingsFormSchema),
34 | defaultValues: {
35 | language,
36 | backendURL,
37 | beta: false,
38 | },
39 | });
40 |
41 | useEffect(() => {
42 | const subscription = form.watch((_, { type }) => {
43 | if (type === "change") {
44 | setShowSave(true);
45 | }
46 | });
47 |
48 | return () => {
49 | subscription.unsubscribe();
50 | };
51 | }, [form]);
52 |
53 | const handleSubmit = () => {
54 | form.handleSubmit((data) => {
55 | useSettingsStore.setState(data);
56 | toast.success("Settings saved successfully", { position: "top-right" });
57 | setShowSave(false);
58 | })();
59 | };
60 |
61 | return (
62 |
63 |
146 |
147 |
148 |
153 |
Save settings
154 |
155 | {
158 | form.reset();
159 | setShowSave(false);
160 | }}
161 | className="w-32"
162 | disabled={!showSave}
163 | >
164 | Reset
165 |
166 |
167 | Save
168 |
169 |
170 |
171 |
172 |
173 | Hostname: {window.location.hostname}
174 |
175 |
176 |
177 | );
178 | }
179 |
--------------------------------------------------------------------------------
/src/routes/upload.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
2 | import { Button } from "@/components/ui/button";
3 | import { createFileRoute, Link, redirect } from "@tanstack/react-router";
4 |
5 | export const Route = createFileRoute("/upload")({
6 | component: Upload,
7 | beforeLoad: () => {
8 | if (import.meta.env.PROD) throw redirect({ to: "/", search: { q: "" } });
9 | },
10 | });
11 |
12 | function Upload() {
13 | return (
14 |
15 |
16 |
17 |
18 | Coming soon! ⏱️
19 |
20 | Sorry, Bookracy is a work in progress and this feature is not yet available. Come back later and maybe it will be 😉
21 |
22 | Go Back
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/stores/auth.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 | import { persist, createJSONStorage } from "zustand/middleware";
3 | import { decodeJwt } from "jose";
4 | import { z } from "zod";
5 |
6 | const tokenSchema = z.object({
7 | exp: z.number(),
8 | });
9 |
10 | interface AuthStoreState {
11 | accessToken: string;
12 | refreshToken: string;
13 |
14 | tokenInfo: z.infer | null;
15 |
16 | setTokens: (accessToken: string, refreshToken: string) => boolean;
17 | reset: () => void;
18 | }
19 |
20 | export const useAuthStore = create()(
21 | persist(
22 | (set) => ({
23 | accessToken: "",
24 | refreshToken: "",
25 | tokenInfo: null,
26 |
27 | setTokens: (accessToken, refreshToken) => {
28 | const payload = decodeJwt(accessToken);
29 | const parsedPayload = tokenSchema.safeParse(payload);
30 |
31 | if (parsedPayload.success) {
32 | set({ accessToken, refreshToken, tokenInfo: parsedPayload.data });
33 | }
34 |
35 | return parsedPayload.success;
36 | },
37 | reset: () => set({ accessToken: "", refreshToken: "" }),
38 | }),
39 | {
40 | name: "BR::auth",
41 | storage: createJSONStorage(() => localStorage),
42 | },
43 | ),
44 | );
45 |
--------------------------------------------------------------------------------
/src/stores/bookmarks.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { create } from "zustand";
3 | import { persist, subscribeWithSelector } from "zustand/middleware";
4 |
5 | interface BookmarksStoreState {
6 | bookmarks: string[];
7 | setBookmarks: (bookmarks: string[]) => void;
8 | addBookmark: (md5: string) => void;
9 | removeBookmark: (md5: string) => void;
10 | }
11 |
12 | const versionOneBookmarkSchema = z.object({
13 | bookmarks: z.array(z.object({ md5: z.string() })),
14 | });
15 |
16 | export const useBookmarksStore = create()(
17 | subscribeWithSelector(
18 | persist(
19 | (set) => ({
20 | bookmarks: [],
21 | setBookmarks: (bookmarks) => set({ bookmarks }),
22 | addBookmark: (bookmark) => set((state) => ({ bookmarks: [...state.bookmarks, bookmark] })),
23 | removeBookmark: (md5) => set((state) => ({ bookmarks: state.bookmarks.filter((b) => b !== md5) })),
24 | }),
25 | {
26 | name: "BR::bookmarks",
27 | getStorage: () => localStorage,
28 | version: 1,
29 | migrate(persistedState, version) {
30 | if (version === 0) {
31 | const parsed = versionOneBookmarkSchema.safeParse(persistedState);
32 | if (parsed.success) {
33 | return { bookmarks: parsed.data.bookmarks.map((b) => b.md5) };
34 | }
35 | }
36 | },
37 | },
38 | ),
39 | ),
40 | );
41 |
--------------------------------------------------------------------------------
/src/stores/layout.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 | import { persist, createJSONStorage } from "zustand/middleware";
3 |
4 | interface LayoutStoreState {
5 | sidebar: {
6 | isOpen: boolean;
7 | setIsOpen: () => void;
8 | };
9 | page: {
10 | title: string;
11 | setTitle: (title: string) => void;
12 | };
13 | }
14 |
15 | export const useLayoutStore = create()(
16 | persist(
17 | (set) => ({
18 | sidebar: {
19 | isOpen: true,
20 | setIsOpen: () => {
21 | set((state) => ({ sidebar: { ...state.sidebar, isOpen: !state.sidebar.isOpen } }));
22 | },
23 | },
24 | page: {
25 | title: "",
26 | setTitle: (title) => {
27 | set((state) => ({ page: { ...state.page, title } }));
28 | },
29 | },
30 | }),
31 | {
32 | name: "BR::layout",
33 | storage: createJSONStorage(() => localStorage),
34 | partialize: (state) => ({ isOpen: state.sidebar.isOpen }),
35 | },
36 | ),
37 | );
38 |
--------------------------------------------------------------------------------
/src/stores/progress.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 | import { persist } from "zustand/middleware";
3 |
4 | interface ProgressItem {
5 | md5: string;
6 | currentPage: number;
7 | totalPages: number;
8 | location: string;
9 | }
10 |
11 | interface ReadingProgressStoreState {
12 | readingProgress: ProgressItem[];
13 | findReadingProgress: (md5: string) => ProgressItem | undefined;
14 | setReadingProgress: (progressItem: ProgressItem) => void;
15 | removeReadingProgress: (md5: string) => void;
16 | }
17 |
18 | export const useReadingProgressStore = create()(
19 | persist(
20 | (set, get) => ({
21 | readingProgress: [],
22 | findReadingProgress: (md5) => get().readingProgress.find((p) => p.md5 === md5),
23 | setReadingProgress: (progressItem) =>
24 | set((state) => {
25 | const index = state.readingProgress.findIndex((p) => p.md5 === progressItem.md5);
26 | if (index === -1) {
27 | return { readingProgress: [...state.readingProgress, progressItem] };
28 | }
29 | const newReadingProgress = [...state.readingProgress];
30 | newReadingProgress[index] = progressItem;
31 | return { readingProgress: newReadingProgress };
32 | }),
33 | removeReadingProgress: (md5) => set((state) => ({ readingProgress: state.readingProgress.filter((p) => p.md5 !== md5) })),
34 | }),
35 | {
36 | name: "BR::progress",
37 | getStorage: () => localStorage,
38 | },
39 | ),
40 | );
41 |
--------------------------------------------------------------------------------
/src/stores/settings.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 | import { createJSONStorage, persist } from "zustand/middleware";
3 |
4 | type Theme = "dark" | "light";
5 |
6 | interface SettingsStoreState {
7 | language: string;
8 | backendURL: string;
9 | theme: Theme;
10 | beta: boolean;
11 |
12 | setLanguage: (language: string) => void;
13 | setBackendURL: (url: string) => void;
14 | setTheme: (theme: Theme) => void;
15 | setBeta: (beta: boolean) => void;
16 | }
17 |
18 | const isOldState = (oldState: unknown): oldState is SettingsStoreState => {
19 | if (typeof oldState !== "object" || oldState === null) {
20 | return false;
21 | }
22 |
23 | return "language" in oldState && "backendURL" in oldState && "theme" in oldState;
24 | };
25 |
26 | export const useSettingsStore = create()(
27 | persist(
28 | (set) => ({
29 | language: "en",
30 | backendURL: "https://backend.bookracy.ru",
31 | theme: "dark",
32 | beta: false,
33 |
34 | setLanguage: (language) => set({ language }),
35 | setBackendURL: (url) => set({ backendURL: url }),
36 | setTheme: (theme) => set({ theme }),
37 | setBeta: (beta) => set({ beta }),
38 | }),
39 | {
40 | name: "BR::settings",
41 | storage: createJSONStorage(() => localStorage),
42 | version: 1,
43 | migrate(persistedState, version) {
44 | if (version === 0 && isOldState(persistedState)) {
45 | return { ...persistedState, backendURL: "https://backend.bookracy.ru" };
46 | }
47 | },
48 | },
49 | ),
50 | );
51 |
--------------------------------------------------------------------------------
/src/styles/global.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 20 14.3% 4.1%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 20 14.3% 4.1%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 20 14.3% 4.1%;
13 | --primary: 262.1 83.3% 57.8%;
14 | --primary-foreground: 210 20% 98%;
15 | --secondary: 60 4.8% 95.9%;
16 | --secondary-foreground: 24 9.8% 10%;
17 | --muted: 60 4.8% 95.9%;
18 | --muted-foreground: 25 5.3% 44.7%;
19 | --accent: 60 4.8% 95.9%;
20 | --accent-foreground: 24 9.8% 10%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 60 9.1% 97.8%;
23 | --border: 20 5.9% 90%;
24 | --input: 20 5.9% 90%;
25 | --ring: 20 14.3% 4.1%;
26 | --radius: 0.5rem;
27 | --chart-1: 12 76% 61%;
28 | --chart-2: 173 58% 39%;
29 | --chart-3: 197 37% 24%;
30 | --chart-4: 43 74% 66%;
31 | --chart-5: 27 87% 67%;
32 | }
33 |
34 | .dark {
35 | --background: 20 14.3% 4.1%;
36 | --foreground: 60 9.1% 97.8%;
37 | --card: 20 14.3% 4.1%;
38 | --card-foreground: 60 9.1% 97.8%;
39 | --popover: 20 14.3% 4.1%;
40 | --popover-foreground: 60 9.1% 97.8%;
41 | --primary: 263.4 70% 50.4%;
42 | --primary-foreground: 210 20% 98%;
43 | --secondary: 12 6.5% 15.1%;
44 | --secondary-foreground: 60 9.1% 97.8%;
45 | --muted: 12 6.5% 15.1%;
46 | --muted-foreground: 24 5.4% 63.9%;
47 | --accent: 12 6.5% 15.1%;
48 | --accent-foreground: 60 9.1% 97.8%;
49 | --destructive: 0 62.8% 30.6%;
50 | --destructive-foreground: 60 9.1% 97.8%;
51 | --border: 12 6.5% 15.1%;
52 | --input: 12 6.5% 15.1%;
53 | --ring: 24 5.7% 82.9%;
54 | --chart-1: 220 70% 50%;
55 | --chart-2: 160 60% 45%;
56 | --chart-3: 30 80% 55%;
57 | --chart-4: 280 65% 60%;
58 | --chart-5: 340 75% 55%;
59 | }
60 | }
61 |
62 | @layer base {
63 | * {
64 | @apply border-border;
65 | }
66 | body {
67 | @apply bg-background text-foreground;
68 | }
69 | }
70 |
71 | html,
72 | body,
73 | #root {
74 | height: 100%;
75 | }
76 |
77 | /* Circular scrollbar styles */
78 | ::-webkit-scrollbar {
79 | width: 12px; /* Width of the scrollbar */
80 | background: #f5f5f57c; /* Background of the scrollbar */
81 | border-radius: 5%;
82 | }
83 |
84 | ::-webkit-scrollbar-thumb {
85 | background: #000000; /* Color of the thumb */
86 | border-radius: 2%;
87 | }
88 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: ["./pages/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}"],
6 | prefix: "",
7 | theme: {
8 | container: {
9 | center: true,
10 | padding: "2rem",
11 | screens: {
12 | "2xl": "1400px",
13 | },
14 | },
15 | extend: {
16 | colors: {
17 | border: "hsl(var(--border))",
18 | input: "hsl(var(--input))",
19 | ring: "hsl(var(--ring))",
20 | background: "hsl(var(--background))",
21 | foreground: "hsl(var(--foreground))",
22 | primary: {
23 | DEFAULT: "hsl(var(--primary))",
24 | foreground: "hsl(var(--primary-foreground))",
25 | },
26 | secondary: {
27 | DEFAULT: "hsl(var(--secondary))",
28 | foreground: "hsl(var(--secondary-foreground))",
29 | },
30 | destructive: {
31 | DEFAULT: "hsl(var(--destructive))",
32 | foreground: "hsl(var(--destructive-foreground))",
33 | },
34 | muted: {
35 | DEFAULT: "hsl(var(--muted))",
36 | foreground: "hsl(var(--muted-foreground))",
37 | },
38 | accent: {
39 | DEFAULT: "hsl(var(--accent))",
40 | foreground: "hsl(var(--accent-foreground))",
41 | },
42 | popover: {
43 | DEFAULT: "hsl(var(--popover))",
44 | foreground: "hsl(var(--popover-foreground))",
45 | },
46 | card: {
47 | DEFAULT: "hsl(var(--card))",
48 | foreground: "hsl(var(--card-foreground))",
49 | },
50 | },
51 | borderRadius: {
52 | lg: "var(--radius)",
53 | md: "calc(var(--radius) - 2px)",
54 | sm: "calc(var(--radius) - 4px)",
55 | },
56 | keyframes: {
57 | "accordion-down": {
58 | from: { height: "0" },
59 | to: { height: "var(--radix-accordion-content-height)" },
60 | },
61 | "accordion-up": {
62 | from: { height: "var(--radix-accordion-content-height)" },
63 | to: { height: "0" },
64 | },
65 | "caret-blink": {
66 | "0%,70%,100%": { opacity: "1" },
67 | "20%,50%": { opacity: "0" },
68 | },
69 | },
70 | animation: {
71 | "accordion-down": "accordion-down 0.2s ease-out",
72 | "accordion-up": "accordion-up 0.2s ease-out",
73 | "caret-blink": "caret-blink 1.25s ease-out infinite",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config;
79 |
80 | export default config;
81 |
--------------------------------------------------------------------------------
/tooling/github/action.yml:
--------------------------------------------------------------------------------
1 | name: "Setup and install"
2 | description: "Common setup steps for Actions"
3 |
4 | runs:
5 | using: composite
6 | steps:
7 | - uses: pnpm/action-setup@v3
8 | name: Install pnpm
9 | with:
10 | version: 9
11 | run_install: false
12 | - uses: actions/setup-node@v4
13 | with:
14 | node-version: 21
15 | cache: "pnpm"
16 |
17 | - shell: bash
18 | run: pnpm install
19 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "@/*": ["./src/*"]
6 | },
7 | "composite": true,
8 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
9 | "target": "ES2020",
10 | "useDefineForClassFields": true,
11 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
12 | "module": "ESNext",
13 | "skipLibCheck": true,
14 | "moduleResolution": "bundler",
15 | "allowImportingTsExtensions": true,
16 | "resolveJsonModule": true,
17 | "isolatedModules": true,
18 | "moduleDetection": "force",
19 | "noEmit": true,
20 | "jsx": "react-jsx",
21 | "strict": true,
22 | "noUnusedLocals": true,
23 | "noUnusedParameters": true,
24 | "noFallthroughCasesInSwitch": true,
25 | "esModuleInterop": true
26 | },
27 | "include": ["src", "types/**/*.d.ts"]
28 | }
29 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | {
5 | "path": "./tsconfig.app.json"
6 | },
7 | {
8 | "path": "./tsconfig.node.json"
9 | }
10 | ],
11 | "compilerOptions": {
12 | "baseUrl": ".",
13 | "paths": {
14 | "@/*": ["./src/*"]
15 | },
16 | "typeRoots": ["./node_modules/@types", "./types"]
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "jsx": "react-jsx",
4 | "baseUrl": ".",
5 | "paths": {
6 | "@/*": ["src/*"]
7 | },
8 | "target": "ES2020",
9 | "composite": true,
10 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
11 | "skipLibCheck": true,
12 | "module": "ESNext",
13 | "moduleResolution": "bundler",
14 | "allowSyntheticDefaultImports": true,
15 | "strict": true,
16 | "noEmit": true
17 | },
18 | "include": ["src", "types/**/*.d.ts", "vite.config.ts"]
19 | }
20 |
--------------------------------------------------------------------------------
/types/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.png" {
2 | const value: string;
3 | export default value;
4 | }
5 |
6 | declare module "*.svg" {
7 | const content: string;
8 | export default content;
9 | }
10 |
--------------------------------------------------------------------------------
/types/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import path from "path";
4 | import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
5 |
6 | export default defineConfig({
7 | resolve: {
8 | alias: {
9 | "@": path.resolve(__dirname, "./src"),
10 | },
11 | },
12 | plugins: [TanStackRouterVite(), react()],
13 | });
14 |
--------------------------------------------------------------------------------