refactor(web): rebuild the frontend on SvelteKit, TypeScript and Tailwind

The app was a single-entry React SPA: one index.html that the assembler
copied to every dataset path, with a hand-rolled router resolving the
dataset from window.location and the title patched in at runtime because
one file had to serve every route.

SvelteKit prerenders a real page per route instead. The entry generator
in routes/[dataset]/+page.ts reads the same datasets.json the assembler
does, so the set of pages and the set of databases cannot drift apart,
and each dataset page ships its own <title> and description.

Everything framework-free moved across unchanged in behaviour and became
typed: the admission blocks, subject list, query classifier and SQL
presets. `Student` now mirrors the 22-column table, so a mistyped column
name fails the build rather than rendering blank.

Tailwind replaces the stylesheet. Theme tokens are CSS variables, which
keeps dark mode a single block of overrides rather than a `dark:` variant
on every class. The score tiers stay hand-written CSS: the class is
chosen at runtime from a score, and no utility generator can see that.

Adds the tests the frontend never had, over the three modules where a
silent wrong answer is possible — most importantly that toAscii here
folds exactly as ToAscii does in the parser, which is what makes
accent-insensitive search find anything.

The assembler stops copying index.html per dataset and checks that the
build prerendered each one instead. The database presence, raw-artifact
and idempotence guards are untouched.

Verified: 25 tests, ESLint and svelte-check clean, and a full
`assemble site` producing /thptqg/, /thptqg/2016/, /thptqg/2017/ and
404.html with absolute asset URLs. Not verified in a browser — this
machine has none.
This commit is contained in:
tiennm99 committed 2026-08-14 11:38:29 +07:00
1 parent e2112fdb38
commit 3fd137a233
49 files changed
+4432 -3112

No files matched your search

+6
View File
@@ -66,6 +66,12 @@ jobs:
- name: Test assembler
run: go -C assembler test ./...
# Lint covers ESLint and svelte-check; the tests cover the framework-free
# modules, including the ASCII fold that has to match the Go parser.
- name: Test web
working-directory: web
run: npm test
- name: Lint web
working-directory: web
run: npm run lint
+1
View File
@@ -13,6 +13,7 @@ node_modules/
### Build output ###
dist/
web/.svelte-kit/
### Generated databases and Vite publicDir staging ###
.build/
+13 -8
View File
@@ -3,9 +3,10 @@
Exam-score lookup for Vietnam's national high school graduation exam. Four
stages, each independent: `crawler/` (Go) fetches spreadsheets into `data/<id>/`,
`parser/` (Go) turns them into `<id>.db`, `assembler/` (Go) verifies, compresses
and builds `_site/`, and `web/` (Vite + React) serves every dataset from one app.
and builds `_site/`, and `web/` (SvelteKit + Tailwind) serves every dataset from
one app.
`datasets.json` at the root is the registry the Go stages and the Vite app both
`datasets.json` at the root is the registry the Go stages and the web app both
read. See [`docs/`](./docs/) for architecture, pipeline and deployment.
## Verifying a change
@@ -20,9 +21,11 @@ go -C assembler test ./...
Add the web checks when frontend files changed:
```bash
(cd web && npm run lint && npm run build)
(cd web && npm test && npm run lint && npm run build)
```
`npm run lint` is ESLint plus `svelte-check`, so it is the type check too.
**Do not run the crawler suite as part of routine verification.** The crawler is
not part of the build — `data/<id>/` is committed and a crawl only refreshes it
by hand — so its tests do not gate ordinary changes. Run them only when the
@@ -41,16 +44,18 @@ hashes every real input file. That is the point of it; do not skip it.
regenerated.** A mismatch is a reader bug until proven otherwise, never a cue
to refresh the file. `parser/cmd/dumpcells` narrows a failure to the cell.
- **`ToAscii` filters the literal range U+0300..U+036F, not `unicode.Mn`.** It
must match `toAscii` in `web/src/App.jsx`, or accent-insensitive search
silently misses rows.
must match `toAscii` in `web/src/lib/to-ascii.ts`, or accent-insensitive search
silently misses rows. `to-ascii.test.ts` pins the pairs both sides must agree
on.
- **The 2016 files use four different layouts, and detection is per sheet.**
Two of them publish scores in one column per subject instead of a `DIEM_THI`
sentence, and one puts a three-row ministry title block above its header.
`parser/internal/ingest/detect2016.go` holds the header tables; they are
observations about 119 specific files, not a rule to generalise.
- **`base` in `web/vite.config.js` is absolute.** One emitted `index.html` is
copied to every dataset path, so every URL is a real static file and no SPA
404-fallback is needed — a fallback would break the `?q=` deep links.
- **`paths.relative` is false in `web/svelte.config.js`.** Every route is
prerendered to its own file and asset URLs stay absolute, so the copy of
`index.html` serving as `404.html` works at any depth. No SPA 404-fallback is
used — a fallback would break the `?q=` deep links.
- **`dbSizeMb` in `datasets.json` is a build guard, not just a label.** The
assembler refuses to publish an artifact that falls below a ratio of it.
+4 -4
View File
@@ -25,7 +25,7 @@ pass between them.
crawler/ Go — re-fetches the source spreadsheets → data/
parser/ Go — Excel to SQLite data/ → .db
assembler/ Go — verifies, compresses, builds, assembles .db + web/ → _site/
web/ npm — the frontend, one Vite app for every dataset
web/ npm — the frontend, one SvelteKit app for every dataset
data/<id>/ raw Excel files, one directory per dataset
datasets.json the registry: which datasets exist, and their expected size
docs/ architecture, data pipeline, deployment
@@ -34,9 +34,9 @@ docs/ architecture, data pipeline, deployment
Each stage runs on its own and hands its output to the next through the stores.
`web/` is the only npm project; the three stages are independent Go modules.
`datasets.json` is the contract between them. It is JSON because Go and the Vite
`datasets.json` is the contract between them. It is JSON because Go and the web
app both read it and neither needs a dependency to do so; presentation stays in
`web/src/datasets.js`, keyed by id, which fails loudly if the two disagree.
`web/src/lib/datasets.ts`, keyed by id, which fails loudly if the two disagree.
The dataset id is one identifier end to end:
@@ -85,7 +85,7 @@ Pushing to `main` runs the same steps in
2. Add `parser/configs/<id>.yml` — sheet mode, column indices, validation
guards. No SQL; the schema is canonical.
3. Add an entry to `datasets.json` with its expected row count and size
4. Add the matching presentation to `CONTENT` in `web/src/datasets.js`
4. Add the matching presentation to `CONTENT` in `web/src/lib/datasets.ts`
Everything else follows: the assembler, the router and the hub all read the
registry, and the UI adapts to whichever columns the dataset fills. Steps 3 and 4
+39 -26
View File
@@ -1,15 +1,14 @@
// Package site turns the web app and the staged databases into the directory
// GitHub Pages publishes.
//
// The app resolves its dataset from the URL, so every page is the same
// index.html. Because Vite's `base` is absolute (/thptqg/), that file references
// /thptqg/assets/... no matter which directory it is served from — so copying it
// to each dataset path produces a real static file at every URL.
// SvelteKit prerenders one HTML file per route — the hub and one per dataset in
// the registry — so every URL is already a real static file when this runs. All
// that is left is the 404 document and the checks on what shipped.
//
// GitHub Pages serves those as directory indexes, which is why this needs no
// SPA 404-fallback redirect. That matters beyond tidiness: the usual fallback
// rewrites the URL and would interfere with the ?q= deep links the app relies
// on.
// GitHub Pages serves the prerendered files as directory indexes, which is why
// this needs no SPA fallback redirect. That matters beyond tidiness: the usual
// fallback rewrites the URL and would interfere with the ?q= deep links the app
// relies on.
package site
import (
@@ -26,9 +25,9 @@ import (
// Paths locates the pieces this package needs.
type Paths struct {
// Web is the Vite project directory.
// Web is the SvelteKit project directory.
Web string
// Dist is where Vite emits, inside the web workspace.
// Dist is where the static adapter emits, inside the web workspace.
Dist string
// Site is the artifact the deploy action uploads, at the repository root.
Site string
@@ -44,17 +43,17 @@ func DefaultPaths(root string) Paths {
}
}
// BuildWeb runs the Vite build.
// BuildWeb runs the web build.
//
// Shelling out to npm is not a wart: Vite is a Node tool, and web/ is the only
// npm project in the repository. This stage owns the sequencing, not the
// Shelling out to npm is not a wart: SvelteKit is a Node tool, and web/ is the
// only npm project in the repository. This stage owns the sequencing, not the
// bundling.
func BuildWeb(p Paths) error {
cmd := exec.Command("npm", "run", "build")
cmd.Dir = p.Web
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("vite build: %w", err)
return fmt.Errorf("web build: %w", err)
}
return nil
}
@@ -73,27 +72,20 @@ func Assemble(p Paths, datasets []registry.Dataset) error {
return err
}
// Base build: index.html, assets/, and the gzipped databases from publicDir.
// The prerendered pages, _app/ and the gzipped databases in one move.
if err := copyTree(p.Dist, p.Site); err != nil {
return err
}
// Unknown paths render the hub rather than the default Pages 404.
// Unknown paths render the hub rather than the default Pages 404. Asset URLs
// in that file are absolute, so it works at any depth.
if err := copyFile(index, filepath.Join(p.Site, "404.html")); err != nil {
return err
}
// One entry point per dataset.
for _, d := range datasets {
dir := filepath.Join(p.Site, d.ID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err := copyFile(index, filepath.Join(dir, "index.html")); err != nil {
return err
}
if err := checkDatasetPages(p.Site, datasets); err != nil {
return err
}
if err := checkDatabasesPresent(p.Site, datasets); err != nil {
return err
}
@@ -109,6 +101,27 @@ func Assemble(p Paths, datasets []registry.Dataset) error {
return nil
}
// checkDatasetPages: every dataset must have prerendered an entry point.
//
// The pages come from the web build's entry generator reading the same
// registry, so a missing one means the two fell out of step — a dataset URL
// that 404s while the build stays green.
func checkDatasetPages(siteDir string, datasets []registry.Dataset) error {
var missing []string
for _, d := range datasets {
if _, err := os.Stat(filepath.Join(siteDir, d.ID, "index.html")); err != nil {
missing = append(missing, d.ID+"/index.html")
}
}
if len(missing) > 0 {
return fmt.Errorf(
"the web build prerendered no page for: %s\n"+
"Those URLs would 404. Check the entry generator in web/src/routes/[dataset]/+page.ts",
strings.Join(missing, ", "))
}
return nil
}
// checkDatabasesPresent: every dataset must have shipped its database.
//
// Without this the site assembles happily with an empty db/ directory — every
+26 -6
View File
@@ -11,14 +11,17 @@ import (
var datasets = []registry.Dataset{{ID: "2016"}, {ID: "2017"}}
// fakeBuild stands in for a Vite build: an index.html, an asset, and whatever
// databases the caller wants staged.
// fakeBuild stands in for a web build: the prerendered hub and one page per
// dataset, an asset, and whatever databases the caller wants staged.
func fakeBuild(t *testing.T, dbs ...string) Paths {
t.Helper()
root := t.TempDir()
dist := filepath.Join(root, "web", "dist")
write(t, filepath.Join(dist, "index.html"), "<html>app</html>")
write(t, filepath.Join(dist, "assets", "index.js"), "console.log(1)")
write(t, filepath.Join(dist, "index.html"), "<html>hub</html>")
for _, d := range datasets {
write(t, filepath.Join(dist, d.ID, "index.html"), "<html>"+d.ID+"</html>")
}
write(t, filepath.Join(dist, "_app", "immutable", "entry.js"), "console.log(1)")
for _, name := range dbs {
write(t, filepath.Join(dist, "db", name), "gzipped-bytes")
}
@@ -45,7 +48,7 @@ func TestAssembleProducesAPageForEveryDataset(t *testing.T) {
"404.html",
filepath.Join("2016", "index.html"),
filepath.Join("2017", "index.html"),
filepath.Join("assets", "index.js"),
filepath.Join("_app", "immutable", "entry.js"),
filepath.Join("db", "2016.db.gz"),
} {
if _, err := os.Stat(filepath.Join(p.Site, want)); err != nil {
@@ -54,6 +57,23 @@ func TestAssembleProducesAPageForEveryDataset(t *testing.T) {
}
}
// TestMissingDatasetPageFailsTheBuild: the pages come from the web build's
// entry generator, which reads the same registry this does. If the two fall out
// of step, that dataset's URL 404s — so the build stops instead.
func TestMissingDatasetPageFailsTheBuild(t *testing.T) {
p := fakeBuild(t, "2016.db.gz", "2017.db.gz")
if err := os.RemoveAll(filepath.Join(p.Dist, "2017")); err != nil {
t.Fatal(err)
}
err := Assemble(p, datasets)
if err == nil {
t.Fatal("expected an error when a dataset page was not prerendered")
}
if !strings.Contains(err.Error(), "2017") {
t.Errorf("the error should name the missing page, got: %v", err)
}
}
// TestMissingDatabaseFailsTheBuild is the guard that closes the widest hole.
//
// Without it the site assembles happily with an empty db/ directory: every page
@@ -115,7 +135,7 @@ func TestAssembleRejectsAMissingBuild(t *testing.T) {
root := t.TempDir()
p := Paths{Web: root, Dist: filepath.Join(root, "dist"), Site: filepath.Join(root, "_site")}
if err := Assemble(p, datasets); err == nil {
t.Fatal("expected an error when there is no Vite build")
t.Fatal("expected an error when there is no web build")
}
}
+6 -6
View File
@@ -11,7 +11,7 @@ One-time setup: **Settings → Pages → Source: GitHub Actions**.
2. Parser, crawler and assembler test suites, web lint, `govulncheck` over all three modules
3. `go -C assembler run ./cmd/assemble` — the whole pipeline: compile the
parser, build and verify each database, compress it into `.build/public/db/`,
run the Vite build, assemble `_site/`
run the web build, assemble `_site/`
4. `actions/upload-pages-artifact` + `actions/deploy-pages`
The database build dominates the runtime: roughly 348 MB of Excel is parsed on
@@ -46,9 +46,9 @@ go -C assembler run ./cmd/assemble site
## Base path
`vite.config.js` sets `base: "/thptqg/"`. If you fork under a different repo
name, update it to match — assets are referenced absolutely, so a mismatch shows
up as a blank page with 404s on `/assets/...`.
`svelte.config.js` sets `paths.base: "/thptqg"`. If you fork under a different
repo name, update it to match — assets are referenced absolutely, so a mismatch
shows up as a blank page with 404s on `/_app/...`.
## Adding a dataset
@@ -96,9 +96,9 @@ run rebuilds the older state. There is no data to migrate.
| Symptom | Typical cause |
| --- | --- |
| Blank page, 404 on assets | `base` in `vite.config.js` does not match the repo name |
| Blank page, 404 on assets | `paths.base` in `svelte.config.js` does not match the repo name |
| `Failed to fetch database: 404` | Dataset id in `datasets.json` does not match the file in `db/` |
| A route 404s | The site step did not run, or the id is missing from `datasets.json` |
| WASM fails to load | `sql.js.org` unreachable — self-host `sql-wasm.wasm` and update `SQL_WASM_URL` in `use-sqlite.js` |
| WASM fails to load | `sql.js.org` unreachable — self-host `sql-wasm.wasm` and update `SQL_WASM_URL` in `lib/sqlite.svelte.ts` |
| Deploy fails on assembly | An uncompressed database artefact reached the output; the error names the files |
| Missing rows after a data update | Unknown Excel header — check the per-file row counts the parser prints |
+20 -20
View File
@@ -20,7 +20,7 @@ data/<id>/*.xls(x)
▼ assembler/ — row count must match datasets.json, then gzip
.build/public/db/<id>.db.gz (the raw .db does not survive)
│
▼ assembler/ → vite build (root = web/, publicDir = .build/public)
▼ assembler/ → npm run build (SvelteKit static, assets = .build/public)
web/dist/
│
▼ assembler/ — one index.html per dataset; every database must be present
@@ -40,7 +40,7 @@ data/2017/ → parser/configs/2017.yml → db/2017.db.gz → /thptqg/2017/
`datasets.json` at the repository root declares the ids once, with the row count
and artifact size the assembler enforces. It is JSON rather than a module
because the assembler is a Go program and the Vite app is not, and JSON is the
because the assembler is a Go program and the web app is not, and JSON is the
only format both parse without a dependency.
Presentation — titles, labels, search examples, SQL presets — stays in
@@ -100,23 +100,21 @@ URLs are flat, one segment per dataset, and the segment is the id:
/thptqg/2017/
```
`web/src/router.js` is an exact match on that segment. The nested form used
before (`/thptqg/2017/old/`) would have needed longest-prefix matching, since it
also starts with `/thptqg/2017/`. Both of those URLs addressed the two removed
2017 archives, so the rewrite that kept them working has gone with them; they
fall through to the hub like any other unknown path.
The route is `web/src/routes/[dataset]/`, and its entry generator reads the same
`datasets.json` the assembler does, so the set of pages and the set of databases
cannot drift apart. Unknown paths fall through to the hub.
A single Vite build emits one `index.html`. Because `base` is absolute
(`/thptqg/`), that file references `/thptqg/assets/...` regardless of the
directory it is served from, so the assemble step copies it to every route and
each URL is a real static file. **No SPA 404-fallback redirect is used** — the
usual hack rewrites URLs and would interfere with the deep links.
SvelteKit prerenders one HTML file per route, each with its own `<title>`.
Asset URLs stay absolute (`paths.relative: false`), so the copy of the hub that
serves as `404.html` resolves its assets from any depth. **No SPA 404-fallback
redirect is used** — the usual hack rewrites URLs and would interfere with the
deep links.
## Serving both exam years without branching
No component contains a per-dataset conditional. Two mechanisms do the work:
- **All-NULL columns are hidden.** `score-table.jsx` drops any column where
- **All-NULL columns are hidden.** `score-table.svelte` drops any column where
every row in the result set is NULL, so 2016 rows surface Cụm thi / GT / Đức /
Nhật and 2017 rows surface KHTN / KHXH / GDCD / Nga.
- **Incomplete admission blocks are skipped.** `computeBlocks()` only returns a
@@ -125,13 +123,14 @@ No component contains a per-dataset conditional. Two mechanisms do the work:
self-exclude wherever those languages were not sat.
Anything genuinely per-dataset — title, source, database size, search examples,
SQL presets — lives in `web/src/datasets.js`.
SQL presets — lives in `web/src/lib/datasets.ts`.
## Exam ID formats
`web/src/lib/query-mode.js` decides whether a query is an exam ID or a name, and is
shared by `App.jsx` and `search-form.jsx` (they previously held separate copies
and had drifted apart on exactly this rule).
`web/src/lib/query-mode.ts` decides whether a query is an exam ID or a name, and
is shared by the dataset page and `search-form.svelte` (they previously held
separate copies and had drifted apart on exactly this rule). `query-mode.test.ts`
covers every form in the table below.
| Form | Example | Where |
| --- | --- | --- |
@@ -175,16 +174,17 @@ total descending.
| Diacritics search | Pre-computed `ho_ten_ascii` | `LOWER(REPLACE(...))` at query time defeats the index |
| SQL safety | Leading-keyword allowlist | `sql.js` is in-memory so writes cannot persist; the allowlist prevents confusion |
| Row caps | 100 (lookup), 1000 (SQL) | Keeps DOM render sizes reasonable |
| Routing | Hand-rolled, ~15 lines | Three static routes do not justify a router dependency |
| Routing | SvelteKit file routes, prerendered | Each dataset gets a real HTML file with its own title |
| Styling | Tailwind, with tier colours as CSS variables | Tier classes are chosen at runtime, which no utility generator can see |
## Risks and limitations
- **Database size.** 44–48 MB gzipped per dataset; slow links wait, mitigated by
- **Database size.** 45–48 MB gzipped per dataset; slow links wait, mitigated by
a progress bar.
- **Browser memory.** The full database lives in RAM; older mobile devices may
run out.
- **`sql.js.org` dependency.** If that CDN is unreachable, the WASM fails to
load. Self-hosting `sql-wasm.wasm` and updating `SQL_WASM_URL` in
`web/src/hooks/use-sqlite.js` is the fix.
`web/src/lib/sqlite.svelte.ts` is the fix.
- **Excel format drift.** A new source file with an unseen header layout needs a
new branch in `parser/internal/ingest/detect2016.go` or a new config.
+1 -1
View File
@@ -41,7 +41,7 @@ reader independently verifiable against a hash oracle.
- `ToAscii` strips combining marks in the literal range U+0300–U+036F rather
than by Unicode category. That covers every Vietnamese diacritic and must
stay identical to `toAscii` in `web/src/App.jsx`, or accent-insensitive
stay identical to `toAscii` in `web/src/lib/to-ascii.ts`, or accent-insensitive
search misses rows.
- Gender is normalised to `Nam`/`Nữ`; the Cần Thơ files write `0`/`1` instead
and are translated. Anything else becomes NULL.
+23 -30
View File
@@ -1,37 +1,30 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
import js from "@eslint/js";
import svelte from "eslint-plugin-svelte";
import globals from "globals";
import ts from "typescript-eslint";
import svelteConfig from "./svelte.config.js";
// Scoped to this workspace; every other stage in the repository is Go.
export default defineConfig([
globalIgnores(['dist']),
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
globals: { ...globals.browser, ...globals.node },
},
},
{
// Svelte files and rune modules are parsed by svelte-eslint-parser, which
// needs the TypeScript parser handed to it for `lang="ts"` blocks.
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: {
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
parser: ts.parser,
projectService: true,
extraFileExtensions: [".svelte"],
svelteConfig,
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
{
// The Vite config runs under Node, not in the browser.
files: ['vite.config.js'],
languageOptions: {
globals: { ...globals.node },
},
},
])
{ ignores: ["dist/", ".svelte-kit/", "node_modules/"] },
);
+2704 -1106
View File
File diff suppressed because it is too large. Load diff
+17 -11
View File
@@ -3,27 +3,33 @@
"private": true,
"version": "1.0.0",
"type": "module",
"description": "Frontend — one Vite app serving every dataset and the hub",
"description": "Frontend — one SvelteKit app serving every dataset and the hub",
"scripts": {
"dev": "vite",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint ."
"test": "vitest run",
"lint": "eslint . && svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sql.js": "^1.14.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.3.3",
"@types/sql.js": "^1.4.9",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-svelte": "^3.14.0",
"globals": "^17.4.0",
"vite": "^8.0.16"
"svelte": "^5.56.9",
"svelte-check": "^4.4.3",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.2",
"vite": "^7.1.14",
"vitest": "^4.1.10"
}
}
-866
View File
@@ -1,866 +0,0 @@
/* Design tokens — semantic, paired for light + dark */
:root {
--bg: #f7f7fb;
--surface: #ffffff;
--surface-alt: #f0f0f5;
--border: #e2e2eb;
--border-strong: #d0d0dc;
--text: #1a1a2e;
--text-muted: #5c6072;
--text-subtle: #868a99;
--primary: #3b4fe4;
--primary-hover: #2d3ec9;
--primary-ink: #ffffff;
--focus-ring: 0 0 0 3px rgba(59, 79, 228, 0.35);
--radius: 10px;
--radius-sm: 6px;
--shadow-md: 0 6px 20px rgba(15, 20, 40, 0.08);
/* Score tier palette — TFT rarity ladder (white → green → blue → purple → gold → prismatic).
Pairs picked for AA contrast (≥4.5:1) of ink against bg. */
--tier-common-bg: #f3f4f6; --tier-common-ink: #4a5163; --tier-common-accent: #b5b9c5;
--tier-uncommon-bg: #e6f6ec; --tier-uncommon-ink: #196a36; --tier-uncommon-accent: #2e9e55;
--tier-rare-bg: #e8f0fe; --tier-rare-ink: #1f4abf; --tier-rare-accent: #3b73e8;
--tier-epic-bg: #f1e6fc; --tier-epic-ink: #6420b8; --tier-epic-accent: #9b59e8;
--tier-legendary-bg: #fff4cc; --tier-legendary-ink: #6b4500; --tier-legendary-accent: #d4a015;
/* Prismatic uses a soft gradient — text stays on a near-white backstop strip for legibility */
--tier-prismatic-bg: linear-gradient(135deg, #ffd7f0 0%, #d7e1ff 33%, #d7ffe8 66%, #fff3c8 100%);
--tier-prismatic-ink: #1a1a2e;
--tier-prismatic-accent: #b455e8;
--error-bg: #fdecea;
--error-ink: #a51f1f;
--warning-bg: #fff3e0;
--warning-ink: #8a4a00;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1220;
--surface: #171a2b;
--surface-alt: #1e2236;
--border: #2a2e44;
--border-strong: #3a3f58;
--text: #eaeaf0;
--text-muted: #b0b4c7;
--text-subtle: #8a8fa4;
--primary: #6d82ff;
--primary-hover: #879aff;
--primary-ink: #0f1220;
--focus-ring: 0 0 0 3px rgba(109, 130, 255, 0.45);
--shadow-md: 0 6px 20px rgba(0, 0, 0, 0.55);
--tier-common-bg: #242838; --tier-common-ink: #c5cad8; --tier-common-accent: #4a5163;
--tier-uncommon-bg: #0f2e1b; --tier-uncommon-ink: #9ee3b5; --tier-uncommon-accent: #55c47a;
--tier-rare-bg: #14244a; --tier-rare-ink: #a9c0ff; --tier-rare-accent: #7a96ff;
--tier-epic-bg: #2a1a45; --tier-epic-ink: #cbb0ff; --tier-epic-accent: #a779e8;
--tier-legendary-bg: #332a0a; --tier-legendary-ink: #ffd76a; --tier-legendary-accent: #f0bd3b;
--tier-prismatic-bg: linear-gradient(135deg, #5a2d4d 0%, #2d4d6e 33%, #2d6e4d 66%, #6e5a2d 100%);
--tier-prismatic-ink: #fff;
--tier-prismatic-accent: #e8a0ff;
--error-bg: #3a1717; --error-ink: #ff9b9b;
--warning-bg: #3a2a0d; --warning-ink: #ffc980;
}
}
html { background: var(--bg); color: var(--text); }
body { margin: 0; background: var(--bg); color: var(--text); }
*:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
border-radius: var(--radius-sm);
}
body, .app {
font-family: "Be Vietnam Pro", system-ui, -apple-system, "Segoe UI", sans-serif;
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
color: var(--text);
}
header {
text-align: center;
margin-bottom: 2rem;
}
header h1 {
font-size: 1.8rem;
color: var(--text);
margin-bottom: 0.25rem;
}
.subtitle {
color: var(--text-muted);
font-size: 0.95rem;
}
/* Tabs */
.tabs {
display: flex;
gap: 0;
max-width: 600px;
margin: 0 auto 1.5rem;
border-bottom: 2px solid var(--border);
}
.tab {
flex: 1;
padding: 0.75rem 1rem;
font-size: 1rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
cursor: pointer;
color: var(--text-muted);
transition: color 0.2s, border-color 0.2s;
}
.tab:hover {
color: var(--primary);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
font-weight: 600;
}
/* Custom query */
.custom-query {
max-width: 900px;
margin: 0 auto;
}
.preset-groups {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 1rem;
}
.preset-group {
display: grid;
grid-template-columns: 140px 1fr;
align-items: start;
gap: 0.75rem;
}
.preset-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.preset-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
padding-top: 0.3rem;
}
.preset-btn {
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
background: var(--surface-alt);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.preset-btn:hover:not(:disabled) {
background: var(--border);
border-color: var(--primary);
}
.preset-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.query-form {
margin-bottom: 1rem;
}
.query-form textarea {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.9rem;
font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace;
background: var(--surface);
color: var(--text);
border: 2px solid var(--border);
border-radius: 8px;
outline: none;
resize: vertical;
min-height: 120px;
transition: border-color 0.2s;
tab-size: 2;
}
.query-form textarea:focus {
border-color: var(--primary);
}
.query-actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 0.5rem;
}
.query-actions button {
padding: 0.6rem 1.5rem;
font-size: 0.95rem;
background: var(--primary);
color: var(--primary-ink);
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.query-actions button:hover:not(:disabled) {
background: var(--primary-hover);
}
.query-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.exec-time {
font-size: 0.85rem;
color: var(--text-muted);
}
/* Search form */
.search-form {
max-width: 640px;
margin: 0 auto 1.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.search-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.search-input-row {
display: flex;
gap: 0.5rem;
}
.search-input-wrap {
position: relative;
flex: 1;
display: flex;
align-items: center;
}
.search-input-wrap input {
flex: 1;
width: 100%;
padding: 0.75rem 2.25rem 0.75rem 1rem;
font-size: 1rem;
background: var(--surface);
color: var(--text);
border: 2px solid var(--border);
border-radius: var(--radius);
outline: none;
transition: border-color 0.15s;
}
.search-input-wrap input::-webkit-search-cancel-button { display: none; }
.search-input-wrap input:focus {
border-color: var(--primary);
}
.clear-btn {
position: absolute;
right: 0.4rem;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 50%;
color: var(--text-muted);
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.clear-btn:hover {
background: var(--surface-alt);
color: var(--text);
}
.primary-btn,
.search-form button[type="submit"] {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
background: var(--primary);
color: var(--primary-ink);
border: none;
border-radius: var(--radius);
cursor: pointer;
transition: background 0.15s, transform 0.1s;
min-height: 44px;
}
.primary-btn:hover:not(:disabled) { background: var(--primary-hover); }
.primary-btn:active:not(:disabled) { transform: scale(0.98); }
.primary-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.search-hint {
margin: 0;
font-size: 0.85rem;
color: var(--text-muted);
min-height: 1.2em;
transition: color 0.15s;
}
.search-hint.mode-sbd-short,
.search-hint.mode-name-short {
color: var(--tier-legendary-accent);
}
.example-btn {
display: inline-block;
padding: 0.1rem 0.5rem;
font-size: 0.8rem;
font-family: inherit;
background: var(--surface-alt);
color: var(--text);
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.example-btn:hover {
background: var(--primary);
color: var(--primary-ink);
border-color: var(--primary);
}
/* Loading */
.loading {
text-align: center;
margin: 1rem 0;
color: var(--text-muted);
}
.loading-note {
margin: 0.5rem auto 0;
max-width: 480px;
font-size: 0.85rem;
color: var(--text-subtle);
}
.progress-bar {
width: 300px;
height: 8px;
background: var(--surface-alt);
border-radius: 4px;
margin: 0.5rem auto;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--primary);
border-radius: 4px;
transition: width 0.3s;
}
/* Results table */
.table-wrapper {
overflow-x: auto;
margin-top: 1rem;
}
.result-count {
color: var(--text-muted);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
background: var(--surface);
}
thead th {
background: var(--surface-alt);
color: var(--text);
padding: 0.6rem 0.5rem;
text-align: left;
white-space: nowrap;
position: sticky;
top: 0;
border-bottom: 2px solid var(--border-strong);
}
tbody td {
padding: 0.5rem;
border-bottom: 1px solid var(--border);
color: var(--text);
}
tbody tr:hover { background: var(--surface-alt); }
.name-cell { white-space: nowrap; }
.score-cell {
text-align: center;
font-variant-numeric: tabular-nums;
font-weight: 500;
}
/* Score tier color coding — mirror the detail-tile palette for scanability */
.score-cell.tier-common { color: var(--tier-common-ink); background: var(--tier-common-bg); }
.score-cell.tier-uncommon { color: var(--tier-uncommon-ink); background: var(--tier-uncommon-bg); }
.score-cell.tier-rare { color: var(--tier-rare-ink); background: var(--tier-rare-bg); }
.score-cell.tier-epic { color: var(--tier-epic-ink); background: var(--tier-epic-bg); font-weight: 600; }
.score-cell.tier-legendary { color: var(--tier-legendary-ink); background: var(--tier-legendary-bg); font-weight: 700; }
.score-cell.tier-prismatic { color: var(--tier-prismatic-ink); background: var(--tier-prismatic-bg); font-weight: 700; }
/* Messages */
.no-results,
.error,
.warning {
text-align: center;
padding: 1rem;
border-radius: 8px;
margin: 1rem 0;
}
.no-results {
color: var(--text-muted);
background: var(--surface-alt);
}
.error {
color: var(--error-ink);
background: var(--error-bg);
}
.warning {
color: var(--warning-ink);
background: var(--warning-bg);
font-size: 0.9rem;
}
/* Footer */
footer {
text-align: center;
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
color: var(--text-subtle);
font-size: 0.85rem;
/* The source is a full article URL and some are long enough to overflow a
phone screen, which would scroll the whole page sideways. */
overflow-wrap: anywhere;
}
/* ---- Student detail card ---- */
.detail-card {
max-width: 900px;
margin: 1.5rem auto 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: calc(var(--radius) + 4px);
box-shadow: var(--shadow-md);
padding: 1.75rem;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding-bottom: 1rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--border);
}
.detail-actions {
flex-shrink: 0;
}
.detail-identity h2 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
color: var(--text);
letter-spacing: 0.01em;
}
.detail-meta {
display: flex;
gap: 2rem;
margin: 0;
flex-wrap: wrap;
}
.detail-meta > div {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.detail-meta dt {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-subtle);
}
.detail-meta dd {
margin: 0;
font-size: 1rem;
color: var(--text);
display: flex;
align-items: center;
gap: 0.5rem;
}
.mono {
font-variant-numeric: tabular-nums;
font-family: ui-monospace, "SF Mono", "Cascadia Code", "Fira Code", monospace;
}
.copy-btn {
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
background: var(--surface-alt);
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.copy-btn:hover {
background: var(--primary);
color: var(--primary-ink);
border-color: var(--primary);
}
.section-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--text);
margin: 0 0 0.75rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.section-hint {
font-size: 0.8rem;
font-weight: 400;
color: var(--text-subtle);
}
/* Tier legend — co-locates color/symbol/range/label */
.tier-legend {
list-style: none;
margin: 0 0 0 auto;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
font-weight: 400;
font-size: 0.75rem;
}
.tier-legend-item {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface-alt);
}
.tier-legend-item.tier-common { background: var(--tier-common-bg); color: var(--tier-common-ink); border-color: var(--tier-common-accent); }
.tier-legend-item.tier-uncommon { background: var(--tier-uncommon-bg); color: var(--tier-uncommon-ink); border-color: var(--tier-uncommon-accent); }
.tier-legend-item.tier-rare { background: var(--tier-rare-bg); color: var(--tier-rare-ink); border-color: var(--tier-rare-accent); }
.tier-legend-item.tier-epic { background: var(--tier-epic-bg); color: var(--tier-epic-ink); border-color: var(--tier-epic-accent); }
.tier-legend-item.tier-legendary { background: var(--tier-legendary-bg); color: var(--tier-legendary-ink); border-color: var(--tier-legendary-accent); }
.tier-legend-item.tier-prismatic { background: var(--tier-prismatic-bg); color: var(--tier-prismatic-ink); border-color: var(--tier-prismatic-accent); }
.tier-legend .tier-range {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
/* Subject tile grid */
.score-grid {
list-style: none;
margin: 0 0 1.5rem;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.6rem;
}
.score-tile {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.75rem 0.9rem;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface-alt);
transition: transform 0.1s;
}
.score-tile .score-subject {
font-size: 0.8rem;
color: var(--text-muted);
letter-spacing: 0.02em;
}
.score-tile .score-value {
font-size: 1.6rem;
font-weight: 700;
line-height: 1.1;
color: var(--text);
}
.score-tile .score-tier {
font-size: 0.78rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.3rem;
}
.score-tile .score-symbol {
font-weight: 700;
font-size: 0.9rem;
}
.score-tile.tier-common { background: var(--tier-common-bg); border-color: var(--tier-common-accent); }
.score-tile.tier-common .score-value,
.score-tile.tier-common .score-tier,
.score-tile.tier-common .score-subject { color: var(--tier-common-ink); }
.score-tile.tier-uncommon { background: var(--tier-uncommon-bg); border-color: var(--tier-uncommon-accent); }
.score-tile.tier-uncommon .score-value,
.score-tile.tier-uncommon .score-tier,
.score-tile.tier-uncommon .score-subject { color: var(--tier-uncommon-ink); }
.score-tile.tier-rare { background: var(--tier-rare-bg); border-color: var(--tier-rare-accent); }
.score-tile.tier-rare .score-value,
.score-tile.tier-rare .score-tier,
.score-tile.tier-rare .score-subject { color: var(--tier-rare-ink); }
.score-tile.tier-epic { background: var(--tier-epic-bg); border-color: var(--tier-epic-accent); }
.score-tile.tier-epic .score-value,
.score-tile.tier-epic .score-tier,
.score-tile.tier-epic .score-subject { color: var(--tier-epic-ink); }
.score-tile.tier-legendary { background: var(--tier-legendary-bg); border-color: var(--tier-legendary-accent); }
.score-tile.tier-legendary .score-value,
.score-tile.tier-legendary .score-tier,
.score-tile.tier-legendary .score-subject { color: var(--tier-legendary-ink); }
.score-tile.tier-prismatic { background: var(--tier-prismatic-bg); border-color: var(--tier-prismatic-accent); }
.score-tile.tier-prismatic .score-value,
.score-tile.tier-prismatic .score-tier,
.score-tile.tier-prismatic .score-subject { color: var(--tier-prismatic-ink); }
/* Admission block rows */
.block-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.block-row {
display: grid;
grid-template-columns: 44px 1fr auto auto;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.9rem;
background: var(--surface-alt);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.block-code {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
padding: 0.15rem 0;
background: var(--primary);
color: var(--primary-ink);
border-radius: 6px;
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.02em;
}
.block-label {
color: var(--text-muted);
font-size: 0.9rem;
}
.block-parts {
color: var(--text-subtle);
font-size: 0.85rem;
}
.block-total {
font-size: 1.15rem;
font-weight: 700;
color: var(--text);
min-width: 3.5rem;
text-align: right;
}
/* Responsive */
@media (max-width: 600px) {
header h1 { font-size: 1.3rem; }
.search-input-row { flex-direction: column; }
.search-input-row .primary-btn { width: 100%; }
table { font-size: 0.8rem; }
.preset-group { grid-template-columns: 1fr; gap: 0.35rem; }
.detail-header { flex-direction: column; }
.detail-card { padding: 1.1rem; }
.detail-identity h2 { font-size: 1.25rem; }
.block-row {
grid-template-columns: 36px 1fr auto;
gap: 0.5rem;
}
.block-parts { display: none; }
.score-grid {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* Table cells for the 2016-only identity columns. The table hides them on other
datasets, where every row 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/) */
.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-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;
}
-259
View File
@@ -1,259 +0,0 @@
import { useState, useCallback, useEffect } from "react";
import { useSqlite } from "./hooks/use-sqlite";
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 MAX_RESULTS = 100;
// Strip Vietnamese diacritics: "Nguyễn Bửu Lộc" → "nguyen buu loc"
function toAscii(str) {
return str
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/đ/gi, "d")
.toLowerCase();
}
function isAsciiOnly(str) {
for (let i = 0; i < str.length; i++) {
if (str.charCodeAt(i) > 127) return false;
}
return true;
}
// Sync query to URL (?q=...) without adding history entries
function writeUrlQuery(q) {
const u = new URL(window.location.href);
if (q) u.searchParams.set("q", q);
else u.searchParams.delete("q");
window.history.replaceState({}, "", u);
}
function readUrlQuery() {
return new URL(window.location.href).searchParams.get("q") || "";
}
/** 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");
// Owned here, not in SearchForm, so it can be bound to the URL both ways.
const [query, setQuery] = useState(() => readUrlQuery());
const [totalCount, setTotalCount] = useState(null);
const handleSearch = useCallback(
(q) => {
if (!db) return;
setSearchError(null);
setQuery(q);
writeUrlQuery(q);
try {
let stmt;
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: normaliseExamId(q), $limit: MAX_RESULTS });
} else if (isAsciiOnly(q)) {
stmt = db.prepare(
"SELECT * FROM student WHERE ho_ten_ascii LIKE $q LIMIT $limit",
);
stmt.bind({ $q: `%${toAscii(q)}%`, $limit: MAX_RESULTS });
} else {
const normalized = toAscii(q);
stmt = db.prepare(
`SELECT * FROM student
WHERE ho_ten LIKE $q OR ho_ten_ascii LIKE $qn
LIMIT $limit`,
);
stmt.bind({
$q: `%${q}%`,
$qn: `%${normalized}%`,
$limit: MAX_RESULTS,
});
}
const rows = [];
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
setResults(rows);
} catch (err) {
setSearchError(err.message);
}
},
[db],
);
// Hydrate a ?q= deep link as soon as the database is ready. Keyed on `db`
// alone so it fires at most once per mount and cannot cascade.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (db && query) handleSearch(query);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [db]);
// Candidate count for the footer. One-shot per mount: the count cannot change
// without a new database.
useEffect(() => {
if (!db) return;
const stmt = db.prepare("SELECT COUNT(*) AS c FROM student");
stmt.step();
// eslint-disable-next-line react-hooks/set-state-in-effect
setTotalCount(stmt.getAsObject().c);
stmt.free();
}, [db]);
// Global keyboard shortcuts:
// Ctrl+Enter → submit SQL query (when SQL tab active)
// "/" → focus search box (when search tab active and not already typing)
useEffect(() => {
function handleKeyDown(e) {
if (e.ctrlKey && e.key === "Enter" && activeTab === "sql") {
const form = document.querySelector(".query-form");
if (form) form.requestSubmit();
return;
}
if (
e.key === "/" &&
activeTab === "search" &&
!(e.target instanceof HTMLInputElement) &&
!(e.target instanceof HTMLTextAreaElement)
) {
e.preventDefault();
document.getElementById("search-input")?.focus();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [activeTab]);
const handleClear = useCallback(() => {
setResults(null);
setQuery("");
writeUrlQuery("");
}, []);
// The assembler copies one index.html to every route, so its static <title>
// cannot name a dataset. Set the real one once the route is resolved.
useEffect(() => {
document.title = dataset.title;
}, [dataset.title]);
return (
<div className="app">
<header>
<h1>{dataset.title}</h1>
<p className="subtitle">{dataset.subtitle}</p>
<p className="hub-back">
<a href={import.meta.env.BASE_URL}>← Tất cả các kỳ thi</a>
</p>
</header>
<main>
{loading && (
<div className="loading">
<p>
Đang tải cơ sở dữ liệu ~{dataset.dbSizeMb} MB
{progress > 0 ? ` · ${progress}%` : ""}
</p>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${progress}%` }} />
</div>
<p className="loading-note">
Lần đầu có thể mất 10-30 giây. Sau đó trình duyệt sẽ lưu cache
và mở nhanh hơn.
</p>
</div>
)}
{error && <p className="error">Lỗi: {error}</p>}
<div className="tabs">
<button
className={`tab ${activeTab === "search" ? "active" : ""}`}
onClick={() => setActiveTab("search")}
>
Tra cứu
</button>
<button
className={`tab ${activeTab === "sql" ? "active" : ""}`}
onClick={() => setActiveTab("sql")}
>
Truy vấn SQL
</button>
</div>
{activeTab === "search" && (
<>
<SearchForm
value={query}
onSearch={handleSearch}
onClear={handleClear}
disabled={loading || !!error}
examples={dataset.examples}
/>
{searchError && (
<p className="error">Lỗi truy vấn: {searchError}</p>
)}
{results && results.length === 1 ? (
<StudentDetail student={results[0]} />
) : (
<ScoreTable results={results} />
)}
{results && results.length >= MAX_RESULTS && (
<p className="warning">
Hiển thị tối đa {MAX_RESULTS} kết quả. Vui lòng tìm kiếm cụ thể hơn.
</p>
)}
</>
)}
{activeTab === "sql" && (
<CustomQuery
db={db}
disabled={loading || !!error}
presets={dataset.presets}
/>
)}
</main>
<footer>
<p>
Nguồn:{" "}
<a href={dataset.source} target="_blank" rel="noopener noreferrer">
{dataset.source}
</a>
{totalCount !== null && ` · ${totalCount.toLocaleString("vi-VN")} thí sinh`}
{" · Dữ liệu chỉ mang tính tham khảo"}
</p>
</footer>
</div>
);
}
function App() {
// Resolved once at mount: every route is a full page load on GitHub Pages,
// so there is no in-app navigation to react to.
const [dataset] = useState(() => resolveRoute());
return dataset ? <DatasetApp dataset={dataset} /> : <Hub />;
}
export default App;
+221
View File
@@ -0,0 +1,221 @@
@import "tailwindcss";
/*
* Design tokens. Everything the utilities reference lives here, so dark mode is
* one block of variable overrides rather than a `dark:` variant on every class.
*/
@theme {
--color-bg: #f7f7fb;
--color-surface: #ffffff;
--color-surface-alt: #f0f0f5;
--color-line: #e2e2eb;
--color-line-strong: #d0d0dc;
--color-ink: #1a1a2e;
--color-ink-muted: #5c6072;
--color-ink-subtle: #868a99;
--color-primary: #3b4fe4;
--color-primary-hover: #2d3ec9;
--color-primary-ink: #ffffff;
--color-error-bg: #fdecea;
--color-error-ink: #a51f1f;
--color-warning-bg: #fff3e0;
--color-warning-ink: #8a4a00;
--font-sans: "Be Vietnam Pro", system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Fira Code", monospace;
--shadow-card: 0 6px 20px rgba(15, 20, 40, 0.08);
}
/*
* Score tier palette — a TFT rarity ladder (white → green → blue → purple →
* gold → prismatic), each pair picked for AA contrast (≥4.5:1) of ink on bg.
* Kept outside @theme because tier classes are applied from a runtime value
* (`tier-{key}`), which no utility generator can see.
*/
:root {
--tier-common-bg: #f3f4f6;
--tier-common-ink: #4a5163;
--tier-common-accent: #b5b9c5;
--tier-uncommon-bg: #e6f6ec;
--tier-uncommon-ink: #196a36;
--tier-uncommon-accent: #2e9e55;
--tier-rare-bg: #e8f0fe;
--tier-rare-ink: #1f4abf;
--tier-rare-accent: #3b73e8;
--tier-epic-bg: #f1e6fc;
--tier-epic-ink: #6420b8;
--tier-epic-accent: #9b59e8;
--tier-legendary-bg: #fff4cc;
--tier-legendary-ink: #6b4500;
--tier-legendary-accent: #d4a015;
--tier-prismatic-bg: linear-gradient(135deg, #ffd7f0 0%, #d7e1ff 33%, #d7ffe8 66%, #fff3c8 100%);
--tier-prismatic-ink: #1a1a2e;
--tier-prismatic-accent: #b455e8;
--focus-ring: 0 0 0 3px rgba(59, 79, 228, 0.35);
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f1220;
--color-surface: #171a2b;
--color-surface-alt: #1e2236;
--color-line: #2a2e44;
--color-line-strong: #3a3f58;
--color-ink: #eaeaf0;
--color-ink-muted: #b0b4c7;
--color-ink-subtle: #8a8fa4;
--color-primary: #6d82ff;
--color-primary-hover: #879aff;
--color-primary-ink: #0f1220;
--color-error-bg: #3a1717;
--color-error-ink: #ff9b9b;
--color-warning-bg: #3a2a0d;
--color-warning-ink: #ffc980;
--shadow-card: 0 6px 20px rgba(0, 0, 0, 0.55);
--focus-ring: 0 0 0 3px rgba(109, 130, 255, 0.45);
--tier-common-bg: #242838;
--tier-common-ink: #c5cad8;
--tier-common-accent: #4a5163;
--tier-uncommon-bg: #0f2e1b;
--tier-uncommon-ink: #9ee3b5;
--tier-uncommon-accent: #55c47a;
--tier-rare-bg: #14244a;
--tier-rare-ink: #a9c0ff;
--tier-rare-accent: #7a96ff;
--tier-epic-bg: #2a1a45;
--tier-epic-ink: #cbb0ff;
--tier-epic-accent: #a779e8;
--tier-legendary-bg: #332a0a;
--tier-legendary-ink: #ffd76a;
--tier-legendary-accent: #f0bd3b;
--tier-prismatic-bg: linear-gradient(135deg, #5a2d4d 0%, #2d4d6e 33%, #2d6e4d 66%, #6e5a2d 100%);
--tier-prismatic-ink: #ffffff;
--tier-prismatic-accent: #e8a0ff;
}
}
@layer base {
html {
background: var(--color-bg);
color: var(--color-ink);
}
body {
background: var(--color-bg);
color: var(--color-ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
*:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
border-radius: 6px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
}
@layer components {
/*
* Each tier class only sets the trio of variables; `tier-surface` paints with
* them. One rule per tier instead of one per tier per element type.
*/
.tier-common {
--tier-bg: var(--tier-common-bg);
--tier-ink: var(--tier-common-ink);
--tier-accent: var(--tier-common-accent);
}
.tier-uncommon {
--tier-bg: var(--tier-uncommon-bg);
--tier-ink: var(--tier-uncommon-ink);
--tier-accent: var(--tier-uncommon-accent);
}
.tier-rare {
--tier-bg: var(--tier-rare-bg);
--tier-ink: var(--tier-rare-ink);
--tier-accent: var(--tier-rare-accent);
}
.tier-epic {
--tier-bg: var(--tier-epic-bg);
--tier-ink: var(--tier-epic-ink);
--tier-accent: var(--tier-epic-accent);
}
.tier-legendary {
--tier-bg: var(--tier-legendary-bg);
--tier-ink: var(--tier-legendary-ink);
--tier-accent: var(--tier-legendary-accent);
}
.tier-prismatic {
--tier-bg: var(--tier-prismatic-bg);
--tier-ink: var(--tier-prismatic-ink);
--tier-accent: var(--tier-prismatic-accent);
}
.tier-surface {
background: var(--tier-bg);
color: var(--tier-ink);
border-color: var(--tier-accent);
}
/* Tier weight rises with rarity, so a top score reads as heavier at a glance. */
.tier-epic.tier-surface {
font-weight: 600;
}
.tier-legendary.tier-surface,
.tier-prismatic.tier-surface {
font-weight: 700;
}
.btn-primary {
@apply min-h-11 cursor-pointer rounded-[10px] border-0 px-6 py-3 font-semibold
text-[color:var(--color-primary-ink)] transition-colors;
background: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.btn-primary:active:not(:disabled) {
transform: scale(0.98);
}
.btn-primary:disabled {
@apply cursor-not-allowed opacity-50;
}
/* Pill-shaped secondary action: examples, presets, copy. */
.btn-chip {
@apply cursor-pointer rounded-full border border-line bg-surface-alt px-2 py-0.5
text-xs text-ink transition-colors;
}
.btn-chip:hover:not(:disabled) {
background: var(--color-primary);
color: var(--color-primary-ink);
border-color: var(--color-primary);
}
.btn-chip:disabled {
@apply cursor-not-allowed opacity-50;
}
.field {
@apply w-full rounded-[10px] border-2 border-line bg-surface px-4 py-3 text-ink
outline-none transition-colors;
}
.field:focus {
border-color: var(--color-primary);
}
.notice {
@apply my-4 rounded-lg p-4 text-center;
}
}
+6 -11
View File
@@ -1,23 +1,18 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600;700&display=swap"
/>
<!--
One index.html is copied to every route, so this title has to be the one
that suits them all; the app replaces it with the dataset's own title once
it knows which route it is on.
-->
<title>Tra cứu điểm thi THPT Quốc gia</title>
<!-- Each route prerenders its own <title> through <svelte:head>. -->
%sveltekit.head%
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
-170
View File
@@ -1,170 +0,0 @@
import { useState, useCallback, useEffect } from "react";
const MAX_ROWS = 1000;
export function CustomQuery({ db, disabled, presets = [] }) {
// By convention every preset list ends with a "Hệ thống" group whose first
// query dumps the table schema. See lib/sql-presets.js.
const schemaPreset = presets[presets.length - 1]?.queries[0];
const [sql, setSql] = useState("");
const [columns, setColumns] = useState([]);
const [rows, setRows] = useState([]);
const [queryError, setQueryError] = useState(null);
const [execTime, setExecTime] = useState(null);
const executeQuery = useCallback(
(queryStr) => {
if (!db) return;
setQueryError(null);
setColumns([]);
setRows([]);
setExecTime(null);
const trimmed = queryStr.trim();
if (!trimmed) return;
// Read-only statements only. The database is a per-browser copy, so this
// guards the user's own session against a typo, not the server.
const upper = trimmed.toUpperCase();
const allowed = ["SELECT", "PRAGMA", "EXPLAIN", "WITH"];
if (!allowed.some((kw) => upper.startsWith(kw))) {
setQueryError(
"Chỉ hỗ trợ truy vấn đọc (SELECT, PRAGMA, EXPLAIN, WITH).",
);
return;
}
// Cap an unbounded SELECT: the tables run to hundreds of thousands of
// rows and rendering them all would lock the tab.
let finalSql = trimmed;
if (
upper.startsWith("SELECT") &&
!upper.includes("LIMIT") &&
!upper.includes("PRAGMA")
) {
finalSql = `${trimmed.replace(/;$/, "")} LIMIT ${MAX_ROWS}`;
}
try {
const start = performance.now();
const stmt = db.prepare(finalSql);
const colNames = stmt.getColumnNames();
const resultRows = [];
let count = 0;
while (stmt.step() && count < MAX_ROWS) {
resultRows.push(stmt.get());
count++;
}
stmt.free();
const elapsed = performance.now() - start;
setColumns(colNames);
setRows(resultRows);
setExecTime(elapsed.toFixed(1));
} catch (err) {
setQueryError(err.message);
}
},
[db],
);
function handleSubmit(e) {
e.preventDefault();
executeQuery(sql);
}
function handlePreset(presetSql) {
setSql(presetSql);
executeQuery(presetSql);
}
// Auto-run the schema preset the first time the tab is opened so the user
// sees the student columns instead of a blank textarea.
useEffect(() => {
if (db && schemaPreset && columns.length === 0 && !sql) {
// eslint-disable-next-line react-hooks/set-state-in-effect
executeQuery(schemaPreset.sql);
setSql(schemaPreset.sql);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [db]);
return (
<div className="custom-query">
<div className="preset-groups">
{presets.map((group) => (
<div key={group.category} className="preset-group">
<span className="preset-label">{group.category}</span>
<div className="preset-list">
{group.queries.map((p, i) => (
<button
key={i}
className="preset-btn"
onClick={() => handlePreset(p.sql)}
disabled={disabled}
>
{p.label}
</button>
))}
</div>
</div>
))}
</div>
<form onSubmit={handleSubmit} className="query-form">
<textarea
value={sql}
onChange={(e) => setSql(e.target.value)}
placeholder={`Nhập truy vấn SQL...\nVí dụ: SELECT * FROM student WHERE toan >= 9 LIMIT 10`}
disabled={disabled}
rows={5}
spellCheck={false}
/>
<div className="query-actions">
<button type="submit" disabled={disabled || !sql.trim()}>
Thực thi (Ctrl+Enter)
</button>
{execTime !== null && (
<span className="exec-time">
{rows.length} kết quả · {execTime}ms
</span>
)}
</div>
</form>
{queryError && <p className="error">Lỗi: {queryError}</p>}
{columns.length > 0 && (
<div className="table-wrapper">
<table>
<thead>
<tr>
{columns.map((col, i) => (
<th key={i}>{col}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td key={ci} className="score-cell">
{cell === null ? "NULL" : String(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
{rows.length >= MAX_ROWS && (
<p className="warning">
Hiển thị tối đa {MAX_ROWS} kết quả. Thêm LIMIT để giới hạn.
</p>
)}
</div>
)}
</div>
);
}
-35
View File
@@ -1,35 +0,0 @@
import { DATASETS, pathOf } from "../datasets";
/** The /thptqg/ landing route. No database is fetched here. */
export function Hub() {
const base = import.meta.env.BASE_URL;
return (
<div className="hub">
<header>
<h1>Tra cứu điểm thi THPT Quốc gia</h1>
<p className="subtitle">
Tra cứu điểm thi tốt nghiệp THPT Quốc gia theo số báo danh — truy vấn
SQL chạy hoàn toàn trên trình duyệt (sql.js).
</p>
</header>
<main>
<ul className="hub-list" role="list">
{DATASETS.map((d) => (
<li key={d.id}>
<a className="hub-link" href={pathOf(d, base)}>
<span className="hub-label">{d.label}</span>
<span className="hub-blurb">{d.blurb}</span>
</a>
</li>
))}
</ul>
</main>
<footer>
<p>Dữ liệu chỉ mang tính tham khảo.</p>
</footer>
</div>
);
}
-74
View File
@@ -1,74 +0,0 @@
import { scoreTier } from "../lib/admission-blocks";
import { SUBJECTS, IDENTITY_COLUMNS, hasAnyValue } from "../lib/subjects";
function formatScore(val) {
if (val === null || val === undefined) return "—";
return Number(val).toFixed(2);
}
export function ScoreTable({ results }) {
if (!results) return null;
if (results.length === 0) {
return <p className="no-results">Không tìm thấy kết quả.</p>;
}
// Drop columns that are NULL for every row in the result set. That is what
// lets one table serve both exam years with no dataset conditional here: each
// year's unused columns simply drop out.
const visibleIdentity = IDENTITY_COLUMNS.filter((col) =>
hasAnyValue(results, col.key),
);
const visibleSubjects = SUBJECTS.filter((col) => hasAnyValue(results, col.key));
return (
<div className="table-wrapper">
<p className="result-count">Tìm thấy {results.length} kết quả</p>
<table>
<thead>
<tr>
<th>SBD</th>
<th>Họ tên</th>
<th>Ngày sinh</th>
{visibleIdentity.map((col) => (
<th key={col.key}>{col.label}</th>
))}
{visibleSubjects.map((col) => (
<th key={col.key}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{results.map((row) => (
<tr key={row.so_bao_danh}>
<td className="sbd-cell">{row.so_bao_danh}</td>
<td className="name-cell">{row.ho_ten}</td>
<td>{row.ngay_sinh || "—"}</td>
{visibleIdentity.map((col) => (
<td
key={col.key}
className={col.key === "ten_cum_thi" ? "cumthi-cell" : undefined}
title={col.key === "ten_cum_thi" ? row[col.key] || "" : undefined}
>
{row[col.key] || "—"}
</td>
))}
{visibleSubjects.map((col) => {
const tier = scoreTier(row[col.key]);
const cls = tier ? `score-cell tier-${tier.key}` : "score-cell";
return (
<td
key={col.key}
className={cls}
title={tier ? tier.label : undefined}
>
{formatScore(row[col.key])}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
-120
View File
@@ -1,120 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { detectMode } from "../lib/query-mode";
const DEBOUNCE_MS = 300;
export function SearchForm({
value = "",
onSearch,
onClear,
disabled,
examples = [],
}) {
const [query, setQuery] = useState(value);
const inputRef = useRef(null);
const timerRef = useRef(null);
const { mode, hint } = detectMode(query);
const canSearch = mode === "sbd" || mode === "name";
// Mirror the parent's query into local state. The parent owns the value so
// it can bind it to the URL; this only runs when that external value changes
// (deep-link hydration, or the clear button), never in response to typing.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setQuery(value);
}, [value]);
// Debounced live search
useEffect(() => {
clearTimeout(timerRef.current);
if (!canSearch) {
if (mode === "empty") onClear?.();
return;
}
// Skip if the query already matches external value (prevents loop when
// value flows in from URL hydration)
if (query.trim() === value.trim() && query.trim() !== "") return;
timerRef.current = setTimeout(() => onSearch(query.trim()), DEBOUNCE_MS);
return () => clearTimeout(timerRef.current);
}, [query, canSearch, mode, onSearch, onClear, value]);
function handleSubmit(e) {
e.preventDefault();
if (canSearch) {
clearTimeout(timerRef.current);
onSearch(query.trim());
}
}
function handleClear() {
setQuery("");
inputRef.current?.focus();
}
return (
<form onSubmit={handleSubmit} className="search-form" role="search">
<label htmlFor="search-input" className="search-label">
Tra cứu điểm
</label>
<div className="search-input-row">
<div className={`search-input-wrap mode-${mode}`}>
<input
id="search-input"
ref={inputRef}
type="search"
inputMode="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Ví dụ: 47006585 hoặc Nguyen Van An"
disabled={disabled}
autoFocus
autoComplete="off"
spellCheck={false}
aria-describedby="search-hint"
/>
{query && (
<button
type="button"
className="clear-btn"
onClick={handleClear}
aria-label="Xoá tìm kiếm"
tabIndex={-1}
>
×
</button>
)}
</div>
<button
type="submit"
className="primary-btn"
disabled={disabled || !canSearch}
>
Tra cứu
</button>
</div>
<p id="search-hint" className={`search-hint mode-${mode}`} aria-live="polite">
{hint}
{mode === "empty" && (
<>
{" · VD: "}
{examples.map((ex, i) => (
<span key={ex}>
<button
type="button"
className="example-btn"
onClick={() => {
setQuery(ex);
inputRef.current?.focus();
}}
>
{ex}
</button>
{i < examples.length - 1 ? " hoặc " : ""}
</span>
))}
</>
)}
</p>
</form>
);
}
-173
View File
@@ -1,173 +0,0 @@
import { useState } from "react";
import { computeBlocks, scoreTier } from "../lib/admission-blocks";
import { SUBJECTS } from "../lib/subjects";
function fmt(n) {
return n === null || n === undefined ? "—" : Number(n).toFixed(2);
}
// Visible legend, so a user does not have to hover tiles to decode the colors.
// The ranges must stay in step with scoreTier() in lib/admission-blocks.js.
const TIER_LEGEND = [
{ key: "common", symbol: "·", range: "≤ 1", label: "Điểm liệt" },
{ key: "uncommon", symbol: "○", range: "< 5", label: "Chưa đạt" },
{ key: "rare", symbol: "◆", range: "5-6.5", label: "Trung bình" },
{ key: "epic", symbol: "★", range: "6.5-8", label: "Khá" },
{ key: "legendary", symbol: "✦", range: "8-9", label: "Giỏi" },
{ key: "prismatic", symbol: "❖", range: "9-10", label: "Xuất sắc" },
];
export function StudentDetail({ student }) {
const [copied, setCopied] = useState(null); // null | 'sbd' | 'share' | 'url'
const blocks = computeBlocks(student);
const subjects = SUBJECTS
.filter((s) => student[s.key] !== null && student[s.key] !== undefined)
.map((s) => ({ key: s.key, label: s.label, score: student[s.key] }));
function flash(kind) {
setCopied(kind);
setTimeout(() => setCopied(null), 1500);
}
function copySbd() {
navigator.clipboard.writeText(student.so_bao_danh).then(() => flash("sbd"));
}
// Formatted summary suitable for pasting into Zalo / Messenger:
// Nguyễn Văn A (SBD 49008235)
// Toán 8.50 · Ngữ văn 7.25 · ...
// Khối A: 22.75
// Xem tại: https://...?q=49008235
function shareSummary() {
const lines = [];
lines.push(`${student.ho_ten} (SBD ${student.so_bao_danh})`);
lines.push(subjects.map((s) => `${s.label} ${fmt(s.score)}`).join(" · "));
if (blocks.length > 0) {
lines.push(
blocks.slice(0, 3).map((b) => `Khối ${b.code} ${b.total.toFixed(2)}`).join(" · "),
);
}
lines.push(
`${window.location.origin}${window.location.pathname}?q=${student.so_bao_danh}`,
);
const text = lines.join("\n");
if (navigator.share) {
navigator.share({ text }).catch(() => {});
return;
}
navigator.clipboard.writeText(text).then(() => flash("share"));
}
return (
<article className="detail-card" aria-label="Chi tiết điểm thí sinh">
<header className="detail-header">
<div className="detail-identity">
<h2>{student.ho_ten}</h2>
<dl className="detail-meta">
<div>
<dt>SBD</dt>
<dd>
<span className="mono">{student.so_bao_danh}</span>
<button
type="button"
className="copy-btn"
onClick={copySbd}
aria-label="Sao chép số báo danh"
>
{copied === "sbd" ? "✓ Đã chép" : "Chép"}
</button>
</dd>
</div>
{student.ngay_sinh && (
<div>
<dt>Ngày sinh</dt>
<dd className="mono">{student.ngay_sinh}</dd>
</div>
)}
{/* Only the 2016 dataset carries these; NULL elsewhere. */}
{student.ten_cum_thi && (
<div>
<dt>Cụm thi</dt>
<dd>{student.ten_cum_thi}</dd>
</div>
)}
{student.gioi_tinh && (
<div>
<dt>Giới tính</dt>
<dd>{student.gioi_tinh}</dd>
</div>
)}
</dl>
</div>
<div className="detail-actions">
<button
type="button"
className="primary-btn"
onClick={shareSummary}
aria-label="Chia sẻ bảng điểm"
>
{copied === "share" ? "✓ Đã chép" : "Chia sẻ"}
</button>
</div>
</header>
<section aria-labelledby="subjects-heading">
<h3 id="subjects-heading" className="section-title">
Điểm môn thi
<ul className="tier-legend" role="list" aria-label="Chú thích mức điểm">
{TIER_LEGEND.map((t) => (
<li key={t.key} className={`tier-legend-item tier-${t.key}`}>
<span aria-hidden="true">{t.symbol}</span>
<span className="tier-range">{t.range}</span>
<span className="tier-name">{t.label}</span>
</li>
))}
</ul>
</h3>
<ul className="score-grid" role="list">
{subjects.map((s) => {
const tier = scoreTier(s.score);
return (
<li
key={s.key}
className={`score-tile tier-${tier.key}`}
aria-label={`${s.label}: ${fmt(s.score)}, ${tier.label}`}
>
<span className="score-subject">{s.label}</span>
<span className="score-value mono">{fmt(s.score)}</span>
<span className="score-tier">
<span className="score-symbol" aria-hidden="true">{tier.symbol}</span>
{tier.label}
</span>
</li>
);
})}
</ul>
</section>
{blocks.length > 0 && (
<section aria-labelledby="blocks-heading">
<h3 id="blocks-heading" className="section-title">
Tổng điểm khối thi
<span className="section-hint">
(Chỉ tính những khối thí sinh có đủ 3 môn)
</span>
</h3>
<ul className="block-list" role="list">
{blocks.map((b) => (
<li key={b.code} className="block-row">
<span className="block-code">{b.code}</span>
<span className="block-label">{b.label}</span>
<span className="block-parts mono">
{b.parts.map((p) => fmt(p.score)).join(" + ")}
</span>
<span className="block-total mono">{b.total.toFixed(2)}</span>
</li>
))}
</ul>
</section>
)}
</article>
);
}
-86
View File
@@ -1,86 +0,0 @@
import { useState, useEffect, useRef } from "react";
import initSqlJs from "sql.js";
// The sql.js engine itself, fetched from the upstream CDN rather than bundled.
// It is a hard runtime dependency: if this URL is unreachable, initSqlJs()
// rejects and no dataset can be opened at all, whatever the database fetch does.
const SQL_WASM_URL =
"https://sql.js.org/dist/sql-wasm.wasm";
/**
* Load a SQLite database from a URL into sql.js. A `.gz` URL is decompressed in
* the browser. Returns { db, loading, error, progress }.
*/
export function useSqlite(dbUrl) {
const [db, setDb] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [progress, setProgress] = useState(0);
const dbRef = useRef(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const SQL = await initSqlJs({ locateFile: () => SQL_WASM_URL });
const response = await fetch(dbUrl);
if (!response.ok) throw new Error(`Failed to fetch database: ${response.status}`);
const contentLength = +response.headers.get("Content-Length") || 0;
const reader = response.body.getReader();
const chunks = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
if (contentLength > 0) {
setProgress(Math.round((received / contentLength) * 100));
}
}
if (cancelled) return;
const blob = new Blob(chunks);
let arrayBuffer;
if (dbUrl.endsWith(".gz")) {
const ds = new DecompressionStream("gzip");
const decompressed = blob.stream().pipeThrough(ds);
const decompressedBlob = await new Response(decompressed).blob();
arrayBuffer = await decompressedBlob.arrayBuffer();
} else {
arrayBuffer = await blob.arrayBuffer();
}
if (cancelled) return;
const database = new SQL.Database(new Uint8Array(arrayBuffer));
dbRef.current = database;
setDb(database);
setLoading(false);
} catch (err) {
if (!cancelled) {
setError(err.message);
setLoading(false);
}
}
}
load();
return () => {
cancelled = true;
if (dbRef.current) {
dbRef.current.close();
dbRef.current = null;
}
};
}, [dbUrl]);
return { db, loading, error, progress };
}
-13
View File
@@ -1,13 +0,0 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
background: #fafafa;
color: #333;
}
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { ADMISSION_BLOCKS, computeBlocks, scoreTier } from "./admission-blocks";
import type { Student } from "./types";
/** A row with every column NULL, so a test only states the scores it needs. */
function student(scores: Partial<Student>): Student {
return {
so_bao_danh: "49008235",
ho_ten: "Nguyễn Văn A",
ho_ten_ascii: "nguyen van a",
ngay_sinh: null,
ten_cum_thi: null,
gioi_tinh: null,
toan: null,
ngu_van: null,
vat_ly: null,
hoa_hoc: null,
sinh_hoc: null,
khtn: null,
lich_su: null,
dia_ly: null,
gdcd: null,
khxh: null,
tieng_anh: null,
tieng_phap: null,
tieng_nga: null,
tieng_duc: null,
tieng_nhat: null,
tieng_trung: null,
...scores,
};
}
describe("scoreTier", () => {
it("returns nothing for a subject the candidate did not sit", () => {
expect(scoreTier(null)).toBeNull();
expect(scoreTier(undefined)).toBeNull();
});
it("treats điểm liệt as an inclusive bound and the rest as exclusive", () => {
// ≤ 1 is an automatic fail whatever the other scores are.
expect(scoreTier(0)?.key).toBe("common");
expect(scoreTier(1)?.key).toBe("common");
expect(scoreTier(1.01)?.key).toBe("uncommon");
expect(scoreTier(4.99)?.key).toBe("uncommon");
expect(scoreTier(5)?.key).toBe("rare");
expect(scoreTier(6.5)?.key).toBe("epic");
expect(scoreTier(8)?.key).toBe("legendary");
expect(scoreTier(9)?.key).toBe("prismatic");
expect(scoreTier(10)?.key).toBe("prismatic");
});
it("carries a symbol as well as a colour, so meaning is never colour-only", () => {
for (const score of [0, 3, 6, 7, 8.5, 9.5]) {
expect(scoreTier(score)?.symbol).toBeTruthy();
}
});
});
describe("computeBlocks", () => {
it("skips a block when any of its three subjects is missing", () => {
const blocks = computeBlocks(student({ toan: 8, vat_ly: 7 }));
expect(blocks.find((b) => b.code === "A00")).toBeUndefined();
});
it("sums exactly three subjects and sorts by total desc", () => {
const blocks = computeBlocks(student({ toan: 8, vat_ly: 7, hoa_hoc: 6, tieng_anh: 9 }));
const a00 = blocks.find((b) => b.code === "A00");
const a01 = blocks.find((b) => b.code === "A01");
expect(a00?.total).toBe(21);
expect(a01?.total).toBe(24);
expect(a00?.parts).toHaveLength(3);
expect(blocks[0].total).toBeGreaterThanOrEqual(blocks[blocks.length - 1].total);
});
it("self-excludes 2017-only blocks on a 2016 row, with no per-year branching", () => {
// GDCD did not exist in 2016, so every block needing it drops out.
const blocks = computeBlocks(student({ toan: 8, lich_su: 7, dia_ly: 6 }));
expect(blocks.map((b) => b.code)).toContain("A07");
expect(blocks.map((b) => b.code)).not.toContain("A08");
});
it("keeps every block a 3-subject sum", () => {
for (const block of ADMISSION_BLOCKS) {
expect(block.subjects).toHaveLength(3);
}
});
});
@@ -1,3 +1,15 @@
import type { Student, SubjectKey } from "./types";
export type AdmissionBlock = { code: string; subjects: SubjectKey[]; label: string };
export type ComputedBlock = {
code: string;
label: string;
total: number;
parts: { key: SubjectKey; score: number }[];
};
export type TierKey = "common" | "uncommon" | "rare" | "epic" | "legendary" | "prismatic";
export type Tier = { key: TierKey; symbol: string; label: string };
// Vietnamese university admission-block ("khối thi") definitions, per Circular
// 03/2017/TT-BGDĐT. Each block is the sum of exactly 3 subject scores.
//
@@ -5,7 +17,7 @@
// skips any block with a missing subject score, so blocks needing GDCD
// self-exclude on 2016 rows, and blocks needing a language the candidate did
// not sit self-exclude row by row.
export const ADMISSION_BLOCKS = [
export const ADMISSION_BLOCKS: AdmissionBlock[] = [
{ code: "A00", subjects: ["toan", "vat_ly", "hoa_hoc"], label: "Toán + Lý + Hóa" },
{ code: "A01", subjects: ["toan", "vat_ly", "tieng_anh"], label: "Toán + Lý + Anh" },
{ code: "A02", subjects: ["toan", "vat_ly", "sinh_hoc"], label: "Toán + Lý + Sinh" },
@@ -64,13 +76,14 @@ export const ADMISSION_BLOCKS = [
// Returns { code, label, total, parts:[{key,score}] } for every block where
// the student has scores for all three subjects, sorted by total desc.
export function computeBlocks(student) {
const out = [];
export function computeBlocks(student: Student): ComputedBlock[] {
const out: ComputedBlock[] = [];
for (const b of ADMISSION_BLOCKS) {
const parts = b.subjects.map((k) => ({ key: k, score: student[k] }));
if (parts.some((p) => p.score === null || p.score === undefined)) continue;
const total = parts.reduce((s, p) => s + p.score, 0);
out.push({ code: b.code, label: b.label, total, parts });
const whole = parts as { key: SubjectKey; score: number }[];
const total = whole.reduce((s, p) => s + p.score, 0);
out.push({ code: b.code, label: b.label, total, parts: whole });
}
return out.sort((a, b) => b.total - a.total);
}
@@ -80,7 +93,7 @@ export function computeBlocks(student) {
// whatever the other scores are, so that boundary is inclusive while the rest
// are exclusive upper bounds. Every tier carries a unicode symbol as well as a
// color, so the meaning is never conveyed by color alone.
export function scoreTier(score) {
export function scoreTier(score: number | null | undefined): Tier | null {
if (score === null || score === undefined) return null;
if (score <= 1) return { key: "common", symbol: "·", label: "Điểm liệt" };
if (score < 5) return { key: "uncommon", symbol: "○", label: "Chưa đạt" };
+174
View File
@@ -0,0 +1,174 @@
<script lang="ts">
import type { Database } from "sql.js";
import type { PresetGroup } from "$lib/types";
const MAX_ROWS = 1000;
const PLACEHOLDER = "Nhập truy vấn SQL...\nVí dụ: SELECT * FROM student WHERE toan >= 9 LIMIT 10";
let {
db,
disabled = false,
presets = [],
}: { db: Database | null; disabled?: boolean; presets?: PresetGroup[] } = $props();
// By convention every preset list ends with a "Hệ thống" group whose first
// query dumps the table schema. See lib/sql-presets.ts.
const schemaPreset = $derived(presets[presets.length - 1]?.queries[0]);
let sql = $state("");
let columns = $state<string[]>([]);
let rows = $state<unknown[][]>([]);
let queryError = $state<string | null>(null);
let execTime = $state<string | null>(null);
let autoRan = false;
function execute(queryStr: string) {
if (!db) return;
queryError = null;
columns = [];
rows = [];
execTime = null;
const trimmed = queryStr.trim();
if (!trimmed) return;
// Read-only statements only. The database is a per-browser copy, so this
// guards the user's own session against a typo, not the server.
const upper = trimmed.toUpperCase();
if (!["SELECT", "PRAGMA", "EXPLAIN", "WITH"].some((kw) => upper.startsWith(kw))) {
queryError = "Chỉ hỗ trợ truy vấn đọc (SELECT, PRAGMA, EXPLAIN, WITH).";
return;
}
// Cap an unbounded SELECT: the tables run to hundreds of thousands of rows
// and rendering them all would lock the tab.
let finalSql = trimmed;
if (upper.startsWith("SELECT") && !upper.includes("LIMIT") && !upper.includes("PRAGMA")) {
finalSql = `${trimmed.replace(/;$/, "")} LIMIT ${MAX_ROWS}`;
}
try {
const start = performance.now();
const stmt = db.prepare(finalSql);
const colNames = stmt.getColumnNames();
const resultRows: unknown[][] = [];
let count = 0;
while (stmt.step() && count < MAX_ROWS) {
resultRows.push(stmt.get());
count++;
}
stmt.free();
execTime = (performance.now() - start).toFixed(1);
columns = colNames;
rows = resultRows;
} catch (err) {
queryError = err instanceof Error ? err.message : String(err);
}
}
function runPreset(presetSql: string) {
sql = presetSql;
execute(presetSql);
}
// Show the student columns the first time the tab opens, rather than a blank
// textarea. Once only, however the database changes underneath.
$effect(() => {
if (!db || autoRan || !schemaPreset) return;
autoRan = true;
runPreset(schemaPreset.sql);
});
</script>
<div class="mx-auto max-w-[900px]">
<div class="mb-4 flex flex-col gap-2.5">
{#each presets as group (group.category)}
<div class="grid grid-cols-[140px_1fr] items-start gap-3 max-sm:grid-cols-1 max-sm:gap-1.5">
<span
class="pt-1 text-xs font-semibold tracking-wider whitespace-nowrap text-ink-muted uppercase"
>
{group.category}
</span>
<div class="flex flex-wrap gap-1.5">
{#each group.queries as preset (preset.label)}
<button
type="button"
class="btn-chip rounded-md"
onclick={() => runPreset(preset.sql)}
{disabled}
>
{preset.label}
</button>
{/each}
</div>
</div>
{/each}
</div>
<form
class="query-form mb-4"
onsubmit={(e) => {
e.preventDefault();
execute(sql);
}}
>
<textarea
bind:value={sql}
class="field min-h-30 resize-y rounded-lg font-mono text-sm [tab-size:2]"
placeholder={PLACEHOLDER}
{disabled}
rows={5}
spellcheck="false"
></textarea>
<div class="mt-2 flex items-center gap-4">
<button type="submit" class="btn-primary" disabled={disabled || !sql.trim()}>
Thực thi (Ctrl+Enter)
</button>
{#if execTime !== null}
<span class="text-sm text-ink-muted">{rows.length} kết quả · {execTime}ms</span>
{/if}
</div>
</form>
{#if queryError}
<p class="notice bg-error-bg text-error-ink">Lỗi: {queryError}</p>
{/if}
{#if columns.length > 0}
<div class="mt-4 overflow-x-auto">
<table class="w-full border-collapse bg-surface text-sm max-sm:text-xs">
<thead>
<tr
class="[&>th]:sticky [&>th]:top-0 [&>th]:border-b-2 [&>th]:border-line-strong
[&>th]:bg-surface-alt [&>th]:px-2 [&>th]:py-2.5 [&>th]:text-left
[&>th]:whitespace-nowrap"
>
{#each columns as col, i (i)}
<th>{col}</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as row, ri (ri)}
<tr
class="hover:bg-surface-alt [&>td]:border-b [&>td]:border-line [&>td]:px-2 [&>td]:py-2"
>
{#each row as cell, ci (ci)}
<td class="text-center font-medium tabular-nums">
{cell === null ? "NULL" : String(cell)}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{#if rows.length >= MAX_ROWS}
<p class="notice bg-warning-bg text-sm text-warning-ink">
Hiển thị tối đa {MAX_ROWS} kết quả. Thêm LIMIT để giới hạn.
</p>
{/if}
</div>
{/if}
</div>
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
import { scoreTier } from "$lib/admission-blocks";
import { IDENTITY_COLUMNS, SUBJECTS, hasAnyValue } from "$lib/subjects";
import type { Student } from "$lib/types";
let { results }: { results: Student[] | null } = $props();
function formatScore(val: number | null): string {
return val === null || val === undefined ? "—" : Number(val).toFixed(2);
}
// Drop columns that are NULL for every row in the result set. That is what
// lets one table serve both exam years with no dataset conditional here: each
// year's unused columns simply drop out.
const visibleIdentity = $derived(
results ? IDENTITY_COLUMNS.filter((col) => hasAnyValue(results, col.key)) : [],
);
const visibleSubjects = $derived(
results ? SUBJECTS.filter((col) => hasAnyValue(results, col.key)) : [],
);
</script>
{#if results && results.length === 0}
<p class="notice bg-surface-alt text-ink-muted">Không tìm thấy kết quả.</p>
{:else if results}
<div class="mt-4 overflow-x-auto">
<p class="mb-2 text-sm text-ink-muted">Tìm thấy {results.length} kết quả</p>
<table class="w-full border-collapse bg-surface text-sm max-sm:text-xs">
<thead>
<tr
class="[&>th]:sticky [&>th]:top-0 [&>th]:border-b-2 [&>th]:border-line-strong
[&>th]:bg-surface-alt [&>th]:px-2 [&>th]:py-2.5 [&>th]:text-left
[&>th]:whitespace-nowrap"
>
<th>SBD</th>
<th>Họ tên</th>
<th>Ngày sinh</th>
{#each visibleIdentity as col (col.key)}
<th>{col.label}</th>
{/each}
{#each visibleSubjects as col (col.key)}
<th>{col.label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each results as row (row.so_bao_danh)}
<tr class="hover:bg-surface-alt [&>td]:border-b [&>td]:border-line [&>td]:px-2 [&>td]:py-2">
<td class="font-mono text-xs whitespace-nowrap text-ink-muted">{row.so_bao_danh}</td>
<td class="whitespace-nowrap">{row.ho_ten}</td>
<td>{row.ngay_sinh || "—"}</td>
{#each visibleIdentity as col (col.key)}
<td
class={col.key === "ten_cum_thi"
? "max-w-[220px] truncate text-xs text-ink-muted"
: undefined}
title={col.key === "ten_cum_thi" ? (row[col.key] ?? "") : undefined}
>
{row[col.key] || "—"}
</td>
{/each}
{#each visibleSubjects as col (col.key)}
{@const tier = scoreTier(row[col.key])}
<td
class="text-center font-medium tabular-nums {tier
? `tier-surface tier-${tier.key}`
: ''}"
title={tier?.label}
>
{formatScore(row[col.key])}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import { detectMode } from "$lib/query-mode";
const DEBOUNCE_MS = 300;
let {
value = "",
onSearch,
onClear,
disabled = false,
examples = [],
}: {
value?: string;
onSearch: (q: string) => void;
onClear?: () => void;
disabled?: boolean;
examples?: string[];
} = $props();
// A writable derived: it follows the owner's value — which is bound to the
// URL, so a deep link or the clear button flows in — and typing overrides it
// until the next external change.
let query = $derived(value);
let input = $state<HTMLInputElement | null>(null);
let timer: ReturnType<typeof setTimeout> | undefined;
const detected = $derived(detectMode(query));
const canSearch = $derived(detected.mode === "sbd" || detected.mode === "name");
// Debounced live search.
$effect(() => {
const q = query;
const mode = detected.mode;
clearTimeout(timer);
if (mode !== "sbd" && mode !== "name") {
if (mode === "empty") onClear?.();
return;
}
// Already showing results for this query — happens when the value flows in
// from URL hydration.
if (q.trim() === value.trim() && q.trim() !== "") return;
timer = setTimeout(() => onSearch(q.trim()), DEBOUNCE_MS);
return () => clearTimeout(timer);
});
function submit(event: SubmitEvent) {
event.preventDefault();
if (!canSearch) return;
clearTimeout(timer);
onSearch(query.trim());
}
function clear() {
query = "";
input?.focus();
}
</script>
<form onsubmit={submit} class="mx-auto mb-6 flex max-w-[640px] flex-col gap-1.5" role="search">
<label for="search-input" class="text-sm font-semibold tracking-wider text-ink-muted uppercase">
Tra cứu điểm
</label>
<div class="flex gap-2 max-sm:flex-col">
<div class="relative flex flex-1 items-center">
<input
id="search-input"
bind:this={input}
bind:value={query}
type="search"
inputmode="search"
class="field pr-9 [&::-webkit-search-cancel-button]:hidden"
placeholder="Ví dụ: 47006585 hoặc Nguyen Van An"
{disabled}
autocomplete="off"
spellcheck="false"
aria-describedby="search-hint"
/>
{#if query}
<button
type="button"
class="absolute right-1.5 flex h-7 w-7 cursor-pointer items-center justify-center
rounded-full border-0 bg-transparent text-xl leading-none text-ink-muted
transition-colors hover:bg-surface-alt hover:text-ink"
onclick={clear}
aria-label="Xoá tìm kiếm"
tabindex="-1"
>
×
</button>
{/if}
</div>
<button type="submit" class="btn-primary max-sm:w-full" disabled={disabled || !canSearch}>
Tra cứu
</button>
</div>
<p
id="search-hint"
class="m-0 min-h-[1.2em] text-sm transition-colors"
class:text-ink-muted={detected.mode !== "sbd-short" && detected.mode !== "name-short"}
class:text-[color:var(--tier-legendary-accent)]={detected.mode === "sbd-short" ||
detected.mode === "name-short"}
aria-live="polite"
>
{detected.hint}{#if detected.mode === "empty"} · VD: {#each examples as example, i (example)}<button
type="button"
class="btn-chip"
onclick={() => {
query = example;
input?.focus();
}}>{example}</button
>{i < examples.length - 1 ? " hoặc " : ""}{/each}{/if}
</p>
</form>
@@ -0,0 +1,203 @@
<script lang="ts">
import { computeBlocks, scoreTier } from "$lib/admission-blocks";
import { SUBJECTS } from "$lib/subjects";
import type { Student, SubjectKey } from "$lib/types";
let { student }: { student: Student } = $props();
// Visible legend, so a user does not have to hover tiles to decode the
// colours. The ranges must stay in step with scoreTier() in
// lib/admission-blocks.ts.
const TIER_LEGEND = [
{ key: "common", symbol: "·", range: "≤ 1", label: "Điểm liệt" },
{ key: "uncommon", symbol: "○", range: "< 5", label: "Chưa đạt" },
{ key: "rare", symbol: "◆", range: "5-6.5", label: "Trung bình" },
{ key: "epic", symbol: "★", range: "6.5-8", label: "Khá" },
{ key: "legendary", symbol: "✦", range: "8-9", label: "Giỏi" },
{ key: "prismatic", symbol: "❖", range: "9-10", label: "Xuất sắc" },
];
let copied = $state<null | "sbd" | "share">(null);
const blocks = $derived(computeBlocks(student));
const subjects = $derived(
SUBJECTS.filter((s) => student[s.key] !== null && student[s.key] !== undefined).map((s) => ({
key: s.key as SubjectKey,
label: s.label,
score: student[s.key] as number,
})),
);
function fmt(n: number | null | undefined): string {
return n === null || n === undefined ? "—" : Number(n).toFixed(2);
}
function flash(kind: "sbd" | "share") {
copied = kind;
setTimeout(() => (copied = null), 1500);
}
function copySbd() {
navigator.clipboard.writeText(student.so_bao_danh).then(() => flash("sbd"));
}
// Formatted summary suitable for pasting into Zalo / Messenger:
// Nguyễn Văn A (SBD 49008235)
// Toán 8.50 · Ngữ văn 7.25 · ...
// Khối A00 22.75
// https://...?q=49008235
function shareSummary() {
const lines = [
`${student.ho_ten} (SBD ${student.so_bao_danh})`,
subjects.map((s) => `${s.label} ${fmt(s.score)}`).join(" · "),
];
if (blocks.length > 0) {
lines.push(
blocks
.slice(0, 3)
.map((b) => `Khối ${b.code} ${b.total.toFixed(2)}`)
.join(" · "),
);
}
lines.push(`${window.location.origin}${window.location.pathname}?q=${student.so_bao_danh}`);
const text = lines.join("\n");
if (navigator.share) {
navigator.share({ text }).catch(() => {});
return;
}
navigator.clipboard.writeText(text).then(() => flash("share"));
}
</script>
<article
class="mx-auto mt-6 max-w-[900px] rounded-[14px] border border-line bg-surface p-7 shadow-card
max-sm:p-4"
aria-label="Chi tiết điểm thí sinh"
>
<header
class="mb-5 flex items-start justify-between gap-4 border-b border-line pb-4 max-sm:flex-col"
>
<div>
<h2 class="mb-2 text-2xl tracking-wide max-sm:text-xl">{student.ho_ten}</h2>
<dl class="m-0 flex flex-wrap gap-8">
<div class="flex flex-col gap-0.5">
<dt class="text-xs tracking-widest text-ink-subtle uppercase">SBD</dt>
<dd class="m-0 flex items-center gap-2">
<span class="font-mono tabular-nums">{student.so_bao_danh}</span>
<button
type="button"
class="btn-chip text-ink-muted"
onclick={copySbd}
aria-label="Sao chép số báo danh"
>
{copied === "sbd" ? "✓ Đã chép" : "Chép"}
</button>
</dd>
</div>
{#if student.ngay_sinh}
<div class="flex flex-col gap-0.5">
<dt class="text-xs tracking-widest text-ink-subtle uppercase">Ngày sinh</dt>
<dd class="m-0 font-mono tabular-nums">{student.ngay_sinh}</dd>
</div>
{/if}
<!-- Only the 2016 dataset carries these; NULL elsewhere. -->
{#if student.ten_cum_thi}
<div class="flex flex-col gap-0.5">
<dt class="text-xs tracking-widest text-ink-subtle uppercase">Cụm thi</dt>
<dd class="m-0">{student.ten_cum_thi}</dd>
</div>
{/if}
{#if student.gioi_tinh}
<div class="flex flex-col gap-0.5">
<dt class="text-xs tracking-widest text-ink-subtle uppercase">Giới tính</dt>
<dd class="m-0">{student.gioi_tinh}</dd>
</div>
{/if}
</dl>
</div>
<div class="shrink-0">
<button type="button" class="btn-primary" onclick={shareSummary} aria-label="Chia sẻ bảng điểm">
{copied === "share" ? "✓ Đã chép" : "Chia sẻ"}
</button>
</div>
</header>
<section aria-labelledby="subjects-heading">
<h3
id="subjects-heading"
class="mb-3 flex flex-wrap items-center justify-between gap-2 text-[0.95rem] font-semibold"
>
Điểm môn thi
<ul class="m-0 ml-auto flex flex-wrap gap-1.5 p-0 text-xs font-normal" aria-label="Chú thích mức điểm">
{#each TIER_LEGEND as t (t.key)}
<li
class="tier-surface tier-{t.key} inline-flex items-center gap-1 rounded-full border
px-2 py-0.5"
>
<span aria-hidden="true">{t.symbol}</span>
<span class="font-semibold tabular-nums">{t.range}</span>
<span>{t.label}</span>
</li>
{/each}
</ul>
</h3>
<ul
class="m-0 mb-6 grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-2.5 p-0
max-sm:grid-cols-[repeat(auto-fill,minmax(130px,1fr))]"
>
{#each subjects as s (s.key)}
{@const tier = scoreTier(s.score)}
<li
class="tier-surface tier-{tier?.key} flex flex-col gap-1 rounded-[10px] border px-3.5 py-3"
aria-label="{s.label}: {fmt(s.score)}, {tier?.label}"
>
<span class="text-xs tracking-wide">{s.label}</span>
<span class="font-mono text-2xl leading-tight font-bold tabular-nums">{fmt(s.score)}</span>
<span class="flex items-center gap-1 text-[0.78rem]">
<span class="text-sm font-bold" aria-hidden="true">{tier?.symbol}</span>
{tier?.label}
</span>
</li>
{/each}
</ul>
</section>
{#if blocks.length > 0}
<section aria-labelledby="blocks-heading">
<h3
id="blocks-heading"
class="mb-3 flex flex-wrap items-center justify-between gap-2 text-[0.95rem] font-semibold"
>
Tổng điểm khối thi
<span class="text-xs font-normal text-ink-subtle">
(Chỉ tính những khối thí sinh có đủ 3 môn)
</span>
</h3>
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
{#each blocks as b (b.code)}
<li
class="grid grid-cols-[44px_1fr_auto_auto] items-center gap-3 rounded-[10px] border
border-line bg-surface-alt px-3.5 py-2.5
max-sm:grid-cols-[36px_1fr_auto] max-sm:gap-2"
>
<span
class="inline-flex w-9 items-center justify-center rounded-md bg-primary px-0 py-0.5
text-sm font-bold tracking-wide text-primary-ink"
>
{b.code}
</span>
<span class="text-sm text-ink-muted">{b.label}</span>
<span class="font-mono text-xs text-ink-subtle tabular-nums max-sm:hidden">
{b.parts.map((p) => fmt(p.score)).join(" + ")}
</span>
<span class="min-w-14 text-right font-mono text-lg font-bold tabular-nums">
{b.total.toFixed(2)}
</span>
</li>
{/each}
</ul>
</section>
{/if}
</article>
+21 -10
View File
@@ -10,9 +10,10 @@
* dataset cannot be misconfigured into pointing at the wrong database.
*/
import registry from "../../datasets.json";
import registry from "../../../datasets.json";
import { PRESETS_2016, PRESETS_2017 } from "./lib/sql-presets.js";
import { PRESETS_2016, PRESETS_2017 } from "./sql-presets";
import type { Dataset, PresetGroup } from "./types";
const SUBTITLE = "Dữ liệu thí sinh toàn quốc · Hỗ trợ truy vấn SQL tùy chỉnh";
@@ -24,7 +25,9 @@ const SUBTITLE = "Dữ liệu thí sinh toàn quốc · Hỗ trợ truy vấn SQ
* (crawler/internal/sources/source_<id>.go); it is duplicated here because a Go
* module and a Vite app cannot share a constant. Keep the two in step.
*/
const CONTENT = {
type Content = Omit<Dataset, "id" | "dbSizeMb" | "blurb"> & { presets: PresetGroup[] };
const CONTENT: Record<string, Content> = {
2016: {
label: "Kỳ thi 2016",
title: "Tra cứu điểm thi THPT Quốc gia 2016",
@@ -61,7 +64,7 @@ for (const id of Object.keys(CONTENT)) {
}
}
export const DATASETS = registry.datasets.map((d) => ({
export const DATASETS: Dataset[] = registry.datasets.map((d) => ({
id: d.id,
dbSizeMb: d.dbSizeMb,
// Derived from expectedRows so the count the hub shows is the same number the
@@ -70,12 +73,20 @@ export const DATASETS = registry.datasets.map((d) => ({
...CONTENT[d.id],
}));
/** Site path for a dataset, e.g. pathOf(d, "/thptqg/") → "/thptqg/2017/". */
export function pathOf(dataset, base) {
return `${base}${dataset.id}/`;
/** Look up a dataset by the route segment, or undefined for an unknown id. */
export function datasetById(id: string): Dataset | undefined {
return DATASETS.find((d) => d.id === id);
}
/** Gzipped database URL, e.g. dbOf(d, "/thptqg/") → "/thptqg/db/2017.db.gz". */
export function dbOf(dataset, base) {
return `${base}db/${dataset.id}.db.gz`;
/**
* Site path for a dataset. `base` is SvelteKit's, which carries no trailing
* slash: pathOf(d, "/thptqg") → "/thptqg/2017/".
*/
export function pathOf(dataset: Dataset, base: string): string {
return `${base}/${dataset.id}/`;
}
/** Gzipped database URL, e.g. dbOf(d, "/thptqg") → "/thptqg/db/2017.db.gz". */
export function dbOf(dataset: Dataset, base: string): string {
return `${base}/db/${dataset.id}.db.gz`;
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { MIN_NAME_CHARS, MIN_SBD_DIGITS, detectMode, isExamId, normaliseExamId } from "./query-mode";
describe("isExamId", () => {
it("accepts every exam-number shape in the corpus", () => {
expect(isExamId("49008235")).toBe(true); // 2017, 8 digits
expect(isExamId("017006021")).toBe(true); // 2016, 9 digits with leading zero
expect(isExamId("BAL000001")).toBe(true); // 2016, cluster prefix
expect(isExamId("TKG002747")).toBe(true);
expect(isExamId("bal000001")).toBe(true); // lower case is normalised later
});
it("rejects names and prefixes longer than a cluster code", () => {
expect(isExamId("Nguyen Van A")).toBe(false);
expect(isExamId("NGUYEN01")).toBe(false); // 6 letters, not a cluster code
expect(isExamId("")).toBe(false);
expect(isExamId("2016-01")).toBe(false);
});
});
describe("normaliseExamId", () => {
it("upper-cases the cluster prefix and trims", () => {
expect(normaliseExamId(" bal000001 ")).toBe("BAL000001");
expect(normaliseExamId("49008235")).toBe("49008235");
});
});
describe("detectMode", () => {
it("classifies an empty box", () => {
expect(detectMode(" ").mode).toBe("empty");
});
it("classifies exam numbers, short ones separately", () => {
expect(detectMode("49008235").mode).toBe("sbd");
expect(detectMode("1".repeat(MIN_SBD_DIGITS)).mode).toBe("sbd");
expect(detectMode("1".repeat(MIN_SBD_DIGITS - 1)).mode).toBe("sbd-short");
});
it("classifies names, short ones separately", () => {
expect(detectMode("Nguyễn").mode).toBe("name");
expect(detectMode("a".repeat(MIN_NAME_CHARS)).mode).toBe("name");
expect(detectMode("a".repeat(MIN_NAME_CHARS - 1)).mode).toBe("name-short");
});
it("always returns a hint to show under the field", () => {
for (const q of ["", "49008235", "1", "Nguyễn Bửu Lộc", "a"]) {
expect(detectMode(q).hint.length).toBeGreaterThan(0);
}
});
});
@@ -1,6 +1,7 @@
/**
* Classify a search box query as an exam ID (số báo danh) or a name. Shared by
* App.jsx and search-form.jsx so the two cannot disagree on what a query is.
* the dataset page and search-form.svelte so the two cannot disagree on what a
* query is.
*
* SBD formats across both exam years:
*
@@ -17,8 +18,11 @@ const SBD_PATTERN = /^[A-Za-z]{0,4}\d+$/;
export const MIN_SBD_DIGITS = 3;
export const MIN_NAME_CHARS = 2;
/** How a query was classified, and the hint shown beneath the field. */
export type QueryMode = "empty" | "sbd" | "sbd-short" | "name" | "name-short";
/** True when the query looks like an exam ID rather than a name. */
export function isExamId(query) {
export function isExamId(query: string): boolean {
return SBD_PATTERN.test(query.trim());
}
@@ -26,20 +30,19 @@ export function isExamId(query) {
* Normalise an exam ID for lookup. Letter prefixes are stored upper-case, so a
* user typing "bal000001" still matches. No-op for all-digit IDs.
*/
export function normaliseExamId(query) {
export function normaliseExamId(query: string): string {
return query.trim().toUpperCase();
}
function digitCount(str) {
function digitCount(str: string): number {
return (str.match(/\d/g) ?? []).length;
}
/**
* Decide what the user is searching for, and what hint to show beneath the
* field. Returns `{ mode, hint }` where mode is one of:
* empty | sbd | sbd-short | name | name-short.
* field.
*/
export function detectMode(raw) {
export function detectMode(raw: string): { mode: QueryMode; hint: string } {
const q = raw.trim();
if (!q) return { mode: "empty", hint: "Gõ SBD (số báo danh) hoặc họ tên để tìm" };
@@ -1,3 +1,5 @@
import type { PresetGroup } from "./types";
/**
* SQL presets shown in the "Truy vấn SQL" tab, per dataset.
*
@@ -11,7 +13,7 @@
* before the user writes anything.
*/
export const PRESETS_2017 = [
export const PRESETS_2017: PresetGroup[] = [
{
category: "Xếp hạng môn",
queries: [
@@ -179,7 +181,7 @@ FROM student`,
],
},
];
export const PRESETS_2016 = [
export const PRESETS_2016: PresetGroup[] = [
{
category: "Xếp hạng môn",
queries: [
+84
View File
@@ -0,0 +1,84 @@
import initSqlJs, { type Database } from "sql.js";
// The sql.js engine itself, fetched from the upstream CDN rather than bundled.
// It is a hard runtime dependency: if this URL is unreachable, initSqlJs()
// rejects and no dataset can be opened at all, whatever the database fetch does.
const SQL_WASM_URL = "https://sql.js.org/dist/sql-wasm.wasm";
/**
* A SQLite database loaded from a URL into sql.js, with reactive load state.
*
* The whole file is downloaded and decompressed before the first query: a `.gz`
* URL is inflated in the browser. Nothing streams — sql.js needs the complete
* image in memory.
*/
export class SqliteSource {
db = $state<Database | null>(null);
loading = $state(true);
error = $state<string | null>(null);
progress = $state(0);
#cancelled = false;
constructor(url: string) {
void this.#load(url);
}
async #load(url: string) {
try {
const SQL = await initSqlJs({ locateFile: () => SQL_WASM_URL });
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch database: ${response.status}`);
const contentLength = Number(response.headers.get("Content-Length")) || 0;
const reader = response.body!.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
if (contentLength > 0) {
this.progress = Math.round((received / contentLength) * 100);
}
}
if (this.#cancelled) return;
const blob = new Blob(chunks as BlobPart[]);
const bytes = url.endsWith(".gz")
? await new Response(blob.stream().pipeThrough(new DecompressionStream("gzip"))).arrayBuffer()
: await blob.arrayBuffer();
if (this.#cancelled) return;
this.db = new SQL.Database(new Uint8Array(bytes));
this.loading = false;
} catch (err) {
if (this.#cancelled) return;
this.error = err instanceof Error ? err.message : String(err);
this.loading = false;
}
}
/** Release the database. Call from the owning component's teardown. */
close() {
this.#cancelled = true;
this.db?.close();
this.db = null;
}
}
/** Run a statement and return its rows as objects. */
export function queryRows<T>(db: Database, sql: string, params?: Record<string, unknown>): T[] {
const stmt = db.prepare(sql);
try {
if (params) stmt.bind(params as never);
const rows: T[] = [];
while (stmt.step()) rows.push(stmt.getAsObject() as T);
return rows;
} finally {
stmt.free();
}
}
@@ -1,14 +1,16 @@
import type { IdentityKey, Student, SubjectKey } from "./types";
/**
* The 16 subject columns of the canonical schema, in display order. Mirrors
* `ScoreFields` in parser/internal/schema/schema.go, and is the single list both
* score-table.jsx and student-detail.jsx render from.
* score-table.svelte and student-detail.svelte render from.
*
* Nothing here is dataset-specific. Columns an exam year has no data for are
* NULL on every row, and the UI hides all-NULL columns, so one list serves both
* years without branching.
*/
export const SUBJECTS = [
export const SUBJECTS: { key: SubjectKey; label: string }[] = [
{ key: "toan", label: "Toán" },
{ key: "ngu_van", label: "Ngữ văn" },
{ key: "vat_ly", label: "Vật lí" },
@@ -32,12 +34,12 @@ export const SUBJECTS = [
* populates these; the same all-NULL filter that hides unused subjects hides
* them elsewhere, so no per-dataset conditional is needed.
*/
export const IDENTITY_COLUMNS = [
export const IDENTITY_COLUMNS: { key: IdentityKey; label: string }[] = [
{ key: "ten_cum_thi", label: "Cụm thi" },
{ key: "gioi_tinh", label: "GT" },
];
/** True when at least one row carries a value for `key`. */
export function hasAnyValue(rows, key) {
export function hasAnyValue(rows: Student[], key: SubjectKey | IdentityKey): boolean {
return rows.some((row) => row[key] !== null && row[key] !== undefined);
}
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { isAsciiOnly, toAscii } from "./to-ascii";
/*
* These pairs are the contract with the Go parser: `ho_ten_ascii` is written by
* ToAscii in parser/internal/transform/transform.go, and searched by toAscii
* here. The two implementations must agree character for character or a name
* search silently misses rows. The cases below are the ones
* parser/internal/transform/transform_test.go pins on the Go side.
*/
const GO_PARITY: [input: string, want: string][] = [
["Nguyễn Bửu Lộc", "nguyen buu loc"],
["NGUYỄN THỊ HOA", "nguyen thi hoa"],
["Trần Thị Phước An", "tran thi phuoc an"],
["Đặng Văn Đức", "dang van duc"],
["ĐINH TIÊN HOÀNG", "dinh tien hoang"],
["Lê Thị Thúy ái", "le thi thuy ai"],
["Hồ Diệu á", "ho dieu a"],
["", ""],
];
describe("toAscii", () => {
it.each(GO_PARITY)("folds %j to %j", (input, want) => {
expect(toAscii(input)).toBe(want);
});
it("strips only U+0300..U+036F, not every combining mark", () => {
// U+0654 (Arabic hamza above) is a combining mark outside the dropped
// range, so it survives. This is the same pair the Go side pins in
// TestToAsciiUsesLiteralRangeNotUnicodeMn; widening either implementation
// to "every combining mark" breaks it.
expect(toAscii("a\u0654b")).toBe("a\u0654b");
expect(toAscii("\u00e1b")).toBe("ab");
});
it("handles đ regardless of case and of NFD decomposition", () => {
expect(toAscii("Đ")).toBe("d");
expect(toAscii("đ")).toBe("d");
});
});
describe("isAsciiOnly", () => {
it("separates plain queries from accented ones", () => {
expect(isAsciiOnly("nguyen van a")).toBe(true);
expect(isAsciiOnly("47006585")).toBe(true);
expect(isAsciiOnly("Nguyễn")).toBe(false);
});
});
+29
View File
@@ -0,0 +1,29 @@
/**
* Vietnamese → ASCII fold, used for accent-insensitive search.
*
* This MUST produce the same string as `ToAscii` in
* parser/internal/transform/transform.go, which is what wrote the
* `ho_ten_ascii` column being searched. The four steps are the contract:
*
* 1. NFD decompose
* 2. drop combining marks in U+0300..U+036F — a literal range, not a category
* 3. đ/Đ → d, which NFD does not decompose
* 4. lowercase
*
* to-ascii.test.ts pins the pairs both sides have to agree on.
*/
export function toAscii(str: string): string {
return str
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/đ/gi, "d")
.toLowerCase();
}
/** True when every character is ASCII, i.e. the query carries no diacritics. */
export function isAsciiOnly(str: string): boolean {
for (let i = 0; i < str.length; i++) {
if (str.charCodeAt(i) > 127) return false;
}
return true;
}
+51
View File
@@ -0,0 +1,51 @@
/**
* The shapes the app shares. `Student` mirrors the 22-column table in
* parser/internal/schema/schema.go — every column is nullable except the three
* the schema declares NOT NULL, so a typo in a column name fails the build
* instead of rendering blank.
*/
export type SubjectKey =
| "toan"
| "ngu_van"
| "vat_ly"
| "hoa_hoc"
| "sinh_hoc"
| "khtn"
| "lich_su"
| "dia_ly"
| "gdcd"
| "khxh"
| "tieng_anh"
| "tieng_phap"
| "tieng_nga"
| "tieng_duc"
| "tieng_nhat"
| "tieng_trung";
export type IdentityKey = "ten_cum_thi" | "gioi_tinh";
export type Student = {
so_bao_danh: string;
ho_ten: string;
ho_ten_ascii: string;
ngay_sinh: string | null;
} & { [K in IdentityKey]: string | null } & { [K in SubjectKey]: number | null };
/** A dataset as the interface needs it: registry facts plus presentation. */
export type Dataset = {
id: string;
dbSizeMb: number;
blurb: string;
label: string;
title: string;
subtitle: string;
source: string;
examples: string[];
presets: PresetGroup[];
};
export type PresetGroup = {
category: string;
queries: { label: string; sql: string }[];
};
-10
View File
@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
-29
View File
@@ -1,29 +0,0 @@
/**
* Path → dataset resolution.
*
* URLs are flat, one segment per dataset, and the segment IS the dataset id:
*
* /thptqg/ → null (hub)
* /thptqg/2016/ → 2016
* /thptqg/2017/ → 2017
*
* Keeping them flat makes this an exact match on a single segment; nested
* routes would need longest-prefix matching to disambiguate. Unknown paths fall
* through to the hub.
*/
import { DATASETS } from "./datasets";
const BASE = import.meta.env.BASE_URL;
/** Strip the site base and any trailing slash, leaving a bare route segment. */
function toSegment(pathname) {
const path = pathname.startsWith(BASE) ? pathname.slice(BASE.length) : pathname;
return path.replace(/^\/+/, "").replace(/\/+$/, "");
}
/** Resolve the current location to a dataset, or null for the hub. */
export function resolveRoute(pathname = window.location.pathname) {
const segment = toSegment(pathname);
return DATASETS.find((d) => d.id === segment) ?? null;
}
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
import "../app.css";
let { children } = $props();
</script>
{@render children()}
+5
View File
@@ -0,0 +1,5 @@
// Every route is a static file: no server runs, and the databases are fetched
// from the browser. Trailing slashes make each page a directory index, which is
// what lets GitHub Pages serve /thptqg/2016/ without a rewrite.
export const prerender = true;
export const trailingSlash = "always";
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { DATASETS } from "$lib/datasets";
import type { Dataset } from "$lib/types";
// Trailing slash on purpose: each page is prerendered as <id>/index.html, and
// hitting it without the slash costs a GitHub Pages directory redirect.
// resolve() still supplies the base path.
function hrefOf(dataset: Dataset): string {
return `${resolve("/[dataset]", { dataset: dataset.id })}/`;
}
</script>
<svelte:head>
<title>Tra cứu điểm thi THPT Quốc gia</title>
<meta
name="description"
content="Tra cứu điểm thi tốt nghiệp THPT Quốc gia theo số báo danh hoặc họ tên, chạy hoàn toàn trên trình duyệt."
/>
</svelte:head>
<div class="mx-auto my-12 max-w-2xl px-4 leading-relaxed">
<header>
<h1 class="mb-2 text-2xl font-semibold">Tra cứu điểm thi THPT Quốc gia</h1>
<p class="text-[0.95rem] text-ink-muted">
Tra cứu điểm thi tốt nghiệp THPT Quốc gia theo số báo danh — truy vấn SQL chạy hoàn toàn trên
trình duyệt (sql.js).
</p>
</header>
<main>
<ul class="m-0 my-6 grid list-none gap-3 p-0">
{#each DATASETS as dataset (dataset.id)}
<li>
<!-- hrefOf() is resolve() plus the trailing slash; see above. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={hrefOf(dataset)}
class="flex flex-wrap items-baseline gap-x-3 gap-y-1 rounded-[10px] border border-line
bg-surface px-4.5 py-3.5 text-inherit no-underline hover:border-line-strong
focus-visible:border-line-strong"
>
<span class="font-semibold">{dataset.label}</span>
<span class="text-sm text-ink-muted">{dataset.blurb}</span>
</a>
</li>
{/each}
</ul>
</main>
<footer class="mt-12 border-t border-line pt-4 text-center text-sm text-ink-subtle">
<p>Dữ liệu chỉ mang tính tham khảo.</p>
</footer>
</div>
+230
View File
@@ -0,0 +1,230 @@
<script lang="ts">
import { browser } from "$app/environment";
import { replaceState } from "$app/navigation";
import { base, resolve } from "$app/paths";
import { page } from "$app/state";
import CustomQuery from "$lib/components/custom-query.svelte";
import ScoreTable from "$lib/components/score-table.svelte";
import SearchForm from "$lib/components/search-form.svelte";
import StudentDetail from "$lib/components/student-detail.svelte";
import { dbOf } from "$lib/datasets";
import { isExamId, normaliseExamId } from "$lib/query-mode";
import { SqliteSource, queryRows } from "$lib/sqlite.svelte";
import { isAsciiOnly, toAscii } from "$lib/to-ascii";
import type { Student } from "$lib/types";
const MAX_RESULTS = 100;
let { data } = $props();
const dataset = $derived(data.dataset);
let source = $state<SqliteSource | null>(null);
let results = $state<Student[] | null>(null);
let searchError = $state<string | null>(null);
let activeTab = $state<"search" | "sql">("search");
let totalCount = $state<number | null>(null);
// Owned here, not in SearchForm, so it can be bound to the URL both ways.
// The query string is unreadable while prerendering — there is no request —
// so a deep link is picked up on the client only.
let query = $state(browser ? (page.url.searchParams.get("q") ?? "") : "");
const db = $derived(source?.db ?? null);
const loading = $derived(source?.loading ?? true);
const loadError = $derived(source?.error ?? null);
const progress = $derived(source?.progress ?? 0);
const busy = $derived(loading || !!loadError);
// The database is per dataset, and only ever fetched in the browser: $effect
// does not run while prerendering.
$effect(() => {
const opened = new SqliteSource(dbOf(dataset, base));
source = opened;
return () => {
opened.close();
source = null;
results = null;
totalCount = null;
};
});
// Candidate count for the footer. One-shot per database: it cannot change
// without a new one.
$effect(() => {
if (!db) return;
const [row] = queryRows<{ c: number }>(db, "SELECT COUNT(*) AS c FROM student");
totalCount = row?.c ?? null;
});
// Hydrate a ?q= deep link as soon as the database is ready.
let hydrated = false;
$effect(() => {
if (!db || hydrated) return;
hydrated = true;
if (query) search(query);
});
// Sync the query to ?q= without adding a history entry, so back still leaves
// the page and a copied URL still reproduces the search.
function writeUrlQuery(q: string) {
const route = resolve("/[dataset]", { dataset: dataset.id });
// The target IS resolve()'d; the lint rule cannot see through the template
// literal that appends the query string.
// eslint-disable-next-line svelte/no-navigation-without-resolve
replaceState(q ? `${route}?q=${encodeURIComponent(q)}` : route, page.state);
}
function search(q: string) {
if (!db) return;
searchError = null;
query = q;
writeUrlQuery(q);
try {
if (isExamId(q)) {
// Letter-prefixed 2016 IDs are stored upper-case; digits are unaffected.
results = queryRows<Student>(
db,
"SELECT * FROM student WHERE so_bao_danh = $q LIMIT $limit",
{ $q: normaliseExamId(q), $limit: MAX_RESULTS },
);
} else if (isAsciiOnly(q)) {
results = queryRows<Student>(
db,
"SELECT * FROM student WHERE ho_ten_ascii LIKE $q LIMIT $limit",
{ $q: `%${toAscii(q)}%`, $limit: MAX_RESULTS },
);
} else {
results = queryRows<Student>(
db,
`SELECT * FROM student
WHERE ho_ten LIKE $q OR ho_ten_ascii LIKE $qn
LIMIT $limit`,
{ $q: `%${q}%`, $qn: `%${toAscii(q)}%`, $limit: MAX_RESULTS },
);
}
} catch (err) {
searchError = err instanceof Error ? err.message : String(err);
}
}
function clearSearch() {
results = null;
query = "";
writeUrlQuery("");
}
// Global shortcuts: Ctrl+Enter submits the SQL query, "/" focuses the search
// box unless the user is already typing somewhere.
function onKeydown(event: KeyboardEvent) {
if (event.ctrlKey && event.key === "Enter" && activeTab === "sql") {
document.querySelector<HTMLFormElement>(".query-form")?.requestSubmit();
return;
}
if (
event.key === "/" &&
activeTab === "search" &&
!(event.target instanceof HTMLInputElement) &&
!(event.target instanceof HTMLTextAreaElement)
) {
event.preventDefault();
document.getElementById("search-input")?.focus();
}
}
</script>
<svelte:head>
<title>{dataset.title}</title>
<meta name="description" content={dataset.subtitle} />
</svelte:head>
<svelte:window onkeydown={onKeydown} />
<div class="mx-auto max-w-[1200px] px-4 py-8">
<header class="mb-8 text-center">
<h1 class="mb-1 text-3xl font-semibold max-sm:text-xl">{dataset.title}</h1>
<p class="text-[0.95rem] text-ink-muted">{dataset.subtitle}</p>
<p class="mt-1.5 text-sm">
<a class="text-ink-muted no-underline hover:underline" href={resolve("/")}>
← Tất cả các kỳ thi
</a>
</p>
</header>
<main>
{#if loading}
<div class="my-4 text-center text-ink-muted">
<p>
Đang tải cơ sở dữ liệu ~{dataset.dbSizeMb} MB{progress > 0 ? ` · ${progress}%` : ""}
</p>
<div class="mx-auto my-2 h-2 w-[300px] max-w-full overflow-hidden rounded bg-surface-alt">
<div class="h-full rounded bg-primary transition-[width]" style="width: {progress}%"></div>
</div>
<p class="mx-auto mt-2 max-w-[480px] text-sm text-ink-subtle">
Lần đầu có thể mất 10-30 giây. Sau đó trình duyệt sẽ lưu cache và mở nhanh hơn.
</p>
</div>
{/if}
{#if loadError}
<p class="notice bg-error-bg text-error-ink">Lỗi: {loadError}</p>
{/if}
<div class="mx-auto mb-6 flex max-w-[600px] border-b-2 border-line">
{#each [{ id: "search", label: "Tra cứu" }, { id: "sql", label: "Truy vấn SQL" }] as const as tab (tab.id)}
<button
class="-mb-0.5 flex-1 cursor-pointer border-0 border-b-2 bg-transparent px-4 py-3
transition-colors hover:text-primary"
class:border-transparent={activeTab !== tab.id}
class:text-ink-muted={activeTab !== tab.id}
class:border-primary={activeTab === tab.id}
class:text-primary={activeTab === tab.id}
class:font-semibold={activeTab === tab.id}
onclick={() => (activeTab = tab.id)}
>
{tab.label}
</button>
{/each}
</div>
{#if activeTab === "search"}
<SearchForm
value={query}
onSearch={search}
onClear={clearSearch}
disabled={busy}
examples={dataset.examples}
/>
{#if searchError}
<p class="notice bg-error-bg text-error-ink">Lỗi truy vấn: {searchError}</p>
{/if}
{#if results && results.length === 1}
<StudentDetail student={results[0]} />
{:else}
<ScoreTable {results} />
{/if}
{#if results && results.length >= MAX_RESULTS}
<p class="notice bg-warning-bg text-sm text-warning-ink">
Hiển thị tối đa {MAX_RESULTS} kết quả. Vui lòng tìm kiếm cụ thể hơn.
</p>
{/if}
{:else}
<CustomQuery {db} disabled={busy} presets={dataset.presets} />
{/if}
</main>
<footer
class="mt-12 border-t border-line pt-4 text-center text-sm break-words text-ink-subtle"
>
<p>
Nguồn:
<a href={dataset.source} target="_blank" rel="noopener noreferrer">{dataset.source}</a>
{#if totalCount !== null}
· {totalCount.toLocaleString("vi-VN")} thí sinh
{/if}
· Dữ liệu chỉ mang tính tham khảo
</p>
</footer>
</div>
+16
View File
@@ -0,0 +1,16 @@
import { error } from "@sveltejs/kit";
import { DATASETS, datasetById } from "$lib/datasets";
import type { EntryGenerator, PageLoad } from "./$types";
/**
* One prerendered page per registered dataset. The registry is the repository
* root's datasets.json, so a dataset the assembler builds is a page that exists,
* and one it does not is a 404 at build time rather than a blank page.
*/
export const entries: EntryGenerator = () => DATASETS.map((d) => ({ dataset: d.id }));
export const load: PageLoad = ({ params }) => {
const dataset = datasetById(params.dataset);
if (!dataset) throw error(404, `Unknown dataset: ${params.dataset}`);
return { dataset };
};
+25
View File
@@ -0,0 +1,25 @@
import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/**
* Every route is prerendered to a real HTML file, so GitHub Pages serves static
* documents and no SPA 404-fallback is needed — a fallback would rewrite the URL
* and break the ?q= deep links.
*
* `paths.relative: false` keeps every asset URL absolute (/thptqg/_app/...), so
* the copy of index.html that serves as 404.html resolves its assets from any
* depth — GitHub Pages answers /thptqg/anything/at/all/ with that one file.
*
* `files.assets` points outside this workspace because the assembler stages the
* gzipped databases there (assembler/internal/databases).
*
* @type {import('@sveltejs/kit').Config}
*/
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({ pages: "dist", assets: "dist", strict: true }),
paths: { base: "/thptqg", relative: false },
files: { assets: "../.build/public" },
},
};
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
-19
View File
@@ -1,19 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// One build serves every page; src/router.js resolves the dataset from the URL.
//
// `base` must stay absolute. It makes the single emitted index.html work as an
// entry point at any depth, because asset URLs read /thptqg/assets/... no matter
// which directory serves the page. The deploy step copies that file to every
// dataset path, so each URL is a real static file and needs no SPA 404-fallback
// — a fallback would break the ?q= deep links.
//
// publicDir points outside this workspace because the assembler stages the
// gzipped databases there (assembler/internal/databases). Vite resolves the path
// against the project root, which is this directory.
export default defineConfig({
plugins: [react()],
base: "/thptqg/",
publicDir: "../.build/public",
});
+12
View File
@@ -0,0 +1,12 @@
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
test: {
// Only the framework-free modules are unit-tested: score tiers, query-mode
// classification and the ASCII fold that has to match the Go parser.
include: ["src/lib/**/*.test.ts"],
},
});