Skip to content

About

notsobot.com's frontend

Resources

Stars

6 stars

Watchers

1 watching

Forks

Repository files navigation

notsobot.com-web

The notsobot.com website. It was rewritten from the earlier webpack/SCSS version of this repo (see git history) onto a newer stack, with Discord OAuth login and a user dashboard.

Before the rewrite Now
Build webpack 5, babel, ts-loader, node-sass Vite 6
UI React 18, SCSS modules, MUI icons React 19, Tailwind CSS 4, lucide icons
Routing react-router-dom 6 (BrowserRouter) React Router 7 data router
Data react-query 3, zustand TanStack Query 5
Auth token in localStorage, half-finished AuthProvider, useAuth(), RequireAuth, typed API client

Getting started

Requires Node 20.19+.

npm install
cp .env.example .env.local   # optional
npm run dev                  # http://localhost:5173

npm run build type-checks the code and builds both halves with @cloudflare/vite-plugin: the app's static files go to dist/client/ and the Cloudflare Worker below to dist/notsobot_com_web/, so the worker can import app code like any other module. /api stays on the notsobot.api origin.

npm run dev runs that same worker (in workerd, reloading on change) in front of the app, so pages get their metatags locally too. It reads .dev.vars like the deploy does, and only /api and Vite's own module paths skip it (vite.config.ts). npm run preview does the same with the production build.

Deploying

The site runs on its own Cloudflare Worker (notsobot-com-web) with Workers Static Assets. The config is in wrangler.jsonc and the code in worker/index.ts, which routes requests with cf-worker-router like nsb.gg-web: notso.bot redirects, /api passes through, /robots.txt and /sitemap.xml are generated, and every other path is a page (renderPage).

npx wrangler login       # once (on a server without a browser, put CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID in .env instead, see .env.example)
npm run deploy           # notsobot.com + www.notsobot.com (+ the notso.bot redirect)
npm run deploy:beta      # beta.notsobot.com
npm run preview:worker   # run the production build + worker locally with wrangler, as notsobot.com

The build writes the wrangler config the deploy uses (dist/notsobot_com_web/wrangler.json, found through .wrangler/deploy/config.json), so the environment is picked when building rather than deploying: deploy:beta builds with CLOUDFLARE_ENV=beta, and wrangler deploy --env isn't allowed.

The worker's own settings that aren't in wrangler.jsonc, for now only UMAMI_WEBSITE_ID, live in .dev.vars (production) and .dev.vars.beta (beta, its own Umami website), both gitignored. npm run deploy and npm run deploy:beta upload their file as the worker's secrets every time (--secrets-file), so changing one is editing the file and deploying, and the deploy fails without it:

