├── .env.example ├── .eslintrc.json ├── .flake8 ├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── deploy-db.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── api └── core │ └── index.py ├── core ├── builder │ ├── compress.py │ ├── config.py │ ├── cursor.py │ └── files.py └── utils │ ├── parser.py │ ├── token.py │ └── wrappers.py ├── docker-compose.yml ├── next-env.d.ts ├── next.config.js ├── package.json ├── postcss.config.js ├── prisma ├── migrations │ ├── 20231112103941_init │ │ └── migration.sql │ ├── 20240103122043_png_as_platform │ │ └── migration.sql │ ├── 20240110062335_right_hand_cursor_type_added │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public └── favicon │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ └── site.webmanifest ├── requirements.txt ├── src ├── app │ ├── (home) │ │ ├── page.tsx │ │ ├── providers.tsx │ │ ├── studio │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ └── styles.css │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ ├── config.ts │ │ ├── db │ │ │ └── download │ │ │ │ ├── count │ │ │ │ └── route.ts │ │ │ │ └── store │ │ │ │ └── route.ts │ │ └── svg │ │ │ ├── fetch │ │ │ └── route.ts │ │ │ └── route.ts │ ├── globals.css │ ├── layout.tsx │ ├── login │ │ ├── layout.tsx │ │ └── page.tsx │ └── not-found.tsx ├── components │ ├── AnimatedCount.tsx │ ├── ColorPicker │ │ ├── index.tsx │ │ ├── modal.tsx │ │ └── preview.tsx │ ├── Cursors │ │ ├── card.tsx │ │ ├── error.tsx │ │ ├── index.tsx │ │ ├── loading.tsx │ │ └── timeout.tsx │ ├── DownloadButton │ │ ├── counts.tsx │ │ ├── error.tsx │ │ ├── index.tsx │ │ ├── sponsor.tsx │ │ └── sub-buttons.tsx │ ├── Footer.tsx │ ├── HeroesElements │ │ ├── card.tsx │ │ ├── index.tsx │ │ └── sponsors.tsx │ ├── Marquee.tsx │ ├── Message.tsx │ ├── NavBar │ │ ├── index.tsx │ │ └── profile.tsx │ ├── SizePicker.tsx │ ├── Tooltip.tsx │ ├── TypePicker.tsx │ └── svgs │ │ ├── android.tsx │ │ ├── banner.tsx │ │ ├── bibata-typo.tsx │ │ ├── check.tsx │ │ ├── close.tsx │ │ ├── download.tsx │ │ ├── error.tsx │ │ ├── flip.tsx │ │ ├── github.tsx │ │ ├── index.ts │ │ ├── info.tsx │ │ ├── linux.tsx │ │ ├── linuxmint.tsx │ │ ├── lock.tsx │ │ ├── manjaro.tsx │ │ ├── modern.tsx │ │ ├── original.tsx │ │ ├── palette.tsx │ │ ├── pngs.tsx │ │ ├── processing.tsx │ │ ├── reddit.tsx │ │ ├── refresh.tsx │ │ ├── twitch.tsx │ │ ├── windows.tsx │ │ └── x.tsx ├── configs.ts ├── middleware.ts ├── services │ ├── download.ts │ ├── kv.ts │ ├── prisma.ts │ └── user.ts ├── types │ ├── bibata.d.ts │ ├── env.d.ts │ └── next-auth.d.ts ├── utils │ ├── auth │ │ └── token.ts │ ├── bug-report.ts │ ├── core.ts │ ├── fetchX.ts │ ├── figma │ │ └── fetch-svgs.ts │ ├── randomColors.ts │ └── sponsor │ │ ├── get-count.ts │ │ ├── get-sponsors.ts │ │ ├── is-sponsor.ts │ │ └── lucky-sponsor.ts └── version.ts ├── tailwind.config.js ├── tsconfig.json ├── vercel.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | # 2 | # Necessary 3 | # 4 | POSTGRES_PRISMA_URL=postgresql://:@localhost:5432/bibata 5 | POSTGRES_URL_NON_POOLING=postgresql://:@localhost:5432/bibata 6 | 7 | # Run `docker compose up` 8 | KV_URL=http://localhost:8080 9 | KV_REST_API_URL=http://localhost:8080 10 | KV_REST_API_TOKEN=supertoken 11 | 12 | GITHUB_ID= 13 | GITHUB_SECRET= 14 | 15 | FIGMA_TOKEN= 16 | FIGMA_FILE= 17 | 18 | # 19 | # Secrets 20 | # 21 | # for production secret generation run: openssl rand -base64 32 22 | NEXTAUTH_SECRET=supersecret 23 | NEXT_PUBLIC_JWT_SECRET=supersecret 24 | FLASK_SECRET=supersecret 25 | SVG_FETCH_SECRET=supersecret 26 | 27 | # 28 | # Dev Only 29 | # 30 | VERCEL_ENV=development 31 | FLASK_ENV=development 32 | NEXTAUTH_URL=http://localhost:3000/api/auth/ 33 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "globalThis": true 4 | }, 5 | "extends": ["next/core-web-vitals", "next", "prettier", "eslint:recommended"], 6 | "ignorePatterns": ["src/types/**"], 7 | "rules": { 8 | "@next/next/no-img-element": "off" 9 | }, 10 | "env": { 11 | "browser": true, 12 | "commonjs": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-ignore = E203 3 | max-line-length = 120 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ful1e5 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - README.md 7 | - LICENSE 8 | 9 | pull_request: 10 | paths-ignore: 11 | - README.md 12 | - LICENSE 13 | branches: 14 | - main 15 | 16 | jobs: 17 | ci: 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Node.js and Yarn 25 | uses: actions/setup-node@v4 26 | with: 27 | registry-url: https://registry.yarnpkg.com 28 | 29 | - name: Cache Yarn dependencies 30 | uses: actions/cache@v4 31 | with: 32 | path: | 33 | node_modules 34 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 35 | restore-keys: | 36 | ${{ runner.os }}-yarn- 37 | 38 | - name: Install Yarn dependencies 39 | run: yarn install 40 | 41 | - name: Yarn Lint 42 | run: yarn lint 43 | 44 | - uses: actions/checkout@v4 45 | - name: Set up Python 46 | uses: actions/setup-python@v5 47 | with: 48 | python-version: 3.8 49 | cache: 'pip' 50 | 51 | - name: Install pip dependencies 52 | run: python -m pip install --upgrade pip flake8 53 | 54 | - name: Flake8 55 | run: flake8 api core 56 | -------------------------------------------------------------------------------- /.github/workflows/deploy-db.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Database 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | deploy-db: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup Node.js and Yarn 16 | uses: actions/setup-node@v4 17 | with: 18 | registry-url: https://registry.yarnpkg.com 19 | 20 | - name: Cache Yarn dependencies 21 | uses: actions/cache@v4 22 | with: 23 | path: | 24 | node_modules 25 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 26 | restore-keys: | 27 | ${{ runner.os }}-yarn- 28 | 29 | - name: Install Yarn dependencies 30 | run: yarn install 31 | 32 | - name: Apply all pending migrations to the database 33 | run: npx prisma migrate deploy 34 | env: 35 | POSTGRES_PRISMA_URL: ${{ secrets.POSTGRES_PRISMA_URL }} 36 | POSTGRES_URL_NON_POOLING: ${{ secrets.POSTGRES_URL_NON_POOLING }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pki 2 | .env.local 3 | .env 4 | 5 | ## Next.js 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # node-waf configuration 29 | .lock-wscript 30 | 31 | # Compiled binary addons (http://nodejs.org/api/addons.html) 32 | build/Release 33 | 34 | # Dependency directories 35 | node_modules 36 | jspm_packages 37 | 38 | # Optional npm cache directory 39 | .npm 40 | 41 | # Optional REPL history 42 | .node_repl_history 43 | .next 44 | 45 | 46 | ## Python 47 | # Byte-compiled / optimized / DLL files 48 | __pycache__/ 49 | *.py[cod] 50 | *$py.class 51 | 52 | # C extensions 53 | *.so 54 | 55 | # Distribution / packaging 56 | .Python 57 | build/ 58 | develop-eggs/ 59 | dist/ 60 | downloads/ 61 | eggs/ 62 | .eggs/ 63 | lib/ 64 | lib64/ 65 | parts/ 66 | sdist/ 67 | var/ 68 | wheels/ 69 | share/python-wheels/ 70 | *.egg-info/ 71 | .installed.cfg 72 | *.egg 73 | MANIFEST 74 | 75 | # PyInstaller 76 | # Usually these files are written by a python script from a template 77 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 78 | *.manifest 79 | *.spec 80 | 81 | # Installer logs 82 | pip-log.txt 83 | pip-delete-this-directory.txt 84 | 85 | # Unit test / coverage reports 86 | htmlcov/ 87 | .tox/ 88 | .nox/ 89 | .coverage 90 | .coverage.* 91 | .cache 92 | nosetests.xml 93 | coverage.xml 94 | *.cover 95 | *.py,cover 96 | .hypothesis/ 97 | .pytest_cache/ 98 | cover/ 99 | 100 | # Translations 101 | *.mo 102 | *.pot 103 | 104 | # Django stuff: 105 | *.log 106 | local_settings.py 107 | db.sqlite3 108 | db.sqlite3-journal 109 | 110 | # Flask stuff: 111 | instance/ 112 | .webassets-cache 113 | 114 | # Scrapy stuff: 115 | .scrapy 116 | 117 | # Sphinx documentation 118 | docs/_build/ 119 | 120 | # PyBuilder 121 | .pybuilder/ 122 | target/ 123 | 124 | # Jupyter Notebook 125 | .ipynb_checkpoints 126 | 127 | # IPython 128 | profile_default/ 129 | ipython_config.py 130 | 131 | # pyenv 132 | # For a library or package, you might want to ignore these files since the code is 133 | # intended to run in multiple environments; otherwise, check them in: 134 | # .python-version 135 | 136 | # pipenv 137 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 138 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 139 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 140 | # install all needed dependencies. 141 | #Pipfile.lock 142 | 143 | # poetry 144 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 145 | # This is especially recommended for binary packages to ensure reproducibility, and is more 146 | # commonly ignored for libraries. 147 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 148 | #poetry.lock 149 | 150 | # pdm 151 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 152 | #pdm.lock 153 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 154 | # in version control. 155 | # https://pdm.fming.dev/#use-with-ide 156 | .pdm.toml 157 | 158 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 159 | __pypackages__/ 160 | 161 | # Celery stuff 162 | celerybeat-schedule 163 | celerybeat.pid 164 | 165 | # SageMath parsed files 166 | *.sage.py 167 | 168 | # Environments 169 | .env 170 | .venv 171 | env/ 172 | venv/ 173 | ENV/ 174 | env.bak/ 175 | venv.bak/ 176 | 177 | # Spyder project settings 178 | .spyderproject 179 | .spyproject 180 | 181 | # Rope project settings 182 | .ropeproject 183 | 184 | # mkdocs documentation 185 | /site 186 | 187 | # mypy 188 | .mypy_cache/ 189 | .dmypy.json 190 | dmypy.json 191 | 192 | # Pyre type checker 193 | .pyre/ 194 | 195 | # pytype static type analyzer 196 | .pytype/ 197 | 198 | # Cython debug symbols 199 | cython_debug/ 200 | 201 | # PyCharm 202 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 203 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 204 | # and can be added to the global gitignore or merged into this file. For a more nuclear 205 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 206 | #.idea/ 207 | .vercel 208 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none", 4 | "jsxSingleQuote": true, 5 | "bracketSpacing": true, 6 | "bracketSameLine": true 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [unreleased] 9 | 10 | ## [v1.0.2] - 01 June 2024 11 | 12 | ### Sponsors 13 | 14 | - Shoutout @AKP2401 As one-time 5$ sponsor 15 | - Shoutout @krishhandro As one-time 3$ sponsor 16 | 17 | ### What's New? 18 | 19 | - Render Cursor bitmaps(SVG->PNG) on client side without `sharp` (related to #22) 20 | - Upgraded cursor builder `clickgen v2.2.0 -> v2.2.3` to support cursors canvasing 21 | 22 | ### Changes 23 | 24 | - Reusable `Marquee` Component 25 | - Adjust heroes' card color for improved logo visibility 26 | 27 | ### Fixes 28 | 29 | - Fixes breakage on `PRO` account link downloads 30 | - Fixed width and font size of `ToolTip` component in mobile devices 31 | - Fixed Download counts bug 32 | 33 | ## [v1.0.1] - 12 January 2024 34 | 35 | ### What's New? 36 | 37 | - Right Hand Cursor Mode added 38 | - Download as PNGs Option added 39 | - Interactive Cursor size component 40 | - More polished UI 41 | - Custom `404` (not-found) page 42 | - Using `geist` fonts 43 | - Button Click Animation added 44 | - Using clickgen v2.2.0 for building Cursors 45 | 46 | ### Fixes 47 | 48 | - Slow animation in Windows Cursors 49 | 50 | ## [v1.0.0] - 30 December 2023 51 | 52 | ### What's New? 53 | 54 | - Refreshed Landing Page 55 | - Footer added 56 | - Privacy Policy added 57 | 58 | ## [v1.0.0-beta.0] - 19 December 2023 59 | 60 | ### :warning: Important Changes 61 | 62 | - Public Downloads Determined by (**monthly sponsorship in cents x 3**) 63 | 64 | ### What's New? 65 | 66 | - feature: Monochrome colored wedge animation Mode in Customize button 67 | - ui: Tint background color on `studio` page 68 | - ui: Landing page added 69 | - ui: Footer added 70 | - ui: Consistent Elements across pages 71 | 72 | ### Fixes 73 | 74 | - Fetch SVG by specifying `type` and `v`(version) parameters to request 75 | 76 | ## [v1.0.0-alpha.1] - 07 December 2023 77 | 78 | ### What's New? 79 | 80 | - Added Download counts for 'PRO' Users 81 | - Background Blur for modal and Navigation bar 82 | 83 | ### Cursors Package Update: 84 | 85 | - Sharp angle corner & resize cursors for 'Bibata Original' (related to Bibata_Cursor#146) 86 | 87 | ### Fixes 88 | 89 | - Print username in sponsor card if name is empty 90 | - Fixed Some UI bugs and Color picker modal for small devices 91 | 92 | ## [v1.0.0-alpha.0] - 23 November 2023 93 | 94 | ### What's New? 95 | 96 | - Initial public release 🎊 97 | 98 | [unreleased]: https://github.com/ful1e5/bibata/compare/v1.0.2...main 99 | [v1.0.2]: https://github.com/ful1e5/bibata/compare/v1.0.2...v1.0.1 100 | [v1.0.1]: https://github.com/ful1e5/bibata/compare/v1.0.1...v1.0.0 101 | [v1.0.0]: https://github.com/ful1e5/bibata/compare/v1.0.0...v1.0.0-beta.0 102 | [v1.0.0-beta.0]: https://github.com/ful1e5/bibata/compare/v1.0.0-alpha.1...v1.0.0-beta.0 103 | [v1.0.0-alpha.1]: https://github.com/ful1e5/bibata/compare/v1.0.0-alpha.0...v1.0.0-alpha.1 104 | [v1.0.0-alpha.0]: https://github.com/ful1e5/bibata/tree/v1.0.0-alpha.0 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2024 Abdulkaiz Khatri 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bibata 2 | 3 | [![CI](https://github.com/ful1e5/bibata/actions/workflows/ci.yml/badge.svg)](https://github.com/ful1e5/bibata/actions/workflows/ci.yml) 4 | [![Deploy Database](https://github.com/ful1e5/bibata/actions/workflows/deploy-db.yml/badge.svg)](https://github.com/ful1e5/bibata/actions/workflows/deploy-db.yml) 5 | 6 | The place where Bibata's cursor gets personalized. This project is the successor to [Bibata_Cursor](https://github.com/ful1e5/Bibata_Cursor) and provides the easiest personalization of cursors, along with other options to create your own Bibata cursor. 7 | 8 | ## Bibata? 9 | 10 | TLDR; This cursor set is a masterpiece of cursors available on the internet, hand-designed by [Abdulkaiz Khatri](https://github.com/ful1e5). 11 | 12 | Bibata is an open-source, compact, and material designed cursor set that aims to improve the cursor experience for users. It is one of the most popular cursor sets in the Linux community and is now available for free on Windows as well, with multiple color and size options. Its goal is to offer personalized cursors to users. 13 | 14 | ### What does "Bibata" word mean? 15 | 16 | The sweetest word I ever spoke was "BI-Buh," which, coincidentally, is also the word for peanuts. To make it more pronounceable and not sound like a baby's words, I added the suffix "Ta." And with that, my journey in the world of open-source began. 17 | 18 | ## Notices 19 | 20 | 22 | 23 | ![shoutout-sponsors](https://sponsor-spotlight.vercel.app/sponsor?login=ful1e5) 24 | 25 | ## Why a Download Limit? 26 | 27 | Bibata, originally an open-source project, has evolved into a web application to improve accessibility. Developing cursors involves using serverless functions, incurring costs like compute expenses, hosting fees, maintenance for the Redis database, and other service charges. To offset these costs, I depend on sponsorships through the GitHub Sponsor program. By sponsoring me on a monthly basis, you'll enjoy benefits like unlimited downloads, early access to new features, and more. 28 | 29 | Upon achieving my monthly sponsorship goal, the public download limit is lifted. 30 | 31 | ### Information on Downloads 32 | 33 | - **Pro:** Unlimited Downloads 34 | - **Fresh SignIn:** 20 Free Downloads 35 | - **Public Downloads:** Determined by (monthly sponsorship in cents x 3) 36 | 37 | ## How to Upgrade to a "Pro" Account? 38 | 39 | To enjoy the perks of a "Pro" account, begin sponsoring [ful1e5](https://github.com/sponsors/ful1e5) at a **monthly tier**. Connect this sponsored GitHub account to the app to unlock pro features and enjoy unlimited downloads. 40 | 41 | By doing so, you will not only gain access to pro features but also receive all the benefits listed on the sponsorship tier. Additionally, you have the opportunity to be featured with a shoutout in the download section and **contribute to increasing the public downloads factor**. 42 | 43 | ## TODO: 44 | 45 | - [x] Landing page 46 | - [x] Custom size component 47 | - [x] Download as PNGs. 48 | - [x] Define Tailwind rules. 49 | - [ ] Report issues with error stack when errors are generated while crafting cursor images 50 | - [ ] Admin Page 51 | - [ ] Docs 52 | - [ ] Write tests 53 | - [ ] Migrate project to AWS (Optional) 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | ## Copying 67 | 68 | This project is released under the terms of the MIT license. 69 | See [LICENCE](./LICENSE) for more information or see 70 | [opensource.org](https://opensource.org/licenses/MIT) 71 | -------------------------------------------------------------------------------- /api/core/index.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List 3 | 4 | from dotenv import load_dotenv 5 | from flask import Flask, jsonify, request, send_file, session 6 | 7 | from core.builder.compress import FileResponse, png_compress, win_compress, x11_compress 8 | from core.builder.cursor import store_cursors 9 | from core.utils.parser import parse_download_params, parse_upload_formdata 10 | from core.utils.token import decode_auth_header 11 | from core.utils.wrappers import auth_required, destroy_build_session, session_keys 12 | 13 | load_dotenv() 14 | 15 | app = Flask(__name__) 16 | logger = app.logger 17 | 18 | app.secret_key = os.getenv("FLASK_SECRET") 19 | app.config["SESSION_COOKIE_SAMESITE"] = "Lax" 20 | 21 | 22 | @app.route("/api/core/session", methods=["GET"]) 23 | def get_session(): 24 | auth = decode_auth_header(logger) 25 | 26 | if isinstance(auth, tuple): 27 | return auth[0], auth[1] 28 | else: 29 | session.setdefault(session_keys["build"], auth.id) 30 | return jsonify({"id": auth.id, "role": auth.role}) 31 | 32 | 33 | @app.route("/api/core/session", methods=["DELETE"]) 34 | def destroy_session(): 35 | k = session_keys["build"] 36 | id = session.get(k, None) 37 | 38 | if id: 39 | destroy_build_session(str(id)) 40 | 41 | return jsonify({"id": id}) 42 | 43 | 44 | @app.route("/api/core/upload", methods=["POST"]) 45 | @auth_required 46 | def upload_images(): 47 | errors: List[str] = [] 48 | 49 | k = session_keys["build"] 50 | id = str(session.get(k)) 51 | 52 | data = parse_upload_formdata(request, logger) 53 | 54 | if data.errors: 55 | errors.extend(data.errors) 56 | return jsonify({"id": id, "file": None, "error": errors}), 400 57 | 58 | name, errs = store_cursors(id, data, logger) 59 | errors.extend(errs) 60 | 61 | if errors: 62 | return jsonify({"id": id, "file": None, "error": errors}), 400 63 | else: 64 | return jsonify({"id": id, "file": name, "error": None}) 65 | 66 | 67 | @app.route("/api/core/download", methods=["GET"]) 68 | def download(): 69 | errors: List[str] = [] 70 | 71 | s = session_keys["build"] 72 | id = str(session.get(s)) 73 | 74 | param = parse_download_params(request, logger) 75 | 76 | if param.errors: 77 | errors.extend(param.errors) 78 | return jsonify({"id": id, "error": errors}), 400 79 | 80 | res: FileResponse 81 | if param.platform == "win": 82 | res = win_compress(id, param, logger) 83 | elif param.platform == "png": 84 | res = png_compress(id, param, logger) 85 | else: 86 | res = x11_compress(id, param, logger) 87 | 88 | if res.errors: 89 | errors.extend(res.errors) 90 | return jsonify({"id": id, "error": errors}), 400 91 | else: 92 | return send_file(res.file, as_attachment=True) 93 | -------------------------------------------------------------------------------- /core/builder/compress.py: -------------------------------------------------------------------------------- 1 | import tarfile 2 | from dataclasses import dataclass 3 | from logging import Logger 4 | from pathlib import Path 5 | from shutil import rmtree 6 | from typing import List 7 | from zipfile import ZipFile 8 | 9 | from clickgen.packer.windows import pack_win 10 | from clickgen.packer.x11 import pack_x11 11 | 12 | from core.builder.config import gsubtmp, gtmp 13 | from core.builder.files import attach_files 14 | from core.utils.parser import DownloadParams 15 | 16 | 17 | @dataclass 18 | class FileResponse: 19 | file: Path 20 | errors: List[str] 21 | 22 | 23 | def win_compress(id: str, param: DownloadParams, logger: Logger) -> FileResponse: 24 | errors: List[str] = [] 25 | 26 | dir = gsubtmp(id) 27 | name = f"{param.name}-{dir.stem.split('-')[1]}-v{param.version}-{param.platform}" 28 | fp = gtmp(id) / f"{name}.zip" 29 | 30 | if not fp.exists(): 31 | if len(list(dir.glob("*"))) <= 0: 32 | errors.append("Empty build directory") 33 | 34 | try: 35 | pack_win( 36 | dir / "cursors", 37 | theme_name=name, 38 | comment="Bibata Windows Cursors", 39 | website="https://github.com/ful1e5/bibata", 40 | ) 41 | 42 | attach_files(id, dir, param, logger) 43 | 44 | with ZipFile(fp, "w") as zip_file: 45 | for f in dir.rglob("*"): 46 | zip_file.write(f, str(f.relative_to(dir.parent))) 47 | 48 | rmtree(dir) 49 | except Exception as e: 50 | errors.append(str(e)) 51 | 52 | return FileResponse(file=fp, errors=errors) 53 | 54 | 55 | def png_compress(id: str, param: DownloadParams, logger: Logger) -> FileResponse: 56 | errors: List[str] = [] 57 | 58 | dir = gsubtmp(id) 59 | name = f"{param.name}-{dir.stem.split('-')[1]}-v{param.version}-{param.platform}" 60 | fp = gtmp(id) / f"{name}.zip" 61 | 62 | if not fp.exists(): 63 | if len(list(dir.glob("*"))) <= 0: 64 | errors.append("Empty build directory") 65 | 66 | try: 67 | attach_files(id, dir, param, logger) 68 | 69 | with ZipFile(fp, "w") as zip_file: 70 | for f in dir.rglob("*"): 71 | zip_file.write(f, str(f.relative_to(dir.parent))) 72 | 73 | rmtree(dir) 74 | except Exception as e: 75 | errors.append(str(e)) 76 | 77 | return FileResponse(file=fp, errors=errors) 78 | 79 | 80 | def x11_compress(id: str, param: DownloadParams, logger: Logger) -> FileResponse: 81 | errors: List[str] = [] 82 | 83 | dir = gsubtmp(id) 84 | name = f"{param.name}-{dir.stem.split('-')[1]}-v{param.version}-{param.platform}" 85 | fp = gtmp(id) / f"{name}.tar.gz" 86 | 87 | if not fp.exists(): 88 | if len(list(dir.rglob("*"))) <= 0: 89 | errors.append("Empty build directory") 90 | 91 | try: 92 | pack_x11(dir, theme_name=name, comment="Bibata XCursors") 93 | 94 | attach_files(id, dir, param, logger) 95 | 96 | with tarfile.open(fp, "w:gz") as tar: 97 | for f in dir.rglob("*"): 98 | tar.add(f, str(f.relative_to(dir.parent))) 99 | 100 | rmtree(dir) 101 | except Exception as e: 102 | errors.append(str(e)) 103 | 104 | return FileResponse(file=fp, errors=errors) 105 | -------------------------------------------------------------------------------- /core/builder/cursor.py: -------------------------------------------------------------------------------- 1 | import os 2 | from io import BytesIO 3 | from logging import Logger 4 | from typing import List 5 | 6 | from clickgen.parser import open_blob 7 | from clickgen.writer import to_win, to_x11 8 | from PIL import Image 9 | 10 | from core.builder.config import configs, gsubtmp, rconfigs 11 | from core.utils.parser import UploadFormData 12 | 13 | 14 | def store_cursors(sid: str, data: UploadFormData, logger: Logger): 15 | errors: List[str] = [] 16 | 17 | name = data.name 18 | platform = data.platform 19 | pngs = data.frames 20 | size = data.size 21 | right_mode = data.mode == "right" 22 | delay = data.delay 23 | 24 | try: 25 | if len(pngs) == 0: 26 | errors.append("Unable to convert SVG to PNG") 27 | return None, errors 28 | 29 | config = configs.get(name, None) 30 | if right_mode: 31 | config = rconfigs.get(name, config) 32 | if not config: 33 | raise ValueError(f"Unable to find Configuration for '{name}'") 34 | else: 35 | ext = "" 36 | cur = b"" 37 | cursor_name = "" 38 | 39 | if platform == "png": 40 | tmp_dir = gsubtmp(sid) / "cursors" 41 | tmp_dir.mkdir(parents=True, exist_ok=True) 42 | 43 | if len(pngs) == 1: 44 | f = tmp_dir / f"{name}.png" 45 | img = Image.open(BytesIO(pngs[0])) 46 | img.resize((size, size)).save(f) 47 | else: 48 | max_digits = len(str(len(pngs))) 49 | for i, png in enumerate(pngs): 50 | index = f"{i + 1:0{max_digits}}" 51 | f = tmp_dir / f"{name}-{index}.png" 52 | img = Image.open(BytesIO(png)) 53 | img.resize((size, size)).save(f) 54 | 55 | if platform == "win" and config.winname: 56 | blob = open_blob(pngs, (config.x, config.y), [size], 1) 57 | ext, cur = to_win(blob.frames) 58 | cursor_name = config.winname 59 | 60 | tmp_dir = gsubtmp(sid) / "cursors" 61 | tmp_dir.mkdir(parents=True, exist_ok=True) 62 | f = tmp_dir / f"{cursor_name}{ext}" 63 | f.write_bytes(cur) 64 | 65 | if platform == "x11" and config.xname: 66 | blob = open_blob(pngs, (config.x, config.y), [size], delay) 67 | cur = to_x11(blob.frames) 68 | cursor_name = config.xname 69 | 70 | tmp_dir = gsubtmp(sid) / "cursors" 71 | tmp_dir.mkdir(parents=True, exist_ok=True) 72 | 73 | xname = f"{cursor_name}{ext}" 74 | f = tmp_dir / xname 75 | f.write_bytes(cur) 76 | 77 | if config.links: 78 | oldpwd = os.getcwd() 79 | os.chdir(tmp_dir) 80 | for link in config.links: 81 | os.symlink(xname, link) 82 | os.chdir(oldpwd) 83 | 84 | except Exception as e: 85 | errors.append(str(e)) 86 | errors.append(f"Failed to build '{name}' cursor") 87 | 88 | return name, errors 89 | -------------------------------------------------------------------------------- /core/builder/files.py: -------------------------------------------------------------------------------- 1 | from logging import Logger 2 | from pathlib import Path 3 | from typing import Literal 4 | 5 | from core.utils.parser import DownloadParams 6 | 7 | README = """[::] Bibata Cursor 8 | TLDR; This cursor set is a masterpiece of cursors available on the internet, 9 | hand-designed by Abdulkaiz Khatri(https://twitter.com/ful1e5). 10 | 11 | Bibata is an open source, compact, and material designed cursor set that aims 12 | to improve the cursor experience for users. It is one of the most popular cursor sets 13 | in the Linux community and is now available for free on Windows as well, with multiple color 14 | and size options. Its goal is to offer personalized cursors to users. 15 | 16 | [::] What does "Bibata" mean? 17 | The sweetest word I ever spoke was "BI-Buh," which, coincidentally, is also the word for peanuts. 18 | To make it more pronounceable and not sound like a baby's words, I added the suffix "Ta." 19 | And with that, my journey in the world of open source began. 20 | 21 | [::] Become Sponsor 22 | https://github.com/sponsors/ful1e5 23 | 24 | [::] LICENSE 25 | MIT License 26 | 27 | [::] Bug Reports & Contact 28 | https://github.com/ful1e5/bibata/issues 29 | """ 30 | 31 | WIN = """ 32 | [::] Installation 33 | 1. Unzip '.zip' file 34 | 2. Open unziped directory in Explorer, and [Right Click] on 'install.inf'. 35 | 3. Click 'Install' from the context menu, and authorize the modifications to your system. 36 | 4. Open Control Panel > Personalization and Appearance > Change mouse pointers, 37 | and select 'Bibata Cursors'. 38 | 5. Click 'Apply'. 39 | 40 | [::] Uninstallation - (i) 41 | (i) Run the 'uninstall.bat' script packed with the '.zip' archive 42 | 43 | [::] Uninstallation - (ii) 44 | 1. Go to 'Registry Editor' by typing the same in the 'start search box'. 45 | 2. Expand 'HKEY_CURRENT_USER' folder and expand 'Control Panel' folder. 46 | 3. Go to 'Cursors' folder and click on 'Schemes' folder - all the available custom cursors that are 47 | installed will be listed here. 48 | 4. [Right Click] on the name of cursor file you want to uninstall; for eg.: 'Bibata Cursors' and 49 | click 'Delete'. 50 | 5. Click 'yes' when prompted.""" 51 | 52 | X = """ 53 | [::] Installation 54 | ```bash 55 | tar -xvf Bibata.tar.gz # extract `Bibata.tar.gz` 56 | mv Bibata-* ~/.icons/ # Install to local users 57 | sudo mv Bibata-* /usr/share/icons/ # Install to all users 58 | ``` 59 | 60 | [::] Uninstallation 61 | ```bash 62 | rm ~/.icons/Bibata-* # Remove from local users 63 | sudo rm /usr/share/icons/Bibata-* # Remove from all users 64 | ```""" 65 | 66 | WIN_README = README + WIN 67 | X_README = README + X 68 | 69 | 70 | def attach_readme(p: Path, platform: Literal["x11", "win", "png"], logger: Logger): 71 | files = {"win": WIN_README, "x11": X_README, "png": README} 72 | 73 | txt = files[platform] or None 74 | if txt: 75 | p.joinpath("README.txt").write_text(txt) 76 | 77 | 78 | def attach_license(p: Path, logger: Logger): 79 | with open("LICENSE", "r") as f: 80 | txt = f.read() 81 | p.joinpath("LICENSE").write_text(txt) 82 | 83 | 84 | def attach_version_file(p: Path, version: str, logger: Logger): 85 | p.joinpath("VERSION").write_text(version) 86 | 87 | 88 | def attach_files(id: str, p: Path, param: DownloadParams, logger: Logger): 89 | attach_readme(p, param.platform, logger) 90 | attach_license(p, logger) 91 | attach_version_file( 92 | p, 93 | f"""ID={id} 94 | Author=Abdualkaiz Khatri 95 | Type={param.name} 96 | Version={param.version}""", 97 | logger, 98 | ) 99 | -------------------------------------------------------------------------------- /core/utils/parser.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from dataclasses import dataclass 3 | from logging import Logger 4 | from typing import List, Literal 5 | 6 | from flask import Request, json 7 | 8 | 9 | @dataclass 10 | class UploadFormData: 11 | name: str 12 | frames: List[bytes] 13 | platform: str 14 | size: int 15 | delay: int 16 | mode: Literal["left", "right"] 17 | errors: List[str] 18 | 19 | 20 | def parse_upload_formdata(request: Request, logger: Logger): 21 | errors: List[str] = [] 22 | 23 | name: str = "" 24 | mode: Literal["left", "right"] = "left" 25 | size: int = 0 26 | delay: int = 0 27 | platform: str = "" 28 | frames: List[bytes] = [] 29 | 30 | try: 31 | form_data = request.form.get("data") 32 | 33 | if not form_data: 34 | raise ValueError("JSON data Not Found at 'data' key in FormData request.") 35 | else: 36 | data = json.loads(form_data) 37 | 38 | s = data.get("size", None) 39 | if not s: 40 | raise ValueError("'size' Not Found in JSON 'data' ") 41 | if type(s) is not int: 42 | raise ValueError("Invalid 'size' type. It must be type 'number'") 43 | else: 44 | size = s 45 | 46 | d = data.get("delay", None) 47 | if not d: 48 | raise ValueError("'delay' Not Found in JSON 'data' ") 49 | if type(d) is not int: 50 | raise ValueError("Invalid 'delay' type. It must be type 'number'") 51 | else: 52 | delay = d 53 | 54 | p = data.get("platform", None) 55 | if not p: 56 | raise ValueError("'platform' Not Found in JSON 'data' ") 57 | if p != "win" and p != "x11" and p != "png": 58 | raise ValueError( 59 | "Invalid 'platform' type. It must be type 'png','x11' or 'win'" 60 | ) 61 | else: 62 | platform = p 63 | 64 | m = data.get("mode", None) 65 | if m != "left" and m != "right": 66 | raise ValueError( 67 | "Invalid 'mode' type. It must be type 'left' or 'right'" 68 | ) 69 | else: 70 | mode = m 71 | 72 | n = data.get("name", None) 73 | if not n: 74 | raise ValueError("'name' Not Found in JSON 'data' ") 75 | if type(n) is not str: 76 | raise ValueError("Invalid 'name' type. It must be type 'string'") 77 | else: 78 | name = n 79 | 80 | f = data.get("frames", None) 81 | if not f: 82 | raise ValueError("'frames' Not Found in JSON 'data' ") 83 | if type(f) is not list: 84 | raise ValueError("Invalid 'frames' type. It must be type 'string[]'") 85 | else: 86 | for i, v in enumerate(f): 87 | if type(v) is not str: 88 | raise ValueError( 89 | f"Invalid 'frames[{i}]' type. It must be type 'string'" 90 | ) 91 | else: 92 | base64_str = v[len("data:image/png;base64,") :] 93 | frames.append(base64.b64decode(base64_str)) 94 | 95 | except Exception as e: 96 | errors.append(str(e)) 97 | 98 | return UploadFormData( 99 | name=name, 100 | frames=frames, 101 | size=size, 102 | delay=delay, 103 | platform=platform, 104 | mode=mode, 105 | errors=errors, 106 | ) 107 | 108 | 109 | @dataclass 110 | class DownloadParams: 111 | name: str 112 | version: str 113 | platform: Literal["win", "x11", "png"] 114 | errors: List[str] 115 | 116 | 117 | def parse_download_params(request: Request, logger: Logger): 118 | platform: Literal["win", "x11", "png"] = "x11" 119 | name: str = "" 120 | version: str = "" 121 | errors: List[str] = [] 122 | 123 | try: 124 | p = request.args.get("platform") 125 | if not p: 126 | raise ValueError("'platform' Param Not Found.") 127 | 128 | if p != "win" and p != "x11" and p != "png": 129 | raise ValueError( 130 | f"Invalid Platform '{platform}'. It should be 'png','x11' or 'win'" 131 | ) 132 | else: 133 | platform = p 134 | 135 | n = request.args.get("name") 136 | if not n: 137 | raise ValueError("'name' Param Not Found.") 138 | if type(n) is not str: 139 | raise ValueError(f"Invalid filename '{n}'. It should be type 'string'") 140 | else: 141 | name = n 142 | 143 | v = request.args.get("v") 144 | if not v: 145 | raise ValueError("'v' Param Not Found.") 146 | if type(v) is not str: 147 | raise ValueError(f"Invalid version '{v}'. It should be type 'string'") 148 | else: 149 | version = v 150 | 151 | except Exception as e: 152 | errors.append(str(e)) 153 | 154 | return DownloadParams(platform=platform, name=name, version=version, errors=errors) 155 | -------------------------------------------------------------------------------- /core/utils/token.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dataclasses import dataclass 3 | from logging import Logger 4 | from typing import Literal, Union 5 | 6 | import jwt 7 | from flask import jsonify, request 8 | 9 | SECRET = os.getenv("NEXT_PUBLIC_JWT_SECRET", "") 10 | 11 | 12 | @dataclass 13 | class AuthToken: 14 | id: str 15 | role: Literal["USER", "PRO", "ADMIN", "ANONYMOUS"] 16 | userId: Union[str, None] = None 17 | githubId: Union[str, None] = None 18 | login: Union[str, None] = None 19 | name: Union[str, None] = None 20 | url: Union[str, None] = None 21 | email: Union[str, None] = None 22 | avatarUrl: Union[str, None] = None 23 | totalDownloadCount: Union[int, None] = None 24 | 25 | 26 | def as_token(data) -> Union[None, AuthToken]: 27 | if isinstance(data, dict): 28 | id = data.get("token_id") 29 | role = data.get("role") 30 | if not role or not id: 31 | return None 32 | return AuthToken( 33 | id=id, 34 | role=role, 35 | userId=data.get("id"), 36 | githubId=data.get("userId"), 37 | login=data.get("login"), 38 | name=data.get("name"), 39 | url=data.get("url"), 40 | email=data.get("email"), 41 | avatarUrl=data.get("avatarUrl"), 42 | totalDownloadCount=data.get("totalDownloadCount"), 43 | ) 44 | else: 45 | return None 46 | 47 | 48 | def decode_token(token: str, logger: Union[Logger, None] = None): 49 | def log_error(e): 50 | logger.error(e) if logger else None 51 | 52 | try: 53 | payload = jwt.decode(token, SECRET, algorithms=["HS256"]) 54 | auth = as_token(payload) 55 | if auth: 56 | return auth 57 | else: 58 | return "invalid" 59 | except jwt.ExpiredSignatureError as e: 60 | log_error(e) 61 | return "expired" 62 | except jwt.InvalidTokenError as e: 63 | log_error(e) 64 | return "invalid" 65 | except Exception as e: 66 | log_error(e) 67 | return "invalid" 68 | 69 | 70 | def decode_auth_header(logger: Union[Logger, None] = None): 71 | def log_error(e): 72 | logger.error(e) if logger else None 73 | 74 | unauth = jsonify({"status": 401, "error": ["Unauthorized"]}) 75 | invalid = jsonify({"status": 401, "error": ["Invalid Token"]}) 76 | expired = jsonify({"status": 401, "error": ["Expired Token"]}) 77 | internal_error = jsonify({"status": 500, "error": ["Internal Authorization Error"]}) 78 | 79 | auth_header = request.headers.get("Authorization") 80 | if auth_header and auth_header.startswith("Bearer "): 81 | token = auth_header[len("Bearer ") :] 82 | try: 83 | auth = decode_token(token, logger) 84 | if auth == "expired": 85 | return expired, 401 86 | elif auth == "invalid": 87 | return invalid, 401 88 | else: 89 | return auth 90 | except Exception as e: 91 | log_error(f"Exception on parsing: {e}\n token:{token}") 92 | return internal_error, 500 93 | 94 | else: 95 | return unauth, 401 96 | -------------------------------------------------------------------------------- /core/utils/wrappers.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from shutil import rmtree 3 | 4 | from flask import g, jsonify, session 5 | 6 | from core.builder.config import gtmp 7 | from core.utils.token import decode_auth_header 8 | 9 | session_keys = {"build": "cbuid"} 10 | 11 | 12 | def auth_required(f): 13 | @wraps(f) 14 | def wrapper(*args, **kwargs): 15 | k = session_keys["build"] 16 | id: str = session.get(k, None) 17 | 18 | unauth = jsonify({"status": 401, "error": ["Unauthorized."]}) 19 | 20 | if id: 21 | auth = decode_auth_header() 22 | if isinstance(auth, tuple): 23 | return auth[0], auth[1] 24 | else: 25 | if auth.id != id: 26 | return ( 27 | jsonify({"status": 401, "error": [f"Invalid Session {id}"]}), 28 | 401, 29 | ) 30 | else: 31 | g.auth = auth 32 | return f(*args, **kwargs) 33 | else: 34 | return unauth, 401 35 | 36 | return wrapper 37 | 38 | 39 | def destroy_build_session(sid: str): 40 | try: 41 | rmtree(gtmp(sid)) 42 | except FileNotFoundError: 43 | pass 44 | finally: 45 | session.pop(session_keys["build"]) 46 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | redis: 4 | image: redis 5 | ports: 6 | - '6380:6379' 7 | serverless-redis-http: 8 | ports: 9 | - '8080:80' 10 | image: hiett/serverless-redis-http:latest 11 | environment: 12 | SRH_MODE: env 13 | SRH_TOKEN: supertoken 14 | SRH_CONNECTION_STRING: 'redis://redis' 15 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const devMode = process.env.NODE_ENV === 'development'; 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | reactStrictMode: true, 6 | rewrites: async () => { 7 | return [ 8 | { 9 | source: devMode ? '/api/core/:path*' : '/api/:path*', 10 | destination: devMode 11 | ? 'http://localhost:5328/api/core/:path*' 12 | : '/api/:path*' 13 | } 14 | ]; 15 | } 16 | }; 17 | 18 | module.exports = nextConfig; 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bibata", 3 | "description": "CLI for converting cursor svg files to png.", 4 | "author": "Abdulkaiz Khatri ", 5 | "version": "1.0.2", 6 | "private": true, 7 | "bugs": { 8 | "url": "https://github.com/ful1e5/bibata/issues", 9 | "email": "kaizmandhu@gmail.com" 10 | }, 11 | "license": { 12 | "type": "MIT", 13 | "url": "https://opensource.org/license/mit/" 14 | }, 15 | "repository": "ful1e5/bibata", 16 | "fundinig": "https://github.com/ful1e5/bibata?sponsor=1", 17 | "scripts": { 18 | "flask-dev": "pip3 install --upgrade pip && pip3 install -r requirements.txt && python3 -m flask --debug --app api/core/index run -p 5328", 19 | "next-dev": "next dev", 20 | "dev": "concurrently \"yarn next-dev\" \"yarn flask-dev\"", 21 | "postinstall": "prisma generate", 22 | "prebuild": "node -p \"'export const LIB_VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts", 23 | "build": "next build", 24 | "start": "next start", 25 | "lint": "next lint" 26 | }, 27 | "dependencies": { 28 | "@prisma/client": "^5.5.2", 29 | "@uiw/react-color-wheel": "^1.4.2", 30 | "@vercel/kv": "^1.0.0", 31 | "concurrently": "^8.0.1", 32 | "figma-api": "^1.11.0", 33 | "geist": "^1.2.0", 34 | "jsonwebtoken": "^8.5.1", 35 | "next": "^14.0.0", 36 | "next-auth": "^4.24.3", 37 | "react": "^18.2.0", 38 | "react-dom": "^18.2.0", 39 | "swr": "^2.2.4", 40 | "tinycolor2": "^1.6.0", 41 | "uuid": "^9.0.1" 42 | }, 43 | "devDependencies": { 44 | "@next/eslint-plugin-next": "^14.0.1", 45 | "@types/jsonwebtoken": "^8.5.9", 46 | "@types/node": "^20.7.1", 47 | "@types/react": "^18.2.23", 48 | "@types/react-dom": "^18.2.8", 49 | "@types/tinycolor2": "^1.4.6", 50 | "@types/uuid": "^9.0.6", 51 | "autoprefixer": "^10.4.16", 52 | "eslint": "8.40.0", 53 | "eslint-config-next": "^14.0.0", 54 | "eslint-config-prettier": "^9.0.0", 55 | "postcss": "^8.4.31", 56 | "prisma": "^5.5.2", 57 | "tailwindcss": "^3.3.3", 58 | "typescript": "^5.2.2" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prisma/migrations/20231112103941_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "Role" AS ENUM ('USER', 'PRO', 'ADMIN'); 3 | 4 | -- CreateEnum 5 | CREATE TYPE "Platform" AS ENUM ('win', 'x11'); 6 | 7 | -- CreateEnum 8 | CREATE TYPE "Type" AS ENUM ('Modern', 'Original'); 9 | 10 | -- CreateTable 11 | CREATE TABLE "User" ( 12 | "id" TEXT NOT NULL, 13 | "userId" TEXT NOT NULL, 14 | "login" TEXT NOT NULL, 15 | "name" TEXT, 16 | "email" TEXT, 17 | "url" TEXT NOT NULL, 18 | "avatarUrl" TEXT NOT NULL, 19 | "totalDownloadCount" INTEGER, 20 | "index" SERIAL NOT NULL, 21 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 22 | "updatedAt" TIMESTAMP(3) NOT NULL, 23 | "role" "Role" NOT NULL DEFAULT 'USER', 24 | 25 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 26 | ); 27 | 28 | -- CreateTable 29 | CREATE TABLE "Download" ( 30 | "id" TEXT NOT NULL, 31 | "baseColor" TEXT NOT NULL, 32 | "outlineColor" TEXT NOT NULL, 33 | "watchBGColor" TEXT NOT NULL, 34 | "platform" "Platform" NOT NULL, 35 | "type" "Type" NOT NULL, 36 | "index" INTEGER NOT NULL, 37 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 38 | "updatedAt" TIMESTAMP(3) NOT NULL, 39 | "userId" TEXT, 40 | 41 | CONSTRAINT "Download_pkey" PRIMARY KEY ("id") 42 | ); 43 | 44 | -- CreateIndex 45 | CREATE UNIQUE INDEX "User_userId_key" ON "User"("userId"); 46 | 47 | -- CreateIndex 48 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 49 | 50 | -- CreateIndex 51 | CREATE UNIQUE INDEX "User_index_key" ON "User"("index"); 52 | 53 | -- AddForeignKey 54 | ALTER TABLE "Download" ADD CONSTRAINT "Download_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 55 | -------------------------------------------------------------------------------- /prisma/migrations/20240103122043_png_as_platform/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "Platform" ADD VALUE 'png'; 3 | 4 | -- AlterEnum 5 | ALTER TYPE "Role" ADD VALUE 'ANONYMOUS'; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20240110062335_right_hand_cursor_type_added/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | -- This migration adds more than one value to an enum. 3 | -- With PostgreSQL versions 11 and earlier, this is not possible 4 | -- in a single migration. This can be worked around by creating 5 | -- multiple migrations, each migration adding only one value to 6 | -- the enum. 7 | 8 | 9 | ALTER TYPE "Type" ADD VALUE 'ModernRight'; 10 | ALTER TYPE "Type" ADD VALUE 'OriginalRight'; 11 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | binaryTargets = ["native", "rhel-openssl-1.0.x", "debian-openssl-1.1.x"] 7 | } 8 | 9 | datasource db { 10 | provider = "postgresql" 11 | url = env("POSTGRES_PRISMA_URL") // uses connection pooling 12 | directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection 13 | } 14 | 15 | enum Role { 16 | USER 17 | PRO 18 | ADMIN 19 | ANONYMOUS 20 | } 21 | 22 | enum Platform { 23 | win 24 | x11 25 | png 26 | } 27 | 28 | enum Type { 29 | Modern 30 | ModernRight 31 | Original 32 | OriginalRight 33 | } 34 | 35 | model User { 36 | id String @id @default(cuid()) 37 | userId String @unique 38 | login String 39 | name String? 40 | email String? @unique 41 | url String 42 | avatarUrl String 43 | totalDownloadCount Int? 44 | 45 | index Int @unique @default(autoincrement()) 46 | createdAt DateTime @default(now()) 47 | updatedAt DateTime @updatedAt 48 | 49 | role Role @default(USER) 50 | downloads Download[] 51 | } 52 | 53 | model Download { 54 | id String @id @default(cuid()) 55 | 56 | baseColor String 57 | outlineColor String 58 | watchBGColor String 59 | 60 | platform Platform 61 | type Type 62 | 63 | index Int 64 | createdAt DateTime @default(now()) 65 | updatedAt DateTime @updatedAt 66 | 67 | user User? @relation(fields: [userId], references: [id], onDelete: Cascade) 68 | userId String? 69 | } 70 | -------------------------------------------------------------------------------- /public/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/bibata/9ab69b7e56b63e28cdcda08e0d4b96d483c32e37/public/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/bibata/9ab69b7e56b63e28cdcda08e0d4b96d483c32e37/public/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/bibata/9ab69b7e56b63e28cdcda08e0d4b96d483c32e37/public/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #000000 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/bibata/9ab69b7e56b63e28cdcda08e0d4b96d483c32e37/public/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/bibata/9ab69b7e56b63e28cdcda08e0d4b96d483c32e37/public/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/bibata/9ab69b7e56b63e28cdcda08e0d4b96d483c32e37/public/favicon/favicon.ico -------------------------------------------------------------------------------- /public/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ful1e5/bibata/9ab69b7e56b63e28cdcda08e0d4b96d483c32e37/public/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /public/favicon/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 17 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /public/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Bibata", 3 | "short_name": "Bibata", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png?v=2", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png?v=2", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#000000", 17 | "background_color": "#000000", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==3.0.0 2 | python-dotenv===1.0.0 3 | PyJWT===2.8.0 4 | clickgen===2.2.3 5 | -------------------------------------------------------------------------------- /src/app/(home)/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | 5 | import { SessionProvider } from 'next-auth/react'; 6 | 7 | export function Providers({ children }: { children: React.ReactNode }) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/(home)/studio/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Metadata } from 'next'; 4 | 5 | export const metadata: Metadata = { 6 | title: 'Bibata Studio', 7 | description: 8 | 'Bibata Studio. The easiest and fastest way to download and personalize color and animation in the #1 internet-ranked Bibata Cursor. Built with open-source technologies and freely accessible.' 9 | }; 10 | 11 | interface Props { 12 | children: React.ReactNode; 13 | } 14 | 15 | export default function RootLayout({ children }: Props) { 16 | return
{children}
; 17 | } 18 | -------------------------------------------------------------------------------- /src/app/(home)/studio/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useState } from 'react'; 4 | import { useSession } from 'next-auth/react'; 5 | import tinycolor from 'tinycolor2'; 6 | 7 | import { TYPES, COLORS, SIZES } from '@root/configs'; 8 | import { LIB_VERSION } from '@root/version'; 9 | 10 | import { TypePicker } from '@components/TypePicker'; 11 | import { SizePicker } from '@components/SizePicker'; 12 | import { ColorPicker } from '@components/ColorPicker'; 13 | import { DownloadButton } from '@components/DownloadButton'; 14 | import { Cursors } from '@components/Cursors'; 15 | 16 | import { genAccessToken } from '@utils/auth/token'; 17 | 18 | import { Image } from 'bibata/app'; 19 | 20 | export default function StudioPage() { 21 | const [type, setType] = useState(TYPES[0]); 22 | const [cursorMode, setCursorMode] = useState<'left' | 'right'>('left'); 23 | const [cursorSize, setCursorSize] = useState(SIZES[3]); 24 | 25 | const [colorName, setColorName] = useState('Amber'); 26 | const [color, setColor] = useState(COLORS[colorName]); 27 | const bg = tinycolor('#141414').toHexString(); 28 | const tint1 = tinycolor.mix(bg, color.base, 2).toHexString(); 29 | const tint2 = tinycolor.mix(bg, color.base, 3).toHexString(); 30 | 31 | // TODO: access version with page parameter `v` 32 | // example: bibata/studio?v=1.0.0-alpha 33 | // eslint-disable-next-line no-unused-vars 34 | const [version, setVersion] = useState(LIB_VERSION); 35 | 36 | const [images, setImages] = useState([]); 37 | const [imagesCount, setImagesCount] = useState(0); 38 | 39 | const { data: session, status, update } = useSession(); 40 | const [token, setToken] = useState(genAccessToken()); 41 | 42 | const resetImages = () => { 43 | setImages([]); 44 | setImagesCount(0); 45 | }; 46 | 47 | const refreshToken = () => { 48 | if (session?.user) { 49 | setToken(genAccessToken(session.user)); 50 | } else { 51 | setToken(genAccessToken()); 52 | } 53 | }; 54 | 55 | useEffect(() => { 56 | if (status !== 'loading') { 57 | refreshToken(); 58 | } 59 | }, [status, update]); // eslint-disable-line react-hooks/exhaustive-deps 60 | 61 | return ( 62 |
66 |
67 |
68 | !a.match('Right'))} 70 | value={type} 71 | onChange={(t, rhm) => { 72 | if (t !== type) { 73 | resetImages(); 74 | setType(t); 75 | setCursorMode(rhm ? 'right' : 'left'); 76 | refreshToken(); 77 | } 78 | }} 79 | /> 80 |
81 | 82 |
83 | { 86 | if (c !== color) { 87 | resetImages(); 88 | setColorName(n); 89 | setColor(c); 90 | refreshToken(); 91 | } 92 | }} 93 | /> 94 |
95 | 96 |
97 | { 101 | if (s !== cursorSize) { 102 | setCursorSize(s); 103 | refreshToken(); 104 | } 105 | }} 106 | /> 107 |
108 | 109 |
110 | 123 |
124 | 125 | setImagesCount(svgs.length)} 130 | onLoad={(i, loading) => { 131 | const l = images; 132 | const index = l.findIndex((e) => e.name === i.name); 133 | if (index >= 0) { 134 | loading ? l.splice(index, 1) : (l[index] = i); 135 | } else if (!loading) { 136 | l.push(i); 137 | } 138 | setImages([...l]); 139 | }} 140 | /> 141 |
142 |
143 | ); 144 | } 145 | -------------------------------------------------------------------------------- /src/app/(home)/styles.css: -------------------------------------------------------------------------------- 1 | .scale-animation { 2 | @apply transition hover:scale-110 ease-in-out duration-150; 3 | } 4 | .main-heading-0 { 5 | @apply text-[40px] sm:text-[96px] text-center font-black tracking-tighter leading-none uppercase text-yellow-100; 6 | } 7 | .main-heading-1 { 8 | @apply text-xl sm:text-5xl text-center font-bold 9 | bg-clip-text text-transparent 10 | bg-[linear-gradient(to_right,theme(colors.yellow.200),theme(colors.orange.100),theme(colors.orange.100),theme(colors.orange.200),theme(colors.yellow.200))] 11 | bg-[length:200%_auto] animate-gradient; 12 | } 13 | .section-heading { 14 | @apply text-3xl sm:text-5xl text-center font-bold; 15 | } 16 | .section-subheading { 17 | @apply text-lg text-center font-light text-white/[.7]; 18 | } 19 | .heading-button { 20 | @apply flex gap-2 px-5 sm:px-12 py-3 sm:py-5 rounded-full uppercase transition active:scale-95; 21 | } 22 | 23 | .selected-button { 24 | @apply bg-white font-black text-black 25 | bg-[linear-gradient(to_right,theme(colors.yellow.200),theme(colors.orange.100),theme(colors.orange.200),theme(colors.yellow.200))] 26 | hover:bg-[linear-gradient(to_right,theme(colors.green.300),theme(colors.teal.300),theme(colors.blue.300),theme(colors.green.300))] 27 | bg-[length:200%_auto] animate-gradient; 28 | } 29 | .outlined-button { 30 | @apply text-white/[.6] hover:text-green-200 ring-1 ring-white/[.6] hover:ring-green-200 font-bold; 31 | } 32 | 33 | /* Statistics */ 34 | .count-card { 35 | @apply rounded-3xl bg-white/[.1] flex flex-col text-center text-white border-2 border-black/[.4]; 36 | } 37 | .count-icon { 38 | @apply w-16 md:w-16 p-1; 39 | } 40 | .award-icon { 41 | @apply fill-white; 42 | } 43 | .download-icon { 44 | @apply fill-blue-300; 45 | } 46 | .star-icon { 47 | @apply fill-yellow-200; 48 | } 49 | .pen-icon { 50 | @apply fill-green-300; 51 | } 52 | .count-heading { 53 | @apply flex flex-col justify-center items-center gap-2 rounded-3xl text-3xl font-black px-5 pt-5; 54 | } 55 | .count-subtext { 56 | @apply text-[8px] sm:text-[10px] lg:text-[12px] text-white/[.4] tracking-tighter mt-1 mb-3; 57 | } 58 | 59 | /* platform */ 60 | .platform-card { 61 | @apply rounded-3xl p-10 flex flex-col text-center; 62 | } 63 | .platform-icon { 64 | @apply w-full h-72 sm:h-56 flex items-center justify-center rounded-3xl text-black/[.8] bg-[--accent]; 65 | } 66 | .platform-heading { 67 | @apply text-xl sm:text-3xl font-bold mt-5 ml-2; 68 | } 69 | .platform-line { 70 | @apply mt-3 ml-2 text-xs md:text-lg text-white/[.6]; 71 | } 72 | 73 | /* Heroes Line*/ 74 | .heroes-line { 75 | @apply text-center text-lg font-semibold bg-clip-text text-transparent 76 | bg-[linear-gradient(to_right,theme(colors.red.200),theme(colors.orange.100),theme(colors.fuchsia.400),theme(colors.blue.200),theme(colors.red.200))] 77 | bg-[length:200%_auto] animate-gradient; 78 | } 79 | 80 | /* Open Source & Libraries */ 81 | .library-card { 82 | @apply rounded-3xl p-6 flex flex-col transform hover:scale-105 ease-in-out duration-150 hover:bg-[#8aa8f2]/[.2] transition active:scale-95; 83 | } 84 | .library-card-heading { 85 | @apply md:text-2xl font-bold; 86 | } 87 | .library-card-text { 88 | @apply text-xs md:text-lg mt-3 text-white/[.6]; 89 | } 90 | .library-card-lang { 91 | @apply mt-12 p-3 inline-flex items-center gap-2 rounded-full; 92 | } 93 | .circle { 94 | @apply w-3 h-3 rounded-full; 95 | } 96 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { AuthOptions } from 'next-auth'; 2 | import GithubProvider from 'next-auth/providers/github'; 3 | 4 | import { upsertUser } from '@services/user'; 5 | import { isSponsor } from '@utils/sponsor/is-sponsor'; 6 | 7 | import { DB_SEEDS } from '@root/configs'; 8 | import { Role } from '@prisma/client'; 9 | 10 | const authOptions: AuthOptions = { 11 | providers: [ 12 | GithubProvider({ 13 | clientId: process.env.GITHUB_ID, 14 | clientSecret: process.env.GITHUB_SECRET 15 | }) 16 | ], 17 | secret: process.env.NEXTAUTH_SECRET, 18 | pages: { 19 | // TODO: Handle Custom Login Page queries 20 | // signIn: '/login' 21 | }, 22 | callbacks: { 23 | async jwt({ token, profile, trigger }) { 24 | if (trigger === 'signIn' && profile) { 25 | const userId = profile.id.toString(); 26 | const login = profile.login; 27 | const role: Role = (await isSponsor(login)) ? 'PRO' : 'USER'; 28 | const user = { 29 | userId: userId, 30 | login: login, 31 | name: profile.name || null, 32 | url: profile.html_url, 33 | email: profile.email || null, 34 | avatarUrl: profile.avatar_url, 35 | role: role, 36 | totalDownloadCount: 37 | role === 'USER' ? DB_SEEDS.FRESH_SIGNUP_DOWNLOADS : null 38 | }; 39 | 40 | token.user = await upsertUser(user); 41 | } 42 | 43 | return token; 44 | }, 45 | 46 | async session({ session, token }) { 47 | return Promise.resolve({ ...session, user: { ...token.user } }); 48 | } 49 | } 50 | }; 51 | 52 | const handler = NextAuth(authOptions); 53 | 54 | export { handler as GET, handler as POST }; 55 | -------------------------------------------------------------------------------- /src/app/api/config.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | export const RESPONSES = { 4 | // Core API related common Responses 5 | unauth: NextResponse.json( 6 | { status: 401, error: 'Unauthorized' }, 7 | { status: 401 } 8 | ), 9 | invalid_auth_token: NextResponse.json( 10 | { status: 401, error: 'Invalid Token' }, 11 | { status: 401 } 12 | ), 13 | invalid_request: NextResponse.json( 14 | { status: 400, error: 'Invalid Request' }, 15 | { status: 400 } 16 | ), 17 | invalid_method: NextResponse.json( 18 | { status: 405, error: 'Method not allowed' }, 19 | { status: 405 } 20 | ), 21 | 22 | // App API related common Responses 23 | image_not_found: NextResponse.json( 24 | { error: 'Image not found' }, 25 | { status: 404 } 26 | ), 27 | internal_error: (e: any) => 28 | NextResponse.json({ error: 'Runtime Error', stack: e }, { status: 500 }) 29 | }; 30 | -------------------------------------------------------------------------------- /src/app/api/db/download/count/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | 3 | import { getUserTotalDownloads } from '@services/user'; 4 | 5 | import { getIndex } from '@services/download'; 6 | import { decodeAuthToken } from '@utils/auth/token'; 7 | 8 | import { RESPONSES as res } from '@api/config'; 9 | import { DB_SEEDS, SPONSOR_API_ENDPOINT } from '@root/configs'; 10 | 11 | import { Goals, JWTToken } from 'bibata/misc'; 12 | 13 | export async function GET(request: NextRequest) { 14 | if (request.method === 'GET') { 15 | let auth: JWTToken | undefined; 16 | 17 | const token = request.headers.get('Authorization')?.replace('Bearer ', ''); 18 | 19 | try { 20 | const sponsor_data = (await fetch(`${SPONSOR_API_ENDPOINT}?goals=true`) 21 | .then((r) => r.json()) 22 | .then((json) => json.goals)) as Goals; 23 | 24 | const sponsorCount = NextResponse.json({ 25 | total: DB_SEEDS.DOWNLOADS_PER_CENTS( 26 | sponsor_data.monthlySponsorshipInCents 27 | ), 28 | count: await getIndex(null), 29 | role: 'ANONYMOUS' 30 | }); 31 | 32 | if (!token) return sponsorCount; 33 | 34 | try { 35 | auth = decodeAuthToken(token); 36 | if (!auth?.id) return sponsorCount; 37 | 38 | const roles = ['USER', 'PRO', 'ADMIN']; 39 | 40 | if (roles.includes(auth.role)) { 41 | let total = await getUserTotalDownloads(auth.id); 42 | 43 | if (total === undefined) return sponsorCount; 44 | 45 | return NextResponse.json({ 46 | total, 47 | count: await getIndex(auth.id), 48 | role: auth.role 49 | }); 50 | } else { 51 | return sponsorCount; 52 | } 53 | } catch { 54 | return sponsorCount; 55 | } 56 | } catch (e) { 57 | console.error(e); 58 | return NextResponse.json({ total: 0, count: 0, error: e }); 59 | } 60 | } else { 61 | return res.invalid_method; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/app/api/db/download/store/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | 3 | import { AddDownloadData, addDownload } from '@services/download'; 4 | import { decodeAuthToken } from '@utils/auth/token'; 5 | 6 | import { JWTToken } from 'bibata/misc'; 7 | 8 | import { RESPONSES as res } from '@api/config'; 9 | 10 | export async function POST(request: NextRequest) { 11 | if (request.method === 'POST') { 12 | let auth: JWTToken | undefined; 13 | 14 | const token = request.headers.get('Authorization')?.replace('Bearer ', ''); 15 | 16 | if (!token) return res.unauth; 17 | 18 | try { 19 | auth = decodeAuthToken(token); 20 | } catch { 21 | return res.invalid_auth_token; 22 | } 23 | 24 | if (!auth) return res.invalid_auth_token; 25 | 26 | try { 27 | const data = (await request.json()) as AddDownloadData['data']; 28 | await addDownload({ id: auth.id, data }); 29 | return NextResponse.json({ success: true }); 30 | } catch (error) { 31 | console.error(error); 32 | return res.invalid_request; 33 | } 34 | } else { 35 | return res.invalid_method; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/api/svg/fetch/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | 3 | import { ImageRedis } from '@services/kv'; 4 | 5 | import { FetchSVG } from '@utils/figma/fetch-svgs'; 6 | 7 | import { TYPES } from '@root/configs'; 8 | 9 | import { ApiError } from 'figma-api/lib/utils'; 10 | 11 | export async function GET(request: NextRequest) { 12 | if ( 13 | request.headers.get('Authorization') !== 14 | `Bearer ${process.env.SVG_FETCH_SECRET}` 15 | ) { 16 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 17 | } 18 | 19 | const version = request.nextUrl.searchParams.get('v'); 20 | const response = await update(version); 21 | 22 | return NextResponse.json( 23 | { fetchAt: Date.now(), ...response }, 24 | { status: 200 } 25 | ); 26 | } 27 | 28 | const update = async (version: string | null) => { 29 | const fetcher = new FetchSVG(); 30 | 31 | try { 32 | const redis = new ImageRedis(); 33 | const file = await fetcher.getFile(); 34 | 35 | for (const type of TYPES) { 36 | const svgs = await fetcher.fetchSVGs({ file, type, version }); 37 | if (!svgs) { 38 | return { 39 | error: 'Something went wrong. SVGs not found', 40 | type, 41 | version 42 | }; 43 | } 44 | 45 | const key = `${type}:${version}`; 46 | await redis.del(key); 47 | await redis.saveSVGs(key, svgs); 48 | } 49 | 50 | return; 51 | } catch (e) { 52 | if (e instanceof Error) { 53 | return { error: e.message, stack: e, status: 504 }; 54 | } 55 | 56 | if (e instanceof ApiError) { 57 | if (e.response.data) { 58 | const res = e.response.data; 59 | return { 60 | error: `[Figma API] ${res.err}`, 61 | status: res.status || 400 62 | }; 63 | } 64 | } 65 | 66 | return { 67 | error: 'Internal Server Error', 68 | stack: JSON.stringify(e), 69 | status: 504 70 | }; 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /src/app/api/svg/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | 3 | import { ImageRedis } from '@services/kv'; 4 | 5 | import { TYPES, VERSIONS } from '@root/configs'; 6 | import { RESPONSES as res } from '@api/config'; 7 | 8 | import { SVG } from 'bibata/app'; 9 | 10 | export async function GET(request: NextRequest) { 11 | if (request.method === 'GET') { 12 | const type = request.nextUrl.searchParams.get('type'); 13 | const version = request.nextUrl.searchParams.get('v'); 14 | 15 | if (!type) { 16 | return NextResponse.json( 17 | { error: "Invalid Request. The 'type' parameter is missing" }, 18 | { status: 400 } 19 | ); 20 | } 21 | 22 | if (!version || !VERSIONS.includes(version)) { 23 | return NextResponse.json( 24 | { 25 | error: `Sorry, unable to retrieve the v${version} Cursor Bitmaps. Please try again later.` 26 | }, 27 | { status: 400 } 28 | ); 29 | } 30 | 31 | if (TYPES.includes(type)) { 32 | try { 33 | const redis = new ImageRedis(); 34 | const key = `${type}:${version}`; 35 | const data = (await redis.get(key)) as SVG[] | null; 36 | 37 | const error = 38 | !data || data.length === 0 39 | ? `Oops! It looks like there's a little hiccup fetching the ${type} v${version} SVG nodes right now.` 40 | : null; 41 | 42 | return NextResponse.json( 43 | { error, data }, 44 | { status: error ? 404 : 200 } 45 | ); 46 | } catch (e) { 47 | return res.internal_error(e); 48 | } 49 | } else { 50 | return NextResponse.json( 51 | { error: `No cursor bitmaps found for '${type}'` }, 52 | { status: 404 } 53 | ); 54 | } 55 | } else { 56 | return res.invalid_method; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --bg-dark: #141414; 7 | --bg-light: #ffffff; 8 | --accent: #d2defc; 9 | --accent-active: #8aa8f2; 10 | 11 | --gradient-light: linear-gradient( 12 | to_right, 13 | theme(colors.fuchsia.100), 14 | theme(colors.green.100), 15 | theme(colors.orange.100), 16 | theme(colors.red.100), 17 | theme(colors.blue.100), 18 | theme(colors.fuchsia.100) 19 | ); 20 | 21 | --gradient-strong: linear-gradient( 22 | to_right, 23 | theme(colors.fuchsia.300), 24 | theme(colors.green.300), 25 | theme(colors.orange.300), 26 | theme(colors.red.300), 27 | theme(colors.blue.300), 28 | theme(colors.fuchsia.300) 29 | ); 30 | 31 | --text-dark: #ffffff; 32 | --text-light: #000000; 33 | } 34 | 35 | /** Remove Tap highlight from buttons **/ 36 | 37 | * { 38 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 39 | } 40 | 41 | .dark { 42 | background-color: var(--bg-dark); 43 | color: var(--text-dark); 44 | } 45 | 46 | .light { 47 | background-color: var(--bg-light); 48 | color: var(--text-light); 49 | } 50 | 51 | .clip-bottom { 52 | clip-path: polygon(50% 0, 100% 100%, 0 100%); 53 | } 54 | 55 | .flex-center { 56 | @apply flex items-center justify-center; 57 | } 58 | 59 | .remove-arrow::-webkit-inner-spin-button, 60 | .remove-arrow::-webkit-outer-spin-button { 61 | -webkit-appearance: none; 62 | margin: 0; 63 | } 64 | 65 | .remove-arrow { 66 | -moz-appearance: textfield; 67 | } 68 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import '@app/globals.css'; 2 | 3 | import { GeistSans } from 'geist/font/sans'; 4 | import { GeistMono } from 'geist/font/mono'; 5 | 6 | import React from 'react'; 7 | import { Metadata } from 'next'; 8 | 9 | import { Providers } from '@app/(home)/providers'; 10 | import { NavBar } from '@components/NavBar'; 11 | import { Footer } from '@components/Footer'; 12 | 13 | export const metadata: Metadata = { 14 | title: 'Bibata', 15 | description: "The place where Bibata's cursor gets personalized.", 16 | icons: { 17 | icon: ['favicon/favicon.ico?v=2'], 18 | apple: ['favicon/apple-touch-icon.png?v=2'], 19 | shortcut: ['favicon/apple-touch-icon.png?v=2'] 20 | }, 21 | manifest: '/favicon/site.webmanifest' 22 | }; 23 | 24 | type Props = { 25 | children: React.ReactNode; 26 | }; 27 | 28 | export default function RootLayout({ children }: Props) { 29 | return ( 30 | 31 | 32 | 33 | 34 | {children} 35 |