From 83bbc597ce04cf7c305970f860c68c84e695f03a Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 13 Aug 2026 11:37:59 +0700 Subject: [PATCH] feat(frontend): serve all four datasets and the hub from one app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2017 frontend becomes the only frontend. It now resolves which dataset to show from the URL, so the four dataset views and the /thptqg/ landing page all come from a single build instead of four builds plus a hand-written static index.html. Routing is an exact match on one path segment, because the segment is the dataset id: /thptqg/2017-old/ -> "2017-old". The nested form this replaces (/thptqg/2017/old/) would have needed longest-prefix matching, since it also starts with /thptqg/2017/. Previously published nested URLs are rewritten to their flat equivalent with the query string intact, so ?q= deep links keep working. Restores what the 2016 site had and the 2017 site lacked: - Cụm thi and Giới tính columns, in the table and on the detail card - exam-ID lookup for letter-prefixed số báo danh That second one would have been a severe regression. 616,593 of 877,461 candidates in the 2016 dataset — 70.3% — have an SBD like BAL000001, and the 2017 app matched /^\d+$/ only, so exam-ID search would have silently failed for most of that year. Detection now lives in lib/query-mode.js, shared by App and SearchForm, which had already drifted apart on exactly this rule. Letter prefixes are upper-cased before lookup so "bal000001" resolves. Neither dataset needs a conditional in a component. Columns where every row is NULL are hidden, so 2016 rows surface Cụm thi / Đức / Nhật while 2017 rows surface KHTN / KHXH / GDCD / Nga. computeBlocks() likewise skips blocks with a missing subject, so one admission-block list covers both years — D05 and D06 are added now that German and Japanese scores exist. Per-dataset content moves into the registry: title, source, database size, search examples and SQL presets. The 2016 presets are lifted from the deleted 2016 component; the Long An (49xxx) queries stay on 2017 only, where that SBD prefix means something. The subject list, previously maintained separately in score-table.jsx and student-detail.jsx, is now declared once in lib/subjects.js mirroring SCORE_FIELDS in the parser. --- src/App.css | 98 +++++++++++ src/App.jsx | 47 +++-- src/components/custom-query.jsx | 184 +------------------- src/components/hub.jsx | 57 ++++++ src/components/score-table.jsx | 46 ++--- src/components/search-form.jsx | 30 ++-- src/components/student-detail.jsx | 44 ++--- src/datasets.js | 37 ++++ src/lib/admission-blocks.js | 21 ++- src/lib/query-mode.js | 59 +++++++ src/lib/sql-presets.js | 280 ++++++++++++++++++++++++++++++ src/lib/subjects.js | 51 ++++++ src/router.js | 56 ++++++ 13 files changed, 745 insertions(+), 265 deletions(-) create mode 100644 src/components/hub.jsx create mode 100644 src/lib/query-mode.js create mode 100644 src/lib/sql-presets.js create mode 100644 src/lib/subjects.js create mode 100644 src/router.js diff --git a/src/App.css b/src/App.css index af95038..376bd76 100644 --- a/src/App.css +++ b/src/App.css @@ -779,3 +779,101 @@ footer { transition-duration: 0.01ms !important; } } + +/* --------------------------------------------------------------------------- + Table cells for the 2016-only identity columns. + Ported from the 2016 frontend; hidden automatically on the 2017 datasets + because every row there is NULL. + --------------------------------------------------------------------------- */ + +.sbd-cell { + font-family: ui-monospace, monospace; + font-size: 0.85rem; + color: var(--text-muted); + white-space: nowrap; +} + +.cumthi-cell { + font-size: 0.82rem; + color: var(--text-muted); + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* --------------------------------------------------------------------------- + Hub route (/thptqg/) — replaces the old standalone static index.html + --------------------------------------------------------------------------- */ + +.hub { + max-width: 42rem; + margin: 3rem auto; + padding: 0 1rem; + line-height: 1.6; +} + +.hub h1 { + font-size: 1.5rem; + margin-bottom: 0.5rem; +} + +.hub-list { + list-style: none; + padding: 0; + margin: 1.5rem 0; + display: grid; + gap: 0.75rem; +} + +.hub-link { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.25rem 0.75rem; + padding: 0.9rem 1.1rem; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface, transparent); + text-decoration: none; + color: inherit; +} + +.hub-link:hover, +.hub-link:focus-visible { + border-color: var(--border-strong); +} + +.hub-label { + font-weight: 600; +} + +.hub-blurb { + color: var(--text-muted); + font-size: 0.9rem; +} + +.hub-archive h2 { + font-size: 0.95rem; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 0; +} + +.hub-list-compact { + margin-top: 0.75rem; +} + +.hub-back { + margin: 0.35rem 0 0; + font-size: 0.85rem; +} + +.hub-back a { + color: var(--text-muted); + text-decoration: none; +} + +.hub-back a:hover { + text-decoration: underline; +} diff --git a/src/App.jsx b/src/App.jsx index 94646c7..7e59bd2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,17 +4,19 @@ import { SearchForm } from "./components/search-form"; import { ScoreTable } from "./components/score-table"; import { StudentDetail } from "./components/student-detail"; import { CustomQuery } from "./components/custom-query"; +import { Hub } from "./components/hub"; +import { dbOf } from "./datasets"; +import { resolveRoute } from "./router"; +import { isExamId, normaliseExamId } from "./lib/query-mode"; import "./App.css"; -const DB_URL = import.meta.env.BASE_URL + "thptqg2017.db.gz"; const MAX_RESULTS = 100; -const DB_SIZE_MB = 47; // Strip Vietnamese diacritics: "Nguyễn Bửu Lộc" → "nguyen buu loc" function toAscii(str) { return str .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") + .replace(/[̀-ͯ]/g, "") .replace(/đ/gi, "d") .toLowerCase(); } @@ -38,8 +40,11 @@ function readUrlQuery() { return new URL(window.location.href).searchParams.get("q") || ""; } -function App() { - const { db, loading, error, progress } = useSqlite(DB_URL); +/** The score-lookup view for one dataset. */ +function DatasetApp({ dataset }) { + const { db, loading, error, progress } = useSqlite( + dbOf(dataset, import.meta.env.BASE_URL), + ); const [results, setResults] = useState(null); const [searchError, setSearchError] = useState(null); const [activeTab, setActiveTab] = useState("search"); @@ -55,14 +60,14 @@ function App() { writeUrlQuery(q); try { - const isExamId = /^\d+$/.test(q); let stmt; - if (isExamId) { + if (isExamId(q)) { + // Letter-prefixed 2016 IDs are stored upper-case; digits are unaffected. stmt = db.prepare( "SELECT * FROM student WHERE so_bao_danh = $q LIMIT $limit", ); - stmt.bind({ $q: q, $limit: MAX_RESULTS }); + stmt.bind({ $q: normaliseExamId(q), $limit: MAX_RESULTS }); } else if (isAsciiOnly(q)) { stmt = db.prepare( "SELECT * FROM student WHERE ho_ten_ascii LIKE $q LIMIT $limit", @@ -141,9 +146,10 @@ function App() { return (
-

Tra cứu điểm thi THPT Quốc gia 2017

-

- Dữ liệu thí sinh toàn quốc · Hỗ trợ truy vấn SQL tùy chỉnh +

{dataset.title}

+

{dataset.subtitle}

+

+ ← Tất cả các kỳ thi

@@ -151,7 +157,7 @@ function App() { {loading && (

- Đang tải cơ sở dữ liệu ~{DB_SIZE_MB} MB + Đang tải cơ sở dữ liệu ~{dataset.dbSizeMb} MB {progress > 0 ? ` · ${progress}%` : ""}

@@ -188,6 +194,7 @@ function App() { onSearch={handleSearch} onClear={handleClear} disabled={loading || !!error} + examples={dataset.examples} /> {searchError && ( @@ -209,13 +216,17 @@ function App() { )} {activeTab === "sql" && ( - + )}