mirror of
https://github.com/tiennm99/thptqg2017.git
synced 2026-10-03 05:13:38 +00:00
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:
1 parent
e2112fdb38
commit
3fd137a233
49 files changed
+4432
-3112
No files matched your search
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ node_modules/
|
||||
|
||||
### Build output ###
|
||||
dist/
|
||||
web/.svelte-kit/
|
||||
|
||||
### Generated databases and Vite publicDir staging ###
|
||||
.build/
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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/"] },
|
||||
);
|
||||
Generated
+2704
-1106
File diff suppressed because it is too large.
Load diff
+17
-11
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" };
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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: [
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }[];
|
||||
};
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import "../app.css";
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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" },
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user