user-web
The Jester admin UI โ a race and blog management dashboard. It covers race and article management, importing and AI-rewriting past blogs, managing blogs, categories and NG words, and previewing articles and blogs in the browser.
Tech stack
| Item | Details |
|---|---|
| Build | Vite (bun run dev / bun run build; output in out/) |
| Framework | React 19 + react-router-dom v7 โ a client-only SPA with no server runtime |
| Styling | Tailwind CSS v4 (@tailwindcss/vite) |
| UI primitives | Radix UI |
| API access | Browser fetch (src/lib/api.ts) |
| Design | Components and SCSS ported from oddspark-static-pages (src/oddspark/) |
Not Next.js
It began as a Next.js app but was migrated to a Vite + React Router SPA. There are no server components or server actions.
Environment variables
| Name | Description |
|---|---|
VITE_API_BASE_URL |
Base URL of odds_poc_app (the App Lambda) |
VITE_MEDIA_BASE_URL |
CloudFront domain used for media delivery |
Directory layout
jester-user-web/
โโโ index.html
โโโ vite.config.ts
โโโ src/
โโโ main.tsx # Entry (BrowserRouter)
โโโ App.tsx # Route definitions
โโโ pages/ # One component per route
โโโ components/ # Shared UI and the admin layout
โโโ lib/
โ โโโ api.ts # API calls (reads and mutations)
โ โโโ types.ts # API response types
โ โโโ race-config.ts # Mapping between racing types and path segments
โ โโโ race-result/ # Race result formatting
โโโ oddspark/ # Design assets ported from oddspark
Screens
Admin (sidebar layout)
| Screen | Path | Purpose |
|---|---|---|
| Horse race list | /horse-racings |
Race list; entry point for article generation |
| Auto race list | /auto-racings |
Same |
| Keirin race list | /bicycle-racings |
Same |
| Race detail | /:raceType/detail?id=... |
Inspect the race result, generate an article, list linked articles |
| Race article detail | /:raceType/detail/article?id=... |
Inspect and edit the article body |
| Failed generations | /articles/failed |
List status=failed articles and regenerate with extra instructions |
| Blog list | /blogs |
List and filter generated blogs |
| Blog detail | /blogs/detail?id=... |
Edit body/title/category, generate a thumbnail, generate related information, delete |
| Past blog list | /old-blogs |
List and import past blogs |
| Past blog detail | /old-blogs/detail?id=... |
Update the category; entry point for AI rewriting |
| Categories | /categories |
Create, update and delete categories |
| NG words | /ng-words |
Create, update and delete NG words |
Visiting the root (/) redirects to the first racing type (the horse race list).
Previews (no admin chrome; opened in a new tab)
| Screen | Path | Purpose |
|---|---|---|
| Race result page | /articles/preview?raceId=... |
The real race result page with the article summary embedded |
| Blog article page | /blogs/preview?id=... |
The real blog rendering |
| Pre-rewrite blog | /blogs/preview/original?id=... |
The same article layout with the body swapped back to the pre-rewrite text, for comparison |
Previews pull in the global CSS (including a reset) ported from oddspark, so they are lazy-loaded to keep those styles separate from the admin UI.
Detail pages take their id from the query string
Detail pages read their id from the query string (?id=...), not a path parameter. The three
racing types (horse-racings / auto-racings / bicycle-racings) share the /:raceType routes.
Racing types
src/lib/race-config.ts maps URL path segments to the backend racing types.
| kind | Path | racingType (API) |
Label | Runner number |
|---|---|---|---|---|
horse |
/horse-racings |
horse_racing |
็ซถ้ฆฌใฌใผใน | horse_number |
auto |
/auto-racings |
auto_racing |
ใชใผใใฌใผใน | car_number |
bicycle |
/bicycle-racings |
bicycle_racing |
็ซถ่ผชใฌใผใน | car_number |
The race list calls GET /v1/races?type={racingType}. Since race_id is unique across racing types,
fetching one race (GET /v1/races/{race_id}) needs no type.
APIs used
Races and articles
| Action | API |
|---|---|
| Race list | GET /v1/races?type=... |
| Race detail | GET /v1/races/{race_id} |
| Articles for a race | GET /v1/articles?race_id=... / GET /v1/races/{race_id}/articles/{article_id} |
| Failed generations | GET /v1/articles?status=failed |
| AI article generation | POST /v1/articles/generation |
| Regenerate a failed article | POST /v1/articles/{article_id}/regeneration |
| Update the article body | PUT /v1/articles/{article_id} |
Blogs and past blogs
| Action | API |
|---|---|
| Blog list | GET /v1/blogs (filterable by race_id / race_type / category_id / origin / status / name / id) |
| Blog detail | GET /v1/blogs/{blog_id} |
| Update a blog (body, title, category) | PUT /v1/blogs/{blog_id} |
| Delete a blog | DELETE /v1/blogs/{blog_id} |
| Generate a thumbnail | POST /v1/blogs/{blog_id}/thumbnail_generate |
| Generate related information | POST /v1/blogs/{blog_id}/related_generate |
| Past blog list / detail | GET /v1/old_blogs / GET /v1/old_blogs/{old_blog_id} |
| Import a past blog | POST /v1/old_blogs |
| Update a past blog's category | PUT /v1/old_blogs/{old_blog_id} |
| Start an AI rewrite | POST /v1/old_blogs/{old_blog_id}/rewrite |
Master data
| Action | API |
|---|---|
| Categories | GET / POST / PUT / DELETE /v1/categories |
| NG words | GET / POST / PUT / DELETE /v1/ng_words |
Importing past blogs
Past blogs are imported by uploading the scraped JSON files produced by scripts/scraping_blog.py
(in jester-backend) โ multiple files can be selected at once.
The UI reads each file and calls POST /v1/old_blogs with this mapping.
| JSON key | Request field |
|---|---|
title |
name |
body_html |
body_html |
race_type |
race_type |
blog_type |
blog_type |
category |
category |
url |
source_url |
published_at |
published_at (both "2026ๅนด5ๆ10ๆฅ" style and ISO 8601 are converted to UNIX ms) |
Files without title or body_html are skipped, and the success count and failure reasons are
reported together at the end.
Asynchronous operations
Article generation, blog rewriting and thumbnail generation return 202 Accepted immediately and
continue in the background. The UI does not poll โ the state is checked on reload.
| Operation | How completion shows up |
|---|---|
| Article generation | status moves generating โ draft (or failed) |
| Blog rewrite / original writing | status moves generating โ draft (on failure the record disappears) |
| Thumbnail generation | The thumbnail image appears in the blog body |
Related-information generation (related_generate) is the exception: it is synchronous and returns
the updated blog immediately.
Error display
FastAPI returns the reason in detail. ApiError in src/lib/api.ts lifts it onto message, so
backend reasons such as "ใใฎใฌใผในใฏ่จไบใ็ๆใงใใพใใ: โฆ" can be shown to the user verbatim.
Deployment
bun run build emits static files to out/, and GitHub Actions
(.github/workflows/dev-deploy.yml) syncs them to S3 + CloudFront on every push to main.
SPA fallback is required
Because this is a client-routed SPA, the CDN must serve index.html (200) for unknown paths
(a CloudFront custom error response mapping 403/404 to /index.html). Without it, deep links 404.