cp .dev.vars.example .dev.vars
cp .dev.vars.example .dev.vars.beta
  • /assets/* and /favicon.svg come straight from static assets and never run the worker. Hashed files are cached for a year (public/_headers).

  • Every other path runs the worker, which returns index.html with that page's <title>, description, og:* tags, canonical link and robots tag, plus the Umami script (UMAMI_WEBSITE_ID in .dev.vars, skipped when empty). Page titles and descriptions live in src/seo.ts, which the router also uses for <title>, so every route in Routes must have an entry there (it's a type error otherwise). Only pages with index: true are indexed and listed in /sitemap.xml.

  • The pages in SERVER_RENDERED_PAGES (home, FAQ, premium, changelog, tag docs, legal, and the not-found page; the changelog and docs take 8-11 ms of CPU, over the free plan's 10 ms limit, so they need Workers Paid) are rendered by the worker with React (src/entry-server.tsx: the app's own routes on a static router), and src/main.tsx hydrates them instead of rendering over the markup. Every other page gets a plain body (renderStaticBody: its title, description and the site's links) until the browser renders it. A rendered page's first render in the browser has to match the server's, so anything only the browser knows waits for an effect: the auth state shows as loading (AuthProvider) and the footer quote is picked after mount. Text a page loads after mounting (another language's FAQ or tag docs) is loaded before both renders instead (src/lib/pagePreload.ts), and dates and numbers follow the request's Accept-Language the way they follow navigator.languages in the browser. A page whose url initI18n moves to another language, or whose render fails, is rendered by the browser like the others.

  • /tags/directory/:id looks the tag up on the API (cached 60s, 3s timeout) so shared links embed with its name, description, stats and thumbnail (none for NSFW tags). Discord gets a component embed with an "Add to Server" button linking to ?add=true, which opens the tag's add dialog (logging in first). The og tags stay as the fallback, and if the lookup fails the page gets the generic Tag Directory embed. /commands/:id (and /commands/:id/slash etc.) gets the command's name, type and description from GET /api/commands (cached 5 minutes), plus a clickable </name:id> for its slash variant once the bot has uploaded external_id. Every embed's color is the logo's gray: the worker sets theme-color and the component embeds' accent_color from the same EMBED_ACCENT_COLOR.

  • /robots.txt and /sitemap.xml are generated by the worker. ALLOW_INDEXING in wrangler.jsonc is "true" only for production, so beta is never indexed.

  • The routes catch every subdomain of notsobot.com and notso.bot (*notsobot.com/*, *notso.bot/*), like nsb.gg: notsobot.com and www.notsobot.com are the site (SITE_HOSTS in the worker, plus localhost for vite dev/vite preview, which the routes never send it), and any other host gets cf-worker-router's 404 ({"code": 0, "message": "Not Found", "status": 404}). beta.notsobot.com/* is the beta script's own route, which is more specific, so it wins.

  • notsobot.com/api/* keeps its "no worker" route on the zone, so API requests still go to the origin. Anything else on the zone that isn't this worker needs one too (Workers Routes → Add route, Worker: None), or the catch-all 404s it. Until Umami moves to oasis.notsocompany.com, that's oasis.notsobot.com/*.

  • notso.bot and www.notso.bot 301 to notsobot.com with the path and query kept, so notso.bot/invite, notso.bot/support and notso.bot/es/premium are short links to the real pages. /assets/* and /favicon.svg are matched before the worker on every host, so those few paths are served instead of redirected or 404'd. The zone only needs proxied DNS records for the routes to run on (* lets other subdomains reach the 404 instead of not resolving), and nothing sends email from it, so it rejects spoofed mail:

    Type Name Content
    AAAA (proxied) notso.bot 100::
    AAAA (proxied) www 100::
    AAAA (proxied) * 100::
    MX notso.bot . (priority 0, a null MX)
    TXT notso.bot v=spf1 -all
    TXT _dmarc v=DMARC1; p=reject

Environment

Variable Default Purpose
VITE_API_BASE /api Where notsobot.api is mounted (same origin in production)
VITE_AUTH_ENABLED true in dev, false in prod builds Shows the login/user menu in the navbar. The old site only showed it on beta.
VITE_DEV_API_PROXY (empty) Dev server only: an origin serving the API under /api (e.g. local nginx) to proxy /api/* to. Empty proxies to a local notsobot.api instead
VITE_DEV_API_PORT 8080 Dev server only: the local notsobot.api port used when VITE_DEV_API_PROXY is empty (/api is stripped, like nginx does)

Pages

The visual design follows the Figma export in ../Discord Bot Command Page (Discord dark palette, card layout). Everything runs on live API data, not that export's mock JSON.

Route Page
/ Home: hero, feature cards, live server/user/command counts, CTA
/commands Ranked by real usage: weekly counts come from GET /commands/stats?since=<7 days ago, floored to the hour>. A "Trending this week" row, a color-coded category sidebar, Prefix/Slash/Context toggle, Popular/A–Z sort, DM-only, and a grid of cards that open a slide-over detail panel at /commands/<command id> (other variants of the same command get /prefix, /slash, /user or /message after it), so commands are linkable. Filters are kept in the URL and remembered between visits. Code lives in src/pages/commands/.
/tags/directory, /tags/directory/:id Tag directory from GET /tags/directory with server-side search, filters (verification status, Verified by default; NSFW hidden unless the small "Show NSFW" checkbox is ticked) and sorting (most used by default, verified first, newest, oldest, A–Z), all kept in the URL (?q=&status=&nsfw=&sort=). "Load more" pages by before for Newest and by offset for the other sorts. A tag opens as a large modal over the directory (child route, so the list, search and scroll stay put). It opens instantly from the card's data, ← → / Previous–Next step through loaded tags, and Back or Escape closes it. Shared /tags/directory/:id links open the directory with that modal on top. There is no single-item endpoint, so direct links ask for before=<id+1>&limit=1. The "Add" button is disabled because the API has no user-facing endpoint for it yet.
/dashboard/* Logged-in dashboard (code-split, behind RequireAuth): Overview; Tags (edit name/content/NSFW, delete, plus Variables and Blobs editors); Servers (mutual servers via the guilds OAuth scope; prefixes, allow/blocklists, command lists, loggers and server settings, editable per Discord permission); Settings (AI personality prompt, models, language/region, media, display); Your Data (export/delete UI; endpoints not built yet). Endpoints: docs/dashboard-api.md. Dev mocks: VITE_DASHBOARD_MOCKS=true npm run dev -- --port 5174.
/faq Help center: 21 questions in 5 sections (content in src/content/faq.ts, with light markup for code, bold, links and lists). Includes a section sidebar, search that opens and highlights matches, animated answers, a "Copy link" on every answer, and deep links like /faq#change-prefix.
/premium What each NotSoPremium tier unlocks: tier cards, what a premium server gets, and a Free/Premium/Plus/Plus AI comparison table (highlights your tier when logged in). The numbers are copied from notsobot.api's utilities/constants/limits.py and the bot's TagLimitDefaults, so update them together. Not in the navbar, footer or sitemap yet, only linked from the dashboard's Premium page.
/status Live shard and cluster health, polled every 2s. A collapsible "Show all totals" panel lists all 18 per-shard counters summed across shards (labels and notes in src/lib/shardCounts.ts). statistics, and a centered shard grid where each cluster shows its healthy count and RAM and each shard has a hover card with live counts. The lookup box takes a server ID, a channel link, shard 12 or cluster 5 (kept in the URL as ?lookup=). It shows the full cluster: health, shard range, RAM, last report, that cluster's shards with the target highlighted, its counts, and a "Show in grid" jump.
/legal/privacy, /legal/tos (/privacy and /terms redirect here) Legal pages. The text was copied over unchanged and lives in src/content/.
/auth/login, /auth/login/callback Discord OAuth (see below)
/auth/redirect Landing page after adding the bot. Sends the Umami BotAdded event, then redirects to /commands.
/invite, /support, /support/invite, /foxbot Redirects to external sites

Authentication

The flow follows notsobot.api/api/views/auth.py:

user clicks "Log in"
  └─ useAuth().login(returnTo)        saves returnTo in sessionStorage
      └─ GET /api/auth/login          API redirects to Discord with a signed `state`
          └─ discord.com/oauth2/authorize
              └─ GET /api/auth/login/callback?code&state
                  │  API exchanges the code, creates or updates the User, ConnectedAccount and UserSession
                  └─ 302 /auth/login/callback#<token>
                      └─ LoginCallbackPage
                          ├─ reads the token from the URL fragment (the fragment is never sent to a server)
                          ├─ removes the fragment from history
                          ├─ tokenStorage.set(token)
                          └─ navigates to returnTo

Main files in src/auth/:

  • tokenStorage.ts: the one place the token is stored, outside React so the API client can read it without a hook. Components subscribe to it with useSyncExternalStore. It listens for the storage event, so logging in or out in one tab updates every tab. It uses the old key and format (localStorage.token, JSON string), so people who are already logged in stay logged in.
  • AuthProvider.tsx: loads GET /users/@me whenever a token exists and exposes useAuth():
    const { status, user, isAuthenticated, login, logout, retry } = useAuth();
    // status: 'anonymous' | 'loading' | 'authenticated' | 'error'
    A token that the API rejects (401/403) is deleted. Network and 5xx errors keep the token and report status: 'error', so a short API outage does not log anyone out.
  • RequireAuth.tsx: route guard for future authenticated pages. Visitors who are not logged in go to /auth/login?next=… and come back after Discord.
    { element: <RequireAuth/>, children: [{ path: '/dashboard', element: <DashboardPage/> }] }
  • returnTo.ts: only allows same-origin paths for the post-login redirect, to prevent open redirects.
  • logout(): calls GET /api/auth/logout, which deletes the UserSession on the server, then clears local state.

src/lib/api/client.ts (apiRequest) sends the token as the raw Authorization header (the API treats a token with no prefix as a user token). It throws ApiRequestError with the API's {code, message, status} body, and clears the token on any authenticated 401.

Testing login locally

The API's callback redirect is relative (/auth/login/callback#…), so the frontend and the API must share an origin. Run notsobot.api with docker compose and set VITE_DEV_API_PROXY=http://localhost. Point DISCORD_CLIENT_REDIRECT_URI at http://localhost:5173/api/auth/login/callback, and add that URL to the Discord application's OAuth redirects.

Backend notes (not done here)

  • TagDirectoryAssets.to_dict builds its CDN URL with a stray $ (f'.../tags-directory/${self.directory_id}/...'), so directory asset URLs come out as /tags-directory/$123/.... No live directory entries have assets yet, so nothing visible is broken today.

  • server_count in TagDirectory.to_dict is hardcoded to 0.

  • The OAuth state is signed and expires, but it is not tied to the browser that started the login. Setting it in an HttpOnly cookie during /auth/login and comparing it on callback would close the login-CSRF gap.

  • If the API ever moves to HttpOnly session cookies, only tokenStorage and apiRequest need to change. Nothing else reads the token.

Layout

src/
  auth/          token storage, AuthProvider/useAuth, RequireAuth, returnTo
  components/
    layout/      SiteLayout, Navbar, AuthControls (login button / user menu), Footer
    ui/          SearchInput, Spinner, PageHeader, FullPageMessage, DiscordLogo
  content/       legal text as typed data
  lib/
    api/         client, endpoints, response types, TanStack Query hooks
    analytics.ts Umami
    discord.ts   avatar URLs, user tags
    utils.ts
  pages/         one file per route
  config.ts      env config, route paths, external URLs
  router.tsx     route table (`handle.title` sets document.title)

About

notsobot.com's frontend

Resources

Stars

6 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages