Skip to content

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.