Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Open more actions menu

Repository files navigation

bancho-web

A clean, modern web frontend for bancho.py, built with React + TypeScript. It's an SPA driven entirely by bancho.py's v2 api — no database access, no server-side rendering.

Features

  • Registration & sign in — website account creation (backed by bancho.py's v2 accounts/sessions apis) with optional captcha (reCAPTCHA, hCaptcha or Turnstile); important since bancho.py disables in-game registration by default
  • Home — server stats (online/registered players) & "how to connect" guide
  • Leaderboards — per-mode rankings (vanilla/relax/autopilot) with performance/score/accuracy/playcount sorts, country filtering, avatars, clan tags & pagination
  • Player profiles — per-mode stats with global/country ranks, level bar, grade counts, userpage, live "now playing" status, best/recent scores and most played maps with beatmap thumbnails
  • Beatmap pages — map info with cover art, difficulty stats, and per-mode leaderboards with grades, mods & FC markers
  • Clans — clan listing & clan pages with member roles
  • Player search — debounced live search in the navbar
  • Settings — avatar upload for signed-in players (requires bancho.py with PUT /v2/players/{id}/avatar)

Stack

Layer Choice
Build Vite 5
UI React 18 + TypeScript (strict)
Styling Tailwind CSS 3
Routing React Router 6
Data TanStack Query 5 (caching, retries, refetch)

Development

You'll need Node.js 18+ and a bancho.py instance to talk to.

npm install
npm run dev        # http://localhost:5173

bancho.py routes its api by Host header (api.{DOMAIN}), so the dev server proxies /api/* to your local instance with the Host header rewritten. Configure via .env (see .env.example):

BANCHO_API_TARGET=http://127.0.0.1:10000  # where bancho.py listens
BANCHO_DOMAIN=cmyui.xyz                   # builds the api.{DOMAIN} Host header

Other scripts:

npm run build            # typecheck + production build into dist/
npm run typecheck        # typecheck only
npm run preview          # serve the production build locally
npm run storybook        # component workshop at http://localhost:6006
npm run build-storybook  # static storybook build

Design system

The brand theme (near-black neutral palette + crimson accent) lives in tailwind.config.js as Tailwind color tokens (canvas, surface, surface-2/3, line, muted, accent, accent-2). Shared primitives in src/components/ui/ keep spacing & shape consistent:

  • Card — the standard surface container (default px-5 py-4 padding, or edge-to-edge for tables & media)
  • PageHeader — page title + description block
  • PillTabs — the segmented pill-tab control (mode switchers, sorts, tabs)

Run Storybook to iterate on components and the theme in isolation — the Design/Theme story documents the palette, type scale, spacing conventions and radii.

Configuration

All VITE_* variables are baked in at build time (see .env.example):

Variable Purpose Default
VITE_APP_NAME Server name shown in navbar & titles bancho.py
VITE_BANCHO_DOMAIN Domain in "how to connect" instructions cmyui.xyz
VITE_API_BASE_URL Base url for api requests /api
VITE_AVATARS_BASE_URL Base url for avatars (a.{DOMAIN}) https://a.cmyui.xyz
VITE_DISCORD_INVITE Discord invite shown in the footer (keep in sync with bancho.py's DISCORD_INVITE) empty = hidden

Deployment

Build the static bundle and serve dist/ from any web server. The SPA needs:

  1. History fallback — unknown paths must serve index.html
  2. An api origin — keep the default /api and proxy it to bancho.py (recommended): sessions ride an http-only, SameSite=Lax cookie, which Just Works when everything stays same-origin. (Splitting the api onto another origin requires CORS with Access-Control-Allow-Credentials and an explicit Access-Control-Allow-Origin — avoid unless you need it.)
# http context: rate limits for the authentication endpoints.
# registration is limited strictly to prevent automated account
# creation; logins are limited to slow down credential stuffing.
limit_req_zone $binary_remote_addr zone=bancho_registrations:10m rate=1r/m;
limit_req_zone $binary_remote_addr zone=bancho_logins:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=bancho_avatar_uploads:10m rate=10r/m;

server {
    listen 443 ssl;
    server_name osu.example.com;

    ssl_certificate     /path/to/fullchain.pem;
    ssl_certificate_key /path/to/privkey.pem;
    ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH:@SECLEVEL=1";

    # bancho.py resolves client ips from these headers (used for
    # geolocation during registration & for rate limiting fairness)
    proxy_set_header Host api.example.com;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    # ---- security headers ----
    # csp: allowlist of what pages may load/execute; limits the blast
    # radius of xss. keep exactly ONE Content-Security-Policy line —
    # the one matching your captcha provider — and replace
    # a.example.com with your avatar host. roll out with
    # Content-Security-Policy-Report-Only first if you've customized
    # the frontend.

    # no captcha:
    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://a.example.com https://assets.ppy.sh https://b.ppy.sh https://flagcdn.com; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;

    # cloudflare turnstile:
    #add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://a.example.com https://assets.ppy.sh https://b.ppy.sh https://flagcdn.com; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;

    # google recaptcha:
    #add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; frame-src https://www.google.com/recaptcha/; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://a.example.com https://assets.ppy.sh https://b.ppy.sh https://flagcdn.com; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;

    # hcaptcha:
    #add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://hcaptcha.com https://*.hcaptcha.com; frame-src https://hcaptcha.com https://*.hcaptcha.com; style-src 'self' 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com; img-src 'self' data: https://a.example.com https://assets.ppy.sh https://b.ppy.sh https://flagcdn.com; connect-src 'self' https://hcaptcha.com https://*.hcaptcha.com; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;

    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location / {
        root /srv/bancho-web/dist;
        try_files $uri /index.html;
    }

    location = /api/v2/accounts {
        limit_req zone=bancho_registrations burst=3 nodelay;
        limit_req_status 429;
        rewrite ^/api/(.*)$ /$1 break;
        proxy_pass http://127.0.0.1:10000;
    }

    location = /api/v2/sessions {
        limit_req zone=bancho_logins burst=5 nodelay;
        limit_req_status 429;
        rewrite ^/api/(.*)$ /$1 break;
        proxy_pass http://127.0.0.1:10000;
    }

    # avatar uploads are up to 2MB (nginx's default body limit is 1MB)
    location ~ ^/api/v2/players/\d+/avatar$ {
        limit_req zone=bancho_avatar_uploads burst=5 nodelay;
        limit_req_status 429;
        client_max_body_size 4m;
        rewrite ^/api/(.*)$ /$1 break;
        proxy_pass http://127.0.0.1:10000;
    }

    location /api/ {
        rewrite ^/api/(.*)$ /$1 break;
        proxy_pass http://127.0.0.1:10000;
    }
}

Project structure

src/
├── lib/
│   ├── api/          # typed api client (http envelope, types, endpoints)
│   ├── gamemodes.ts  # mode ids <-> vanilla/relax/autopilot helpers
│   ├── mods.ts       # mod bitmask -> acronyms
│   ├── grades.ts     # grade display (XH/X/SH/S/A/...)
│   ├── rankedStatus.ts
│   ├── format.ts     # numbers, accuracy, playtime, relative time
│   └── assets.ts     # avatar/cover/flag url builders
├── components/       # shared components (Navbar, ModeSwitcher, badges, ...)
└── pages/            # one component per route

To consume a new api endpoint: add its response shape to lib/api/types.ts, a fetcher to lib/api/client.ts, then use it with useQuery in a component.

External assets

  • Avatars are served by your bancho.py deployment (a.{DOMAIN}), with a built-in placeholder fallback.
  • Beatmap covers/thumbnails load from osu!'s public cdn (assets.ppy.sh / b.ppy.sh) by set id.
  • Country flags load from flagcdn.com.
Morty Proxy This is a proxified and sanitized view of the page, visit original site.