├── LICENSE ├── README.md ├── README_CN.md ├── app ├── .gitignore ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── public │ └── RabiJumpLOGO.svg ├── src │ ├── api │ │ ├── connect.ts │ │ └── redirect.ts │ ├── assets │ │ └── RabiJumpLOGO.svg │ ├── custom.d.ts │ ├── hooks │ │ ├── basic.ts │ │ └── useLoading.ts │ ├── i18n │ │ ├── en.json │ │ ├── resources.ts │ │ └── zh.json │ ├── index.css │ ├── main.tsx │ ├── misc │ │ ├── i18n.ts │ │ ├── request │ │ │ ├── axios.ts │ │ │ └── axiosMiddleware.ts │ │ ├── storage.ts │ │ └── util.ts │ ├── store │ │ ├── app.ts │ │ ├── common.ts │ │ └── links.ts │ ├── styles │ │ ├── global.scss │ │ └── scrollbar.scss │ ├── type │ │ ├── app.ts │ │ └── link.ts │ ├── views │ │ ├── APIConfig.scss │ │ ├── APIConfig.tsx │ │ ├── BackendList.scss │ │ ├── BackendList.tsx │ │ ├── DashBoard │ │ │ ├── DashBoard.tsx │ │ │ ├── Home.tsx │ │ │ ├── SideBar.tsx │ │ │ ├── SiderBar.module.scss │ │ │ ├── about │ │ │ │ ├── AboutPage.module.scss │ │ │ │ └── AboutPage.tsx │ │ │ ├── configs │ │ │ │ ├── ConfigPage.module.scss │ │ │ │ └── ConfigPage.tsx │ │ │ └── links │ │ │ │ ├── LinkCard.scss │ │ │ │ ├── LinkCard.tsx │ │ │ │ ├── LinkForm.scss │ │ │ │ ├── LinkForm.tsx │ │ │ │ ├── LinkManage.module.scss │ │ │ │ ├── LinkManage.tsx │ │ │ │ ├── LinkPagination.module.scss │ │ │ │ ├── LinkPagination.tsx │ │ │ │ ├── LinksList.scss │ │ │ │ ├── LinksList.tsx │ │ │ │ ├── LinksPage.tsx │ │ │ │ └── StatusDot.tsx │ │ ├── Error │ │ │ └── ErrorBoundary.tsx │ │ ├── Loading.tsx │ │ ├── Root.tsx │ │ ├── Share │ │ │ ├── Buttons │ │ │ │ ├── LinkFormButton.scss │ │ │ │ ├── LinkFormButton.tsx │ │ │ │ ├── RefreshLinksButton.tsx │ │ │ │ └── SwitchThemeButton.tsx │ │ │ ├── ContentHeader.module.scss │ │ │ └── ContentHeader.tsx │ │ └── SvgLogo.tsx │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── docs └── API.md └── server ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── src ├── admin │ ├── auth.rs │ ├── error.rs │ └── mod.rs ├── config.rs ├── main.rs └── redirect │ ├── error.rs │ ├── mod.rs │ └── model.rs └── static ├── .gitkeep ├── RabiJumpLOGO.svg ├── assets ├── AboutPage.13d77f91.js ├── AboutPage.1ad32ce6.js ├── AboutPage.239f25e6.js ├── AboutPage.4d0ac899.css ├── AboutPage.f587680d.js ├── ConfigPage.0a9bf7b1.js ├── ConfigPage.11f1853f.js ├── ConfigPage.a1f5bfe0.js ├── ConfigPage.ae297e14.css ├── ConfigPage.d5b7f04c.js ├── ContentHeader.3f02b2ce.js ├── ContentHeader.49828624.js ├── ContentHeader.af246c83.js ├── ContentHeader.c5ea3469.js ├── ContentHeader.fbe8eef8.css ├── DashBoard.2cb688ce.js ├── DashBoard.6da55a18.css ├── DashBoard.7ac1d1ae.js ├── DashBoard.8b8b443f.js ├── DashBoard.9dc19fe4.js ├── Home.78bc5386.js ├── Home.b2b16dde.js ├── Home.b97b4313.js ├── Home.f056bc95.js ├── LinksPage.06e03c77.js ├── LinksPage.0dfd10be.css ├── LinksPage.1589f497.css ├── LinksPage.7b0b7c77.js ├── LinksPage.af82e9d3.js ├── LinksPage.c0b1df4b.js ├── SwitchThemeButton.82b71c6d.js ├── SwitchThemeButton.8b819133.js ├── SwitchThemeButton.8df1ac05.js ├── SwitchThemeButton.f8b4d481.js ├── index.7b5bcad8.js ├── index.811d51f0.js ├── index.ac25c197.js ├── index.b2db8b05.css ├── index.da1ae531.css ├── index.fb700652.js ├── useTranslation.2253e192.js ├── useTranslation.32f89b5f.js ├── useTranslation.792e573a.js └── useTranslation.950ae68b.js └── index.html /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🐰RabiJump 2 | 3 | [简体中文](https://github.com/KernelErr/RabiJump/blob/main/README_CN.md) | [API Doc](https://github.com/KernelErr/RabiJump/blob/main/docs/API.md) 4 | 5 | RabiJump is an open source redirection service aimed for efficiency and simplicity. 6 | 7 | 🎆 Features: 8 | 9 | - Built-in filesystem database. 10 | - Built-in web admin panel. 11 | - Seperate redirects for mobile phone and desktop. 12 | - Enable/disable parameter support (url?foo=bar). 13 | - Change redirection method (301, 302, 307, 308). 14 | - Different ports for redirect and admin. 15 | - Small binary size and RAM usage. 16 | - Visit count and log rotation. 17 | - API support, create your script! 18 | 19 | ## Deploy 20 | 21 | Docker is the easiest way to deploy RabiJump. Or you can clone the repository and simply run `cargo run --release` in server directory. 22 | 23 | ```bash 24 | docker run -d -p 8080:8080 -p 8081:8081 -v db_path:/app/database -v log_path:/app/logs memorysafety/rabijump:0.1.0 25 | ``` 26 | 27 | This command will start RabiJump in Docker container with `db_path` and `log_path` mounted to the container. You can use `docker logs` to check the generated admin token if you didn't set it in the environment variable. 28 | 29 | ### Port 30 | 31 | - 8080: Redirect port. 32 | - 8081: Admin port with web panel. 33 | 34 | ### Environment variables 35 | 36 | - DATABASE_PATH: Path to the database. Default: `database`. 37 | - LOG_PATH: Path to the logs. Default to `logs`. 38 | - FALLBACK_TARGET: Target to redirect to if no match is found, also for index. Default is return 404. 39 | - ALLOW_ORIGIN: CORS `Access-Control-Allow-Origin` header. 40 | - TOKEN: Admin token, if not set, a random token will be generated and displayed in the stdout. 41 | 42 | ## Performance 43 | 44 | RabiJump is designed to be simple and fast. A new instance of RabiJump takes about 30MB RAM. A RabiJump with 10k redirects takes about 70MB RAM and 30MB disk space. Its QPS could reach 30k+. 45 | 46 | ## License and Credits 47 | 48 | RabiJump is licensed under the Apache-2.0 License. Thanks so much for the following open source projects: 49 | 50 | - [Poem](https://github.com/poem-web/poem) 51 | - [Sled](https://github.com/spacejam/sled) 52 | - [CBL-Mariner](https://github.com/microsoft/CBL-Mariner) 53 | - [Semi-design](https://github.com/DouyinFE/semi-design) 54 | - ... -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | # 🐰RabiJump 2 | 3 | [English](https://github.com/KernelErr/RabiJump/blob/main/README.md) 4 | 5 | RabiJump是一个简单高效的开源跳转(短链接)工具。 6 | 7 | 🎆 特性: 8 | 9 | - 内置文件数据库。 10 | - 内置管理面板。 11 | - 可以对手机电脑分别设置跳转。 12 | - 可以启用/禁用参数(url?foo=bar)。 13 | - 可以设置跳转方式(301, 302, 307, 308)。 14 | - 跳转和管理服务监听不同端口。 15 | - 内存占用和二进制大小较小。 16 | - 访问统计和日志轮换。 17 | - 提供API接口,创建你的脚本! 18 | 19 | ## 部署 20 | 21 | Docker是部署RabiJump最简单的方式之一。你也可以克隆仓库并在server目录下直接运行`cargo run --release`。 22 | 23 | 24 | ```bash 25 | docker run -d -p 8080:8080 -p 8081:8081 -v db_path:/app/database -v log_path:/app/logs memorysafety/rabijump:0.1.0 26 | ``` 27 | 28 | 这条命令会启动RabiJump容器,并挂载数据库文件夹`db_path`和日志文件夹`log_path`。如果没有指定管理Token,你可以通过`docker logs`命令去查看生成的Token。 29 | 30 | ### 端口 31 | 32 | - 8080: 跳转服务端口。 33 | - 8081: 管理界面/API端口。 34 | 35 | ### 环境变量 36 | 37 | - DATABASE_PATH:数据库文件夹,默认是`database`。 38 | - LOG_PATH:日志文件夹,默认是`logs`. 39 | - FALLBACK_TARGET:没有找到对应跳转的默认跳转地址,对`/`也同样适用。默认是直接返回404。 40 | - ALLOW_ORIGIN:CORS `Access-Control-Allow-Origin` 标头. 41 | - TOKEN:管理Token,如果没有设置会自动生成一个。 42 | 43 | ## 性能 44 | 45 | RabiJump在设计上注重简单和高效。一个空的RabiJump实例大约占用30MB的内存。一个有10k条跳转的RabiJump大约占用70MB的内存和30MB的磁盘空间。它的QPS可以达到30k以上。 46 | 47 | ## 开源许可证 & 致谢 48 | 49 | RabiJump以Apache 2.0开源许可证发布。非常感谢一下的开源项目: 50 | 51 | - [Poem](https://github.com/poem-web/poem) 52 | - [Sled](https://github.com/spacejam/sled) 53 | - [CBL-Mariner](https://github.com/microsoft/CBL-Mariner) 54 | - [Semi-design](https://github.com/DouyinFE/semi-design) 55 | - ... -------------------------------------------------------------------------------- /app/.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 | *.zip 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | ## A simple web for management. -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | RabiJump 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@douyinfe/semi-ui": "^2.16.1", 13 | "axios": "^0.27.2", 14 | "i18next": "^21.9.0", 15 | "i18next-browser-languagedetector": "^6.1.5", 16 | "i18next-http-backend": "^1.4.1", 17 | "lodash": "^4.17.21", 18 | "moment": "^2.29.4", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "react-i18next": "^11.18.3", 22 | "react-router-dom": "^6.3.0", 23 | "reset-css": "^5.0.1", 24 | "sass": "^1.54.5", 25 | "scss": "^0.2.4", 26 | "zustand": "^4.0.0" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "^18.7.8", 30 | "@types/react": "^18.0.15", 31 | "@types/react-dom": "^18.0.6", 32 | "@vitejs/plugin-react": "^2.0.0", 33 | "typescript": "^4.6.4", 34 | "vite": "^3.0.0" 35 | } 36 | } -------------------------------------------------------------------------------- /app/public/RabiJumpLOGO.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/api/connect.ts: -------------------------------------------------------------------------------- 1 | import request from "@app/misc/request/axios" 2 | import { ExtendedRequest } from "@app/misc/request/axiosMiddleware"; 3 | import { ApiConfig } from "@app/type/app"; 4 | import { AxiosPromise } from "axios"; 5 | 6 | const endpoint = '/api' 7 | 8 | export function backendAuth(apiConfig: ApiConfig): AxiosPromise<{ status: string }> { 9 | return request.get(`${endpoint}/auth`, { ...apiConfig } as ExtendedRequest); 10 | } 11 | 12 | export function backendVersion(apiConfig: ApiConfig): AxiosPromise { 13 | return request.get(`${endpoint}/version`, { ...apiConfig } as ExtendedRequest); 14 | } -------------------------------------------------------------------------------- /app/src/api/redirect.ts: -------------------------------------------------------------------------------- 1 | import request from "@app/misc/request/axios" 2 | import { ExtendedRequest } from "@app/misc/request/axiosMiddleware"; 3 | import { ApiConfig } from "@app/type/app"; 4 | import { LinkProps } from "@app/type/link"; 5 | import { AxiosPromise } from "axios"; 6 | 7 | const endpoint = '/api/redirect' 8 | 9 | export function getRedirect(name: string, apiConfig: ApiConfig): AxiosPromise { 10 | return request.get(`${endpoint}/${name}`, { ...apiConfig } as ExtendedRequest) 11 | } 12 | 13 | export function getRedirectLists(apiConfig: ApiConfig, padding?: any, count?: number, skip?: number): AxiosPromise<{ total: number, data: Array }> { 14 | return request.get(`${endpoint};list`, { ...apiConfig, params: { count: count, skip: skip } } as ExtendedRequest) 15 | } 16 | 17 | export function createRedirect(linkProps: LinkProps, apiConfig: ApiConfig): AxiosPromise { 18 | return request.post(`${endpoint}/${linkProps.name}`, { ...linkProps }, { ...apiConfig } as ExtendedRequest) 19 | } 20 | 21 | export function updateRedirect(linkProps: LinkProps, apiConfig: ApiConfig): AxiosPromise { 22 | return createRedirect(linkProps, apiConfig) 23 | } 24 | 25 | export function deleteRedirect(name: string, apiConfig: ApiConfig): AxiosPromise<{ status: string }> { 26 | return request.delete(`${endpoint}/${name}`, { ...apiConfig } as ExtendedRequest) 27 | } 28 | 29 | export function searchRedirectsByPrefix(apiConfig: ApiConfig, prefix: string, count?: number, skip?: number): AxiosPromise<{ total: number, data: Array }> { 30 | 31 | return request.get(`${endpoint};search?prefix=${prefix}`, { ...apiConfig, params: { count: count, skip: skip } } as ExtendedRequest) 32 | } 33 | 34 | export function getRedirectVisitCount(name: string, apiConfig: ApiConfig): AxiosPromise<{ count: number }> { 35 | return request.get(`${endpoint}/${name}/count`, { ...apiConfig } as ExtendedRequest) 36 | } -------------------------------------------------------------------------------- /app/src/assets/RabiJumpLOGO.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/custom.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | declare const __VERSION__: string; 5 | 6 | -------------------------------------------------------------------------------- /app/src/hooks/basic.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from "react"; 2 | 3 | export function useToggle(initialValue = false) { 4 | const [isOn, setState] = useState(initialValue); 5 | const toggle = useCallback(() => setState((x) => !x), []); 6 | return { isOn, toggle }; 7 | } -------------------------------------------------------------------------------- /app/src/hooks/useLoading.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import CommonStore from '@app/store/common' 4 | 5 | export function useLoading() { 6 | const [loading, setLoading] = useState(false) 7 | useEffect(() => { 8 | CommonStore.subscribe((state) => setLoading(state.loading)) 9 | return () => { 10 | CommonStore.destroy(); 11 | } 12 | }, []) 13 | return { loading } 14 | } -------------------------------------------------------------------------------- /app/src/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "Overview": "Overview", 3 | "Links": "Links", 4 | "Links.name": "Name", 5 | "Links.description": "Description", 6 | "Links.target": "Target", 7 | "Links.mobile_target": "Mobile target", 8 | "Links.parameters": "Allow parameters", 9 | "Links.active": "Active", 10 | "Links.modified": "Last modified", 11 | "Links.status_code": "Redirect type", 12 | "Links.count": "Visit count", 13 | "Config": "Config", 14 | "About": "About", 15 | "false": "No", 16 | "true": "Yes", 17 | "cancle": "Cancle", 18 | "update": "Update", 19 | "create": "Create", 20 | "add": "Add", 21 | "search": "Search", 22 | "search.ByPrefix": "Search by prefix", 23 | "switch_backend": "Switch backend", 24 | "collapse": "Collapse", 25 | "time": "{{val,MM/DD/YYYY HH:mm:ss Z}}", 26 | "Language": "Language", 27 | "Theme": "Theme" 28 | } -------------------------------------------------------------------------------- /app/src/i18n/resources.ts: -------------------------------------------------------------------------------- 1 | import en from "./en.json" 2 | import zh from "./zh.json" 3 | 4 | export const resources = { 5 | "zh": { 6 | translation: zh 7 | }, 8 | "en": { 9 | translation: en 10 | } 11 | } 12 | 13 | export const trans_key = { 14 | "Overview": "Overview", 15 | "Links": "Links", 16 | "Links.name": "Links.name", 17 | "Links.description": "Links.description", 18 | "Links.target": "Links.target", 19 | "Links.mobile_target": "Links.mobile_target", 20 | "Links.parameters": "Links.parameters", 21 | "Links.active": "Links.active", 22 | "Links.modified": "Links.modified", 23 | "Links.status_code": "Links.status_code", 24 | "Config": "Config", 25 | 26 | "About": "About", 27 | 28 | "false": "false", 29 | "true": "true", 30 | "cancle": "cancle", 31 | "update": "update", 32 | "create": "create", 33 | "add": "add", 34 | "search": "search", 35 | "search.ByPrefix": "search.ByPrefix", 36 | "collapse": "collapse", 37 | "switch_backend": "switch_backend", 38 | 39 | "Language": "Language", 40 | "Theme": "Theme" 41 | } 42 | 43 | export const languageOptions: Array<[string, string]> = [ 44 | ['zh', '中文'], 45 | ['en', 'English'] 46 | ] 47 | 48 | import zh_CN from '@douyinfe/semi-ui/lib/es/locale/source/zh_CN'; 49 | import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US'; 50 | 51 | type PairType = { 52 | [key: string]: any 53 | } 54 | 55 | export const semiLocalesOptions: PairType = { 56 | zh: zh_CN, 57 | en: en_US 58 | } -------------------------------------------------------------------------------- /app/src/i18n/zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "Overview": "概览", 3 | "Links": "链接", 4 | "Links.name": "名称", 5 | "Links.description": "描述", 6 | "Links.target": "地址", 7 | "Links.mobile_target": "移动端地址", 8 | "Links.parameters": "启用参数", 9 | "Links.active": "启用链接", 10 | "Links.modified": "上次修改", 11 | "Links.count": "访问次数", 12 | "Links.status_code": "跳转类型", 13 | "Config": "配置", 14 | "About": "关于", 15 | "false": "否", 16 | "true": "是", 17 | "cancle": "取消", 18 | "update": "更新", 19 | "create": "创建", 20 | "add": "添加", 21 | "search": "搜索", 22 | "search.ByPrefix": "前缀搜索", 23 | "collapse": "收起", 24 | "switch_backend": "切换服务", 25 | "time": "{{val,YYYY/MM/DD HH:mm:ss Z}}", 26 | "Language": "语言", 27 | "Theme": "主题" 28 | } -------------------------------------------------------------------------------- /app/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif; 3 | font-size: 16px; 4 | line-height: 24px; 5 | font-weight: 400; 6 | 7 | color-scheme: light dark; 8 | color: rgba(255, 255, 255, 0.87); 9 | background-color: #242424; 10 | 11 | font-synthesis: none; 12 | text-rendering: optimizeLegibility; 13 | -webkit-font-smoothing: antialiased; 14 | -moz-osx-font-smoothing: grayscale; 15 | -webkit-text-size-adjust: 100%; 16 | } 17 | 18 | a { 19 | font-weight: 500; 20 | color: #646cff; 21 | text-decoration: inherit; 22 | } 23 | a:hover { 24 | color: #535bf2; 25 | } 26 | 27 | body { 28 | margin: 0; 29 | display: flex; 30 | place-items: center; 31 | min-width: 320px; 32 | min-height: 100vh; 33 | } 34 | 35 | h1 { 36 | font-size: 3.2em; 37 | line-height: 1.1; 38 | } 39 | 40 | button { 41 | border-radius: 8px; 42 | border: 1px solid transparent; 43 | padding: 0.6em 1.2em; 44 | font-size: 1em; 45 | font-weight: 500; 46 | font-family: inherit; 47 | background-color: #1a1a1a; 48 | cursor: pointer; 49 | transition: border-color 0.25s; 50 | } 51 | button:hover { 52 | border-color: #646cff; 53 | } 54 | button:focus, 55 | button:focus-visible { 56 | outline: 4px auto -webkit-focus-ring-color; 57 | } 58 | 59 | @media (prefers-color-scheme: light) { 60 | :root { 61 | color: #213547; 62 | background-color: #ffffff; 63 | } 64 | a:hover { 65 | color: #747bff; 66 | } 67 | button { 68 | background-color: #f9f9f9; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from "react"; 2 | import { render } from "react-dom"; 3 | import { createRoot } from "react-dom/client" 4 | import Root from './views/Root' 5 | 6 | import "./styles/global.scss" 7 | import "./misc/i18n" 8 | 9 | const root = createRoot( 10 | document.getElementById("root")! 11 | ) 12 | 13 | root.render( 14 | 15 | 16 | 17 | ) 18 | 19 | -------------------------------------------------------------------------------- /app/src/misc/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18next from "i18next"; 2 | import LanguageDetector from 'i18next-browser-languagedetector'; 3 | import HttpBackend from 'i18next-http-backend'; 4 | import { initReactI18next } from "react-i18next"; 5 | import moment from "moment" 6 | 7 | import { resources } from "@app/i18n/resources" 8 | 9 | i18next 10 | .use(HttpBackend) 11 | .use(LanguageDetector) 12 | .use(initReactI18next) 13 | .init({ 14 | debug: process.env.NODE_ENV === 'development', 15 | resources, 16 | supportedLngs: ['en', 'zh'], 17 | fallbackLng: 'en', 18 | interpolation: { 19 | escapeValue: false, 20 | format: function (value, format, lng) { 21 | if (value instanceof Date) return moment(value).format(format) 22 | return value 23 | } 24 | } 25 | }) 26 | 27 | export default i18next -------------------------------------------------------------------------------- /app/src/misc/request/axios.ts: -------------------------------------------------------------------------------- 1 | import { getState } from "@app/store/common"; 2 | import { Notification } from "@douyinfe/semi-ui"; 3 | import axios from "axios"; 4 | import { axiosInit } from "./axiosMiddleware"; 5 | 6 | function showError(content: string, title: string) { 7 | Notification.error({ 8 | content: content, 9 | duration: 3, 10 | title 11 | }) 12 | } 13 | 14 | function hidleLoading() { 15 | getState().toogleLoading(false); 16 | } 17 | 18 | const request = axios.create({ 19 | }) 20 | 21 | axiosInit(request, showError, hidleLoading); 22 | export default request; -------------------------------------------------------------------------------- /app/src/misc/request/axiosMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance, AxiosRequestConfig } from "axios"; 2 | import { getState } from "@app/store/common" 3 | import { getState as globalState } from "@app/store/app"; 4 | 5 | export interface ExtendedRequest extends AxiosRequestConfig { 6 | token?: string 7 | } 8 | 9 | export function axiosInit(axios: AxiosInstance, showError: (content: string, title: string) => void, hideLoading: () => void) { 10 | const { toogleLoading } = getState(); 11 | axios.interceptors.request.use((request: ExtendedRequest) => { 12 | toogleLoading(true); 13 | //@ts-ignore 14 | if (request.token) request.headers['Authorization'] = `Bearer ${request.token}` 15 | return request; 16 | }) 17 | axios.interceptors.response.use( 18 | (response) => { 19 | toogleLoading(false); 20 | return response; 21 | }, 22 | function (error) { 23 | return onError(error); 24 | } 25 | ) 26 | function onError(error: any): Promise { 27 | hideLoading(); 28 | const errPrefix = "Error" 29 | showError(error.response.data || error.code || 'Something wrong', errPrefix); 30 | return Promise.reject(error); 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/misc/storage.ts: -------------------------------------------------------------------------------- 1 | import { GlobalState } from "@app/type/app"; 2 | 3 | const StorageKey = 'RabiJump'; 4 | 5 | function loadState() { 6 | try { 7 | const serialized = localStorage.getItem(StorageKey); 8 | if (!serialized) return undefined; 9 | return JSON.parse(serialized); 10 | } catch (err) { 11 | return undefined; 12 | } 13 | } 14 | 15 | function saveState(state: GlobalState) { 16 | try { 17 | const serialized = JSON.stringify(state); 18 | localStorage.setItem(StorageKey, serialized); 19 | } catch (err) { 20 | 21 | } 22 | } 23 | 24 | function clearState() { 25 | try { 26 | localStorage.removeItem(StorageKey); 27 | } catch (err) { 28 | 29 | } 30 | } 31 | 32 | export { loadState, saveState, clearState }; 33 | 34 | -------------------------------------------------------------------------------- /app/src/misc/util.ts: -------------------------------------------------------------------------------- 1 | export function debounce( 2 | fn: (...args: T) => unknown, 3 | timeout: number 4 | ) { 5 | let timeoutId: ReturnType; 6 | return (...args: T) => { 7 | if (timeoutId) clearTimeout(timeoutId); 8 | timeoutId = setTimeout(() => { 9 | fn(...args); 10 | }, timeout) 11 | } 12 | } 13 | export function delay( 14 | fn: (...args: T) => unknown, 15 | timeout: number 16 | ) { 17 | return (...args: T) => { 18 | setTimeout(() => { 19 | fn(...args) 20 | }, timeout) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/store/app.ts: -------------------------------------------------------------------------------- 1 | import { ApiConfig, GlobalState, ThemeStyle } from '@app/type/app' 2 | import create from 'zustand' 3 | 4 | import { loadState, saveState } from '@app/misc/storage' 5 | 6 | 7 | const defaultApiConfig: ApiConfig = { 8 | baseURL: location.origin, 9 | token: '', 10 | } 11 | 12 | const defaultState: GlobalState = { 13 | apiConfigs: [defaultApiConfig], 14 | selectedApiConfigIndex: 0, 15 | 16 | theme: 'dark' 17 | } 18 | 19 | type State = { 20 | app: GlobalState, 21 | addApiConfig: ({ baseURL, token }: ApiConfig) => void, 22 | removeApiConfig: ({ baseURL, token }: ApiConfig) => void, 23 | selectApiConfig: ({ baseURL, token }: ApiConfig) => void, 24 | switchTheme: () => void 25 | } 26 | 27 | export const getApiConfigs = (app: GlobalState) => app.apiConfigs 28 | export const getSelectedApiConfigIndex = (app: GlobalState) => app.selectedApiConfigIndex 29 | export const getTheme = (app: GlobalState) => app.theme 30 | export const getCurrentApiConfig = (app: GlobalState) => app.apiConfigs[app.selectedApiConfigIndex]; 31 | 32 | 33 | export const useGlobalStore = create((set, get) => { 34 | return { 35 | app: initialState(), 36 | 37 | addApiConfig: ({ baseURL, token }: ApiConfig) => { 38 | const app = get().app; 39 | const idx = findApiConfigIndex(app, { baseURL, token }) 40 | if (idx !== undefined) return; 41 | const apiConfig: ApiConfig = { baseURL, token }; 42 | app.apiConfigs.push(apiConfig); 43 | set({ app: app }); 44 | saveState(app); 45 | }, 46 | 47 | removeApiConfig: ({ baseURL, token }: ApiConfig) => { 48 | const app = get().app; 49 | const idx = findApiConfigIndex(app, { baseURL, token }); 50 | app.apiConfigs.splice(idx as number, 1); 51 | set({ app: app }); 52 | saveState(app); 53 | }, 54 | 55 | selectApiConfig: ({ baseURL, token }: ApiConfig) => { 56 | const app = get().app; 57 | const idx = findApiConfigIndex(app, { baseURL, token }); 58 | const cur = getSelectedApiConfigIndex(app); 59 | if (cur !== idx) { 60 | app.selectedApiConfigIndex = idx as number; 61 | } 62 | set({ app: app }); 63 | saveState(app); 64 | 65 | }, 66 | 67 | switchTheme: () => { 68 | const app = get().app; 69 | const currentTheme = getTheme(app); 70 | const theme = currentTheme === 'light' ? 'dark' : 'light'; 71 | app.theme = theme; 72 | changeTheme(theme) 73 | set({ app: app }); 74 | saveState(app); 75 | } 76 | 77 | } 78 | }) 79 | 80 | const { getState, setState, subscribe, destroy } = useGlobalStore; 81 | export { getState, setState, subscribe, destroy }; 82 | 83 | function findApiConfigIndex(app: GlobalState, { baseURL, token }: ApiConfig): number | undefined { 84 | const arr = getApiConfigs(app); 85 | for (let i = 0; i < arr.length; i++) { 86 | const x = arr[i]; 87 | if (x.baseURL === baseURL && x.token === token) return i; 88 | } 89 | } 90 | 91 | 92 | export function initialState() { 93 | let s = loadState() as GlobalState; 94 | s = { ...defaultState, ...s } 95 | 96 | changeTheme(s.theme); 97 | return s; 98 | } 99 | 100 | function changeTheme(theme: ThemeStyle) { 101 | const body = document.body; 102 | if (theme == 'dark') { 103 | body.setAttribute('theme-mode', 'dark') 104 | } else { 105 | body.removeAttribute('theme-mode') 106 | } 107 | } 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /app/src/store/common.ts: -------------------------------------------------------------------------------- 1 | import create from 'zustand' 2 | 3 | type State = { 4 | loading: boolean, 5 | toogleLoading: (i: boolean) => void 6 | } 7 | 8 | const CommonStore = create((set, get) => ({ 9 | loading: false, 10 | toogleLoading: (i = false) => set({ loading: i }) 11 | })) 12 | 13 | const { getState, setState, subscribe, destroy } = CommonStore; 14 | export { getState, setState, subscribe, destroy }; 15 | export default CommonStore; -------------------------------------------------------------------------------- /app/src/store/links.ts: -------------------------------------------------------------------------------- 1 | 2 | import { LinkProps } from '@app/type/link' 3 | import create from 'zustand' 4 | 5 | const defaultCount = 10; 6 | const defaultSkip = 0; 7 | 8 | interface State { 9 | linkList: Array, 10 | paginationConfig: { 11 | count: number, 12 | skip: number 13 | }, 14 | total: number, 15 | prefix: string, 16 | setLinkList: (list: Array) => void, 17 | pushLinkList: (item: LinkProps) => void, 18 | setPaginationConfig: (count?: number, skip?: number) => void, 19 | setTotal: (total: number) => void, 20 | setPrefix: (v: string) => void; 21 | } 22 | 23 | export const useLinkListStore = create((set, get) => ({ 24 | linkList: [], 25 | paginationConfig: { 26 | count: defaultCount, 27 | skip: defaultSkip 28 | }, 29 | total: 0, 30 | prefix: '', 31 | setLinkList: (list) => set({ linkList: [...list] }), 32 | pushLinkList: (item) => { 33 | const { linkList, setLinkList } = get(); 34 | let idx = isLinkExist(linkList, item) 35 | if (idx == -1) { 36 | const { total, setTotal } = get(); 37 | linkList.push(item) 38 | setTotal(total + 1); 39 | } else { 40 | linkList[idx] = item 41 | } 42 | setLinkList(linkList); 43 | }, 44 | setPaginationConfig: function (_count, _skip) { 45 | let count = _count ? _count : defaultCount; 46 | let skip = _skip ? _skip : defaultSkip; 47 | set({ paginationConfig: { count: count, skip: skip } }) 48 | }, 49 | setTotal: (total) => set({ total: total }), 50 | setPrefix: (v) => set({ prefix: v }), 51 | })) 52 | 53 | function isLinkExist(linkList: Array, item: LinkProps): number { 54 | for (let [idx, obj] of linkList.entries()) { 55 | if (obj.name == item.name) { 56 | return idx 57 | } 58 | } 59 | return -1 60 | } -------------------------------------------------------------------------------- /app/src/styles/global.scss: -------------------------------------------------------------------------------- 1 | @import "/node_modules/reset-css/sass/reset"; 2 | @import "/node_modules/@douyinfe/semi-ui/dist/css/semi.min.css"; 3 | @import url("./scrollbar.scss"); 4 | 5 | html, 6 | body, 7 | #root, 8 | #root>* { 9 | height: 100%; 10 | margin: 0; 11 | 12 | .flex-between { 13 | display: flex; 14 | justify-content: space-between; 15 | align-items: center; 16 | } 17 | 18 | a:-webkit-any-link { 19 | color: unset; 20 | text-decoration: none; 21 | } 22 | } 23 | 24 | body { 25 | color: var(--semi-color-text-0); 26 | background-color: var(--semi-color-bg-0); 27 | font-family: var(--font-normal); 28 | } 29 | 30 | :root { 31 | --font-normal: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, 'PingFang SC', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif; 32 | } -------------------------------------------------------------------------------- /app/src/styles/scrollbar.scss: -------------------------------------------------------------------------------- 1 | ::-webkit-scrollbar { 2 | width: 12px; 3 | height: 8px; 4 | } 5 | 6 | ::-webkit-scrollbar-corner { 7 | background-color: transparent; 8 | } 9 | 10 | ::-webkit-scrollbar-thumb { 11 | border-radius: 6px; 12 | background: var(--semi-color-tertiary-light-hover); 13 | } 14 | 15 | ::-webkit-scrollbar-thumb:hover { 16 | background: var(--semi-color-tertiary-light-active) 17 | } 18 | 19 | ::-webkit-scrollbar-track { 20 | background: transparent; 21 | } -------------------------------------------------------------------------------- /app/src/type/app.ts: -------------------------------------------------------------------------------- 1 | export type ApiConfig = { 2 | baseURL: string; 3 | token?: string; 4 | }; 5 | 6 | export type GlobalState = { 7 | apiConfigs: Array, 8 | selectedApiConfigIndex: number, 9 | 10 | theme: ThemeStyle, 11 | } 12 | 13 | export type ThemeStyle = 'dark' | 'light' -------------------------------------------------------------------------------- /app/src/type/link.ts: -------------------------------------------------------------------------------- 1 | type PairType = { 2 | [key: string]: any 3 | } 4 | 5 | export interface LinkProps extends BasicLinkProps, PairType { 6 | last_modified: string, 7 | mobile_target: string | null, 8 | status_code: number | null, 9 | } 10 | 11 | export interface BasicLinkProps { 12 | name: string, 13 | desc: string, 14 | target: string, 15 | active: boolean, 16 | allow_parameters: boolean, 17 | } -------------------------------------------------------------------------------- /app/src/views/APIConfig.scss: -------------------------------------------------------------------------------- 1 | .apiconfig-layout.semi-layout{ 2 | header.apiconfig-flex{ 3 | padding-top: 48px; 4 | } 5 | .apiconfig-flex{ 6 | display: flex; 7 | align-items: center; 8 | flex-direction: column; 9 | } 10 | .apiconfig-inputgroup{ 11 | 12 | button{ 13 | margin-left: 24px; 14 | } 15 | } 16 | 17 | } 18 | 19 | #root > { 20 | .apiconfig-layout.semi-layout{ 21 | margin-left: 20px; 22 | margin-right: 20px; 23 | } 24 | } 25 | 26 | .apiconfig-button-flex{ 27 | padding:5px 0 10px; 28 | display: flex; 29 | justify-content: flex-end; 30 | align-items: center; 31 | } 32 | 33 | -------------------------------------------------------------------------------- /app/src/views/APIConfig.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useCallback, useState } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import "./APIConfig.scss" 4 | 5 | import { Button, Col, Form, InputGroup, Layout, Row } from "@douyinfe/semi-ui" 6 | import SvgLogo from "./SvgLogo"; 7 | import { Input } from "@douyinfe/semi-ui/lib/es/input"; 8 | import BackendList from "./BackendList"; 9 | import { useGlobalStore } from "@app/store/app"; 10 | import { backendAuth } from "@app/api/connect"; 11 | 12 | const defaultURL = location.origin 13 | 14 | const APIConfig: FC = () => { 15 | const { Header, Content } = Layout 16 | const [baseURL, setBaseURL] = useState(''); 17 | const [token, setToken] = useState(''); 18 | const verifyHandler = async (value: any) => { 19 | let errors = {} as any 20 | let _baseURL: string = baseURL ? baseURL : defaultURL; 21 | try { 22 | new URL(_baseURL); 23 | } catch (e) { 24 | if (_baseURL) { 25 | const prefix = _baseURL.substring(0, 7); 26 | if (prefix !== 'http://' && prefix !== 'https:/') { 27 | errors.baseURL = 'Must starts with http:// or https://' 28 | } 29 | } else { 30 | errors.baseURL = 'Invalid URL' 31 | } 32 | return errors 33 | } 34 | try { 35 | const res = await backendAuth({ baseURL: _baseURL, token }) 36 | } catch (e) { 37 | errors.baseURL = 'Failed to connect' 38 | return errors 39 | } 40 | return "" 41 | } 42 | const submitHandler = () => { 43 | useGlobalStore.getState().addApiConfig({ baseURL: `${baseURL ? baseURL : defaultURL}`, token }) 44 | } 45 | return ( 46 | <> 47 | 48 |
49 | 50 |
54 | 55 | 56 | 61 | 62 | 63 | 66 | 67 | 68 | 69 | 70 |
71 | 72 |
73 | 74 |
75 |
76 |
77 | 78 | 79 | 80 |
81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | ) 89 | } 90 | 91 | export default APIConfig; -------------------------------------------------------------------------------- /app/src/views/BackendList.scss: -------------------------------------------------------------------------------- 1 | .semi-card.backend-list-card{ 2 | button{ 3 | visibility: hidden; 4 | } 5 | div.semi-card-meta-wrapper{ 6 | flex-grow:1 7 | } 8 | div.semi-card-meta-wrapper div:nth-child(1){ 9 | a{ 10 | display: inline-block; 11 | width: 100%; 12 | } 13 | a:hover span{ 14 | text-shadow: 1px 2px 2px #d8d8d8; 15 | } 16 | } 17 | div.semi-card-meta-wrapper div:nth-child(2){ 18 | display: flex; 19 | justify-content: space-between; 20 | align-items: center; 21 | span{ 22 | cursor:text; 23 | } 24 | } 25 | max-width: 520px; 26 | margin: 12px auto; 27 | cursor: default; 28 | } 29 | 30 | .semi-card.backend-list-card:hover{ 31 | button{ 32 | visibility: visible; 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/views/BackendList.tsx: -------------------------------------------------------------------------------- 1 | import { useToggle } from "@app/hooks/basic"; 2 | import { useGlobalStore } from "@app/store/app"; 3 | import { ApiConfig } from "@app/type/app"; 4 | import { IconClose, IconEyeClosedSolid, IconEyeOpened } from "@douyinfe/semi-icons"; 5 | import { Button, Card } from "@douyinfe/semi-ui"; 6 | import { Link } from "react-router-dom"; 7 | import "./BackendList.scss" 8 | 9 | function BackendList() { 10 | 11 | const [app, removeApiConfig, selectApiConfig] = useGlobalStore(state => 12 | [state.app, state.removeApiConfig, state.selectApiConfig]) 13 | return ( 14 | <> 15 | {app.apiConfigs.map((item, idx) => ( 16 | 23 | ))} 24 | 25 | ) 26 | } 27 | 28 | function CardItem({ 29 | baseURL, 30 | token, 31 | onRemove, 32 | onSelect 33 | }: { 34 | baseURL: string, 35 | token: string | undefined, 36 | onRemove: (api: ApiConfig) => void, 37 | onSelect: (api: ApiConfig) => void, 38 | }) { 39 | const { Meta } = Card; 40 | const { isOn: show, toggle } = useToggle(); 41 | const Icon = show ? IconEyeClosedSolid : IconEyeOpened 42 | return ( 43 | 47 | 50 | onSelect({ baseURL, token })}>{baseURL} 51 | 52 | } 53 | avatar={ 54 | 32 | 33 | 34 |
35 |
{t('Theme')}
36 | 37 |
38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | export default ConfigPage -------------------------------------------------------------------------------- /app/src/views/DashBoard/links/LinkCard.scss: -------------------------------------------------------------------------------- 1 | span.semi-descriptions-key { 2 | white-space: normal; 3 | } 4 | 5 | div.linkcard-description-item-count { 6 | display: flex; 7 | justify-content: space-between; 8 | align-items: center 9 | } 10 | 11 | div.listcard-description { 12 | .semi-descriptions-item { 13 | vertical-align: middle; 14 | } 15 | 16 | } 17 | 18 | div.semi-collapsible-wrapper { 19 | cursor: auto; 20 | } -------------------------------------------------------------------------------- /app/src/views/DashBoard/links/LinkCard.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { BasicLinkProps, LinkProps } from '@app/type/link'; 3 | import { useState } from 'react'; 4 | import { Card, Button, Popover, ButtonGroup, Collapse, Descriptions, Switch, Popconfirm, Form, Notification, Typography } from '@douyinfe/semi-ui'; 5 | import { IconClose, IconEdit, IconRefresh, IconRefresh2 } from "@douyinfe/semi-icons"; 6 | import { t } from "i18next"; 7 | import StatusDot from "./StatusDot"; 8 | import LinkForm from './LinkForm'; 9 | 10 | import "./LinkCard.scss" 11 | import { getRedirectVisitCount } from '@app/api/redirect'; 12 | import { getCurrentApiConfig, useGlobalStore } from '@app/store/app'; 13 | import { delay } from '@app/misc/util'; 14 | import LinkFormButton from '@app/views/Share/Buttons/LinkFormButton'; 15 | 16 | import { DescriptionsItemProps } from '@douyinfe/semi-ui/lib/es/descriptions'; 17 | 18 | const LinkCard = React.memo(function LinkCard({ 19 | linkData, 20 | index, 21 | onRemove, 22 | onUpdate, 23 | onActive, 24 | }: { 25 | linkData: LinkProps 26 | index: number 27 | onRemove: (idx: number) => void 28 | onUpdate: (data: LinkProps, idx: number) => void 29 | onActive: (data: boolean, idx: number) => void 30 | }) { 31 | const [count, setCount] = useState(); 32 | const [loading, setLoading] = useState(false); 33 | const [app] = useGlobalStore(state => [state.app]); 34 | const updateCountInfo = React.useCallback( 35 | (enable: boolean = false) => { 36 | enable ? setLoading(true) : null; 37 | getRedirectVisitCount(linkData.name, getCurrentApiConfig(app)) 38 | .then(res => { 39 | setCount(res.data.count) 40 | enable ? setLoading(false) : null 41 | enable ? Notification.success({ 42 | content: 'Update success', 43 | duration: 1 44 | }) : null 45 | }) 46 | }, [] 47 | ) 48 | const delayedUpdateCountInfo = React.useMemo(() => delay(updateCountInfo, 300), [updateCountInfo]) 49 | const { Meta } = Card 50 | return ( 51 |
55 | 59 | 62 | { 63 | onActive(v, index) 64 | }}> 65 | { 68 | v?.length 69 | ? delayedUpdateCountInfo() 70 | : null 71 | } 72 | } 73 | > 74 | 79 |
80 | 81 | {linkData.name} 82 |
83 | 84 | } 86 | theme="borderless" 87 | update 88 | linkProps={linkData} 89 | index={index} 90 | onSubmitHandler={onUpdate} 91 | /> 92 | 100 | 101 | 102 | } 103 | itemKey={`${index}`} 104 | showArrow={false} 105 | > 106 | 107 | {linkData.name} 108 | {linkData.target} 109 | {linkData.mobile_target} 110 | {linkData.desc} 111 | {t('time', 112 | { 113 | val: new Date(linkData.last_modified), 114 | }) as string} 115 | {t(linkData.allow_parameters.toString()) as string} 116 | {t(linkData.active.toString()) as string} 117 | {linkData.status_code == null ? 302 : linkData.status_code} 118 | 119 |
120 | {count} 121 |
123 |
124 | 125 |
126 |
127 |
128 | 129 | 130 | } 131 | /> 132 |
133 | 134 |
135 | ) 136 | }) 137 | 138 | function WrapedDescriptionsItem({ ...props }: DescriptionsItemProps) { 139 | const { Text } = Typography; 140 | return ( 141 | 142 | 154 | {props.children as string} 155 | 156 | 157 | ) 158 | } 159 | 160 | export default LinkCard; -------------------------------------------------------------------------------- /app/src/views/DashBoard/links/LinkForm.scss: -------------------------------------------------------------------------------- 1 | div.linkcard-button-wrapper{ 2 | display: flex; 3 | justify-content: flex-end; 4 | } -------------------------------------------------------------------------------- /app/src/views/DashBoard/links/LinkForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Button, Col, Form, Input, Row } from "@douyinfe/semi-ui" 3 | import { BasicLinkProps, LinkProps } from "@app/type/link" 4 | import { BaseFormProps } from "@douyinfe/semi-ui/lib/es/form" 5 | import { t } from "i18next" 6 | import { trans_key } from "@app/i18n/resources" 7 | 8 | import "./LinkForm.scss" 9 | 10 | const statusCode = [ 11 | [301, 301], 12 | [302, 302], 13 | [307, 307], 14 | [308, 308] 15 | ] 16 | 17 | interface LinkFormProps extends BaseFormProps { 18 | linkData: LinkProps 19 | index?: number 20 | onSubmitHandler: (data: LinkProps, idx: number) => void 21 | update?: boolean 22 | } 23 | 24 | const LinkForm = React.memo(function LinkForm({ 25 | linkData, 26 | index, 27 | onSubmitHandler, 28 | update, 29 | }: LinkFormProps) { 30 | const { Input, Checkbox, Select, TextArea } = Form 31 | return ( 32 |
33 | onSubmitHandler(v as LinkProps, index as number) 34 | } 35 | labelPosition="left" labelAlign="left" labelWidth={"90px"}> 36 | 39 | 42 | 44 | 46 | 48 | 50 | 58 | 59 | 60 |
61 | 68 |
69 | 70 |
71 | 72 | 73 | ) 74 | }) 75 | 76 | export default LinkForm -------------------------------------------------------------------------------- /app/src/views/DashBoard/links/LinkManage.module.scss: -------------------------------------------------------------------------------- 1 | .root{ 2 | position: fixed; 3 | top: 0; 4 | right: 0; 5 | bottom: 0; 6 | left: 0; 7 | z-index: 10; 8 | background-color: transparent; 9 | transform: translateZ(0); 10 | pointer-events:none; 11 | padding: 0 56px; 12 | 13 | } 14 | .inner{ 15 | position: relative; 16 | width: 100%; 17 | height: 100%; 18 | } 19 | .wrapper{ 20 | position: absolute; 21 | bottom: 20px; 22 | left: 100%; 23 | transform: translate(-10px); 24 | pointer-events: auto; 25 | button{ 26 | margin: 6px auto; 27 | border-radius: var(--semi-border-radius-circle); 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/views/DashBoard/links/LinkManage.tsx: -------------------------------------------------------------------------------- 1 | import { createRedirect } from "@app/api/redirect"; 2 | import { getCurrentApiConfig, useGlobalStore } from "@app/store/app"; 3 | import { useLinkListStore } from "@app/store/links"; 4 | import { LinkProps } from "@app/type/link"; 5 | import LinkFormButton from "@app/views/Share/Buttons/LinkFormButton"; 6 | import RefreshLinksButton from "@app/views/Share/Buttons/RefreshLinksButton"; 7 | import SwitchThemeButton from "@app/views/Share/Buttons/SwitchThemeButton"; 8 | import { IconPlus } from "@douyinfe/semi-icons"; 9 | import { Button, Notification } from "@douyinfe/semi-ui"; 10 | import s0 from "./LinkManage.module.scss" 11 | 12 | function LinkManage() { 13 | const [linkList, pushLinkList] = useLinkListStore(state => [state.linkList, state.pushLinkList]) 14 | const [app] = useGlobalStore((state) => [state.app]) 15 | const onCreate = (data: LinkProps) => { 16 | if (!data.status_code) data.status_code = 302 17 | createRedirect(data, getCurrentApiConfig(app)) 18 | .then((res) => { 19 | pushLinkList(res.data) 20 | Notification.success({ 21 | content: 'Create success.' 22 | }) 23 | }) 24 | } 25 | return ( 26 |
27 |
28 |
29 | } /> 30 | 31 | 32 |
33 |
34 | 35 |
36 | ) 37 | } 38 | export default LinkManage; -------------------------------------------------------------------------------- /app/src/views/DashBoard/links/LinkPagination.module.scss: -------------------------------------------------------------------------------- 1 | .page { 2 | justify-content: center; 3 | } -------------------------------------------------------------------------------- /app/src/views/DashBoard/links/LinkPagination.tsx: -------------------------------------------------------------------------------- 1 | import { getRedirectLists, searchRedirectsByPrefix } from "@app/api/redirect"; 2 | import { getCurrentApiConfig, useGlobalStore } from "@app/store/app"; 3 | import { useLinkListStore } from "@app/store/links"; 4 | import { Card, LocaleProvider, Pagination } from "@douyinfe/semi-ui"; 5 | import { useEffect, useCallback } from "react"; 6 | 7 | import s0 from './LinkPagination.module.scss' 8 | 9 | import { useTranslation } from "react-i18next"; 10 | import { semiLocalesOptions } from "@i18n/resources"; 11 | 12 | 13 | function LinkPagination() { 14 | const [total, paginationConfig, setPaginationConfig, setLinkList, prefix] = 15 | useLinkListStore(state => [state.total, state.paginationConfig, state.setPaginationConfig, state.setLinkList, state.prefix]) 16 | const [app] = useGlobalStore(state => [state.app]) 17 | const { i18n } = useTranslation(); 18 | const fetchCurrentHandler = useCallback(prefix === '' ? getRedirectLists : searchRedirectsByPrefix, [prefix]) 19 | return ( 20 | 21 | 22 | { 25 | let skip = (page - 1) * count 26 | setPaginationConfig(count, skip) 27 | fetchCurrentHandler(getCurrentApiConfig(app), prefix, count, skip).then( 28 | (res) => { 29 | setLinkList(res.data.data) 30 | } 31 | ) 32 | }} 33 | /> 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | export default LinkPagination; -------------------------------------------------------------------------------- /app/src/views/DashBoard/links/LinksList.scss: -------------------------------------------------------------------------------- 1 | div.listcard-description.semi-collapse-item { 2 | div.listcard-dot { 3 | margin: 3px; 4 | margin-right: 12px; 5 | padding: 7px; 6 | position: relative; 7 | border-radius: 8px; 8 | overflow: hidden; 9 | 10 | background-color: #000; 11 | } 12 | 13 | div.listcard-header-content { 14 | display: flex; 15 | flex-direction: row; 16 | align-items: center; 17 | } 18 | } 19 | 20 | div.listcard-card { 21 | div.semi-card-meta-wrapper-title { 22 | display: flex; 23 | 24 | div.semi-switch { 25 | margin-top: 16.5px; 26 | } 27 | } 28 | } 29 | 30 | div.semi-card.linklist-body { 31 | overflow: unset; 32 | 33 | div.semi-card-header-wrapper { 34 | justify-content: flex-end; 35 | } 36 | 37 | div.semi-card-header-wrapper-title { 38 | max-width: 600px; 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/views/DashBoard/links/LinksList.tsx: -------------------------------------------------------------------------------- 1 | import { CardGroup, Button, Card, Input, Notification } from "@douyinfe/semi-ui" 2 | import { getCurrentApiConfig, useGlobalStore } from "@app/store/app" 3 | import { useState, useEffect } from "react" 4 | import * as React from 'react'; 5 | import { ApiConfig } from "@app/type/app" 6 | import { BasicLinkProps, LinkProps } from "@app/type/link"; 7 | import { deleteRedirect, getRedirectLists, searchRedirectsByPrefix, updateRedirect } from "@app/api/redirect" 8 | 9 | import LinkCard from "./LinkCard"; 10 | 11 | import "./LinksList.scss" 12 | import { useLinkListStore } from "@app/store/links"; 13 | import { useTranslation } from "react-i18next"; 14 | import { IconSearch, IconFilter } from "@douyinfe/semi-icons"; 15 | import { debounce } from "@app/misc/util"; 16 | 17 | async function setLinkActive(active: boolean, linkProps: LinkProps, apiConfig: ApiConfig) { 18 | let l: LinkProps = { 19 | ...linkProps, 20 | active: active 21 | } 22 | const { data } = await updateRedirect(l, apiConfig) 23 | return await data 24 | } 25 | 26 | function LinksList() { 27 | const [t] = useTranslation(); 28 | const [app] = useGlobalStore((state) => [state.app]) 29 | const [linkList, total, setLinkList, setTotal, paginationConfig, setPaginationConfig, prefix, setPrefix] = 30 | useLinkListStore(state => [state.linkList, state.total, state.setLinkList, state.setTotal, state.paginationConfig, state.setPaginationConfig, state.prefix, state.setPrefix]) 31 | useEffect(() => { 32 | getRedirectLists(getCurrentApiConfig(app), '', paginationConfig.count, paginationConfig.skip) 33 | .then((res) => { setTotal(res.data.total); setLinkList(res.data.data) }) 34 | }, []) 35 | const onActive = (active: boolean, index: number) => { 36 | let linkItem = linkList[index] 37 | setLinkActive(active, linkItem, getCurrentApiConfig(app)) 38 | .then((value) => { 39 | linkList[index] = value 40 | setLinkList([...linkList]) 41 | }) 42 | } 43 | const onUpdate = (data: LinkProps, index: number) => { 44 | updateRedirect(data, getCurrentApiConfig(app)) 45 | .then((value) => { 46 | linkList[index] = value.data 47 | setLinkList([...linkList]) 48 | Notification.success({ 49 | content: 'Update success', 50 | duration: 1 51 | }) 52 | }) 53 | } 54 | const onRemove = (index: number) => { 55 | let name = linkList[index].name 56 | deleteRedirect(name, getCurrentApiConfig(app)) 57 | .then((value) => { 58 | linkList.splice(index, 1) 59 | setLinkList([...linkList]) 60 | setTotal(total - 1); 61 | }) 62 | } 63 | 64 | const onSearch = React.useCallback((prefix: string) => { 65 | searchRedirectsByPrefix(getCurrentApiConfig(app), prefix, paginationConfig.count) 66 | .then(res => { 67 | setTotal(res.data.total) 68 | setPaginationConfig(paginationConfig.count) 69 | setLinkList(res.data.data) 70 | }) 71 | }, [paginationConfig.count, paginationConfig.skip]) 72 | const onSearchDebouce = React.useMemo(() => { 73 | return debounce(onSearch, 500) 74 | }, [onSearch]) 75 | return ( 76 | } 80 | placeholder={t('search.ByPrefix')} 81 | onChange={(v) => { setPrefix(v); onSearchDebouce(v) }} 82 | suffix={ 83 | 84 | } /> 85 | } 86 | > 87 | 88 | {linkList.map((item, idx) => ( 89 | 97 | ) 98 | )} 99 | 100 | 101 | 102 | ) 103 | } 104 | 105 | 106 | 107 | 108 | export default LinksList -------------------------------------------------------------------------------- /app/src/views/DashBoard/links/LinksPage.tsx: -------------------------------------------------------------------------------- 1 | import { getApiConfigs, getCurrentApiConfig, useGlobalStore } from "@app/store/app" 2 | 3 | import ContentHeader from "@app/views/Share/ContentHeader" 4 | import { useTranslation } from "react-i18next" 5 | import { IconArrowUp } from "@douyinfe/semi-icons" 6 | 7 | import LinksList from "./LinksList" 8 | import LinkManage from "./LinkManage" 9 | import LinkPagination from "./LinkPagination" 10 | 11 | function LinksPage() { 12 | const [t] = useTranslation(); 13 | const [app] = useGlobalStore(state => [state.app]) 14 | return ( 15 | <> 16 | 17 | 18 | 19 | 20 | 21 | ) 22 | } 23 | 24 | export default LinksPage -------------------------------------------------------------------------------- /app/src/views/DashBoard/links/StatusDot.tsx: -------------------------------------------------------------------------------- 1 | const colorMap = { 2 | active: '#67c23a', 3 | inactive: 'gray' 4 | } 5 | 6 | function StatusDot( 7 | { 8 | active 9 | }: { 10 | active: boolean 11 | } 12 | ) { 13 | return ( 14 |
17 | 18 |
) 19 | } 20 | 21 | export default StatusDot; -------------------------------------------------------------------------------- /app/src/views/Error/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ReactNode } from 'react'; 2 | 3 | type Props = { 4 | children: React.ReactNode; 5 | }; 6 | 7 | type Err = string | number | null; 8 | 9 | type State = { 10 | error?: Err; 11 | } 12 | 13 | class ErrorBoundary extends Component { 14 | state = { error: null }; 15 | static getDerivedStateFromError(error: Err) { 16 | return { error }; 17 | } 18 | render(): ReactNode { 19 | if (this.state.error) { 20 | return
{'error page.'}
21 | } else { 22 | return this.props.children; 23 | } 24 | } 25 | } 26 | export default ErrorBoundary; -------------------------------------------------------------------------------- /app/src/views/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { Spin } from "@douyinfe/semi-ui"; 2 | import { SpinSize } from "@douyinfe/semi-ui/lib/es/spin"; 3 | 4 | type Props = { 5 | size?: SpinSize; 6 | } 7 | 8 | const Loading = ({ size }: Props) => { 9 | return ( 10 | 11 | ) 12 | } 13 | 14 | export default Loading; -------------------------------------------------------------------------------- /app/src/views/Root.tsx: -------------------------------------------------------------------------------- 1 | import { lazy, Suspense, useState } from 'react' 2 | import { HashRouter, useRoutes } from 'react-router-dom' 3 | import APIConfig from './APIConfig'; 4 | import reactLogo from './assets/react.svg' 5 | import ErrorBoundary from './Error/ErrorBoundary' 6 | import Loading from './Loading'; 7 | 8 | const DashBoard = lazy(() => import("./DashBoard/DashBoard")); 9 | const Home = lazy(() => import("./DashBoard/Home")) 10 | const Links = lazy(() => import('./DashBoard/links/LinksPage')); 11 | const Configs = lazy(() => import('./DashBoard/configs/ConfigPage')); 12 | const About = lazy(() => import("./DashBoard/about/AboutPage")); 13 | 14 | function App() { 15 | return useRoutes([ 16 | { path: '/', element: }, 17 | { 18 | path: '/dashboard', element: , 19 | children: [ 20 | { path: '', element: }, 21 | { path: 'links', element: }, 22 | { path: 'configs', element: }, 23 | { path: 'about', element: } 24 | ] 25 | } 26 | ]); 27 | } 28 | 29 | const Root = () => { 30 | return ( 31 | 32 | 33 | }> 34 | 35 | 36 | 37 | 38 | ) 39 | } 40 | 41 | export default Root 42 | -------------------------------------------------------------------------------- /app/src/views/Share/Buttons/LinkFormButton.scss: -------------------------------------------------------------------------------- 1 | div.modal-form-wrapper.semi-modal-confirm { 2 | div.semi-modal-body { 3 | margin: 24px 12px; 4 | 5 | div.semi-row { 6 | margin-top: 24px; 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /app/src/views/Share/Buttons/LinkFormButton.tsx: -------------------------------------------------------------------------------- 1 | import { createRedirect } from "@app/api/redirect"; 2 | import { getCurrentApiConfig, useGlobalStore } from "@app/store/app"; 3 | import { useLinkListStore } from "@app/store/links"; 4 | import { LinkProps } from "@app/type/link"; 5 | import LinkForm from "@app/views/DashBoard/links/LinkForm"; 6 | import { Button, Modal, Notification } from "@douyinfe/semi-ui"; 7 | import { ButtonProps } from "@douyinfe/semi-ui/lib/es/button/Button"; 8 | 9 | import * as React from "react" 10 | 11 | import './LinkFormButton.scss' 12 | 13 | const defaultLinkData = { 14 | name: '', 15 | desc: '', 16 | target: '', 17 | active: true, 18 | allow_parameters: true, 19 | last_modified: '', 20 | status_code: null, 21 | mobile_target: null 22 | } 23 | 24 | interface Props extends ButtonProps { 25 | update?: boolean 26 | index?: number 27 | linkProps?: LinkProps 28 | onSubmitHandler: (data: LinkProps, idx: number) => void; 29 | } 30 | 31 | function LinkFormButton({ 32 | onSubmitHandler, 33 | update, 34 | linkProps, 35 | index, 36 | ...props 37 | }: Props) { 38 | return ( 39 |