feat: deep-link, share, score legend, web font, grouped SQL presets

- App.jsx owns query state, syncs to URL via ?q= (works for SBD or name);
  hydrates initial search from URL on DB ready
- Loading panel now shows DB size (~47 MB) and one-time download note
- Footer reports total student count from the live DB
- Keyboard shortcut '/' focuses the search input when not already typing
- StudentDetail: "Chia sẻ" button uses Web Share API or clipboard with a
  formatted multi-line summary including a deep-link URL
- StudentDetail: inline tier legend (▽ ○ ◆ ★ ✦) with score ranges
- ScoreTable cells now use background tint matching detail tiles
- CustomQuery: presets grouped into 4 categories; auto-runs PRAGMA
  table_info(student) on first DB ready so the tab opens with the schema
  visible instead of a blank textarea
- SearchForm accepts controlled value prop for URL hydration
- Add Be Vietnam Pro web font for consistent Vietnamese diacritic rendering
This commit is contained in:
tiennm99 committed 2026-04-14 22:44:00 +07:00
1 parent f1169d3d28
commit 95be7e479d
6 files changed
+323 -87

No files matched your search

+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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"
/>
<title>Tra cứu điểm thi THPT QG 2017</title>
</head>
<body>
+82 -13
View File
@@ -67,11 +67,14 @@ body { margin: 0; background: var(--bg); color: var(--text); }
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;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--text);
}
@@ -129,18 +132,34 @@ header h1 {
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.5rem;
margin-bottom: 1rem;
align-items: center;
gap: 0.4rem;
}
.preset-label {
font-size: 0.85rem;
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 {
@@ -348,6 +367,13 @@ header h1 {
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;
@@ -410,12 +436,12 @@ tbody tr:hover { background: var(--surface-alt); }
font-weight: 500;
}
/* Score tier color coding — used on both table cells and detail tiles */
.score-cell.tier-poor { color: var(--score-poor-ink); }
.score-cell.tier-weak { color: var(--score-weak-ink); }
.score-cell.tier-fair { color: var(--score-fair-ink); }
.score-cell.tier-good { color: var(--score-good-ink); font-weight: 600; }
.score-cell.tier-excellent { color: var(--score-excellent-ink); font-weight: 700; }
/* Score tier color coding — mirror the detail-tile palette for scanability */
.score-cell.tier-poor { color: var(--score-poor-ink); background: var(--score-poor-bg); }
.score-cell.tier-weak { color: var(--score-weak-ink); background: var(--score-weak-bg); }
.score-cell.tier-fair { color: var(--score-fair-ink); background: var(--score-fair-bg); }
.score-cell.tier-good { color: var(--score-good-ink); background: var(--score-good-bg); font-weight: 600; }
.score-cell.tier-excellent { color: var(--score-excellent-ink); background: var(--score-excellent-bg); font-weight: 700; }
/* Messages */
.no-results,
@@ -465,11 +491,19 @@ footer {
}
.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;
@@ -534,7 +568,8 @@ footer {
color: var(--text);
margin: 0 0 0.75rem;
display: flex;
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
@@ -545,6 +580,39 @@ footer {
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-poor { background: var(--score-poor-bg); color: var(--score-poor-ink); border-color: var(--score-poor-accent); }
.tier-legend-item.tier-weak { background: var(--score-weak-bg); color: var(--score-weak-ink); border-color: var(--score-weak-accent); }
.tier-legend-item.tier-fair { background: var(--score-fair-bg); color: var(--score-fair-ink); border-color: var(--score-fair-accent); }
.tier-legend-item.tier-good { background: var(--score-good-bg); color: var(--score-good-ink); border-color: var(--score-good-accent); }
.tier-legend-item.tier-excellent { background: var(--score-excellent-bg); color: var(--score-excellent-ink); border-color: var(--score-excellent-accent); }
.tier-legend .tier-range {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
/* Subject tile grid */
.score-grid {
list-style: none;
@@ -676,7 +744,8 @@ footer {
.search-input-row { flex-direction: column; }
.search-input-row .primary-btn { width: 100%; }
table { font-size: 0.8rem; }
.preset-list { flex-direction: column; align-items: flex-start; }
.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 {
+73 -17
View File
@@ -8,6 +8,7 @@ import "./App.css";
const DB_URL = import.meta.env.BASE_URL + "thptqg2017.db.gz";
const MAX_RESULTS = 100;
const DB_SIZE_MB = 47;
// Strip Vietnamese diacritics: "Nguyễn Bửu Lộc" → "nguyen buu loc"
function toAscii(str) {
@@ -19,58 +20,70 @@ function toAscii(str) {
}
function isAsciiOnly(str) {
// True if no Vietnamese diacritics or non-ASCII chars present
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") || "";
}
function App() {
const { db, loading, error, progress } = useSqlite(DB_URL);
const [results, setResults] = useState(null);
const [searchError, setSearchError] = useState(null);
const [activeTab, setActiveTab] = useState("search");
// Query is owned here so we can bidirectionally bind it to the URL
const [query, setQuery] = useState(() => readUrlQuery());
const [totalCount, setTotalCount] = useState(null);
const handleSearch = useCallback(
(query) => {
(q) => {
if (!db) return;
setSearchError(null);
setQuery(q);
writeUrlQuery(q);
try {
const isExamId = /^\d+$/.test(query);
const isExamId = /^\d+$/.test(q);
let stmt;
if (isExamId) {
stmt = db.prepare(
"SELECT * FROM student WHERE so_bao_danh = $q LIMIT $limit",
);
stmt.bind({ $q: query, $limit: MAX_RESULTS });
} else if (isAsciiOnly(query)) {
// ASCII input → match against folded column (handles "nguyen" = "Nguyễn")
stmt.bind({ $q: 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(query)}%`, $limit: MAX_RESULTS });
stmt.bind({ $q: `%${toAscii(q)}%`, $limit: MAX_RESULTS });
} else {
// Vietnamese input → search both original and folded columns
const normalized = toAscii(query);
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: `%${query}%`,
$q: `%${q}%`,
$qn: `%${normalized}%`,
$limit: MAX_RESULTS,
});
}
const rows = [];
while (stmt.step()) {
rows.push(stmt.getAsObject());
}
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
setResults(rows);
} catch (err) {
@@ -80,18 +93,51 @@ function App() {
[db],
);
// Ctrl+Enter submits the SQL query form when in SQL tab
// Run initial URL query once DB is ready
useEffect(() => {
if (db && query) handleSearch(query);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [db]);
// Fetch total count for footer once DB loads
useEffect(() => {
if (!db) return;
const stmt = db.prepare("SELECT COUNT(*) AS c FROM student");
stmt.step();
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("");
}, []);
return (
<div className="app">
<header>
@@ -104,10 +150,17 @@ function App() {
<main>
{loading && (
<div className="loading">
<p>Đang tải cơ sở dữ liệu... {progress > 0 ? `${progress}%` : ""}</p>
<p>
Đang tải cơ sở dữ liệu ~{DB_SIZE_MB} 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>
)}
@@ -131,8 +184,9 @@ function App() {
{activeTab === "search" && (
<>
<SearchForm
value={query}
onSearch={handleSearch}
onClear={() => setResults(null)}
onClear={handleClear}
disabled={loading || !!error}
/>
@@ -161,7 +215,9 @@ function App() {
<footer>
<p>
Nguồn: Sưu tầm từ trang báo thời đó · Dữ liệu chỉ mang tính tham khảo
Nguồn: baotintuc.vn
{totalCount !== null && ` · ${totalCount.toLocaleString("vi-VN")} thí sinh`}
{" · Dữ liệu chỉ mang tính tham khảo"}
</p>
</footer>
</div>
+85 -47
View File
@@ -1,51 +1,64 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
const MAX_ROWS = 1000;
const PRESET_QUERIES = [
const PRESET_GROUPS = [
{
label: "Top 10 điểm Toán cao nhất",
sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan
category: "Xếp hạng môn",
queries: [
{
label: "Top 10 điểm Toán",
sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan
FROM student WHERE toan IS NOT NULL
ORDER BY toan DESC LIMIT 10`,
},
{
label: "Top 10 KHTN (TB Vật lí + Hóa + Sinh)",
sql: `SELECT so_bao_danh, ho_ten, vat_ly, hoa_hoc, sinh_hoc, khtn
},
{
label: "Top 10 KHTN",
sql: `SELECT so_bao_danh, ho_ten, vat_ly, hoa_hoc, sinh_hoc, khtn
FROM student WHERE khtn IS NOT NULL
ORDER BY khtn DESC LIMIT 10`,
},
{
label: "Top 10 KHXH (TB Sử + Địa + GDCD)",
sql: `SELECT so_bao_danh, ho_ten, lich_su, dia_ly, gdcd, khxh
},
{
label: "Top 10 KHXH",
sql: `SELECT so_bao_danh, ho_ten, lich_su, dia_ly, gdcd, khxh
FROM student WHERE khxh IS NOT NULL
ORDER BY khxh DESC LIMIT 10`,
},
{
label: "Thí sinh ≥ 9 điểm Toán",
sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan
FROM student WHERE toan >= 9
ORDER BY toan DESC LIMIT 50`,
},
],
},
{
label: "Top 10 điểm Toán - Long An",
sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan
category: "Long An (SBD 49xxx)",
queries: [
{
label: "Top 10 Toán - Long An",
sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan
FROM student
WHERE so_bao_danh LIKE '49%' AND toan IS NOT NULL
ORDER BY toan DESC LIMIT 10`,
},
{
label: "Top 100 khối A - Long An",
sql: `SELECT so_bao_danh, ho_ten, toan, vat_ly, hoa_hoc,
},
{
label: "Top 100 khối A - Long An",
sql: `SELECT so_bao_danh, ho_ten, toan, vat_ly, hoa_hoc,
ROUND(toan + vat_ly + hoa_hoc, 2) AS tong_khoi_a
FROM student
WHERE so_bao_danh LIKE '49%'
AND toan IS NOT NULL AND vat_ly IS NOT NULL AND hoa_hoc IS NOT NULL
ORDER BY tong_khoi_a DESC LIMIT 100`,
},
],
},
{
label: "Thí sinh đạt 9+ điểm Toán",
sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan
FROM student WHERE toan >= 9
ORDER BY toan DESC LIMIT 50`,
},
{
label: "Phân bố điểm Toán",
sql: `SELECT
category: "Thống kê",
queries: [
{
label: "Phân bố điểm Toán",
sql: `SELECT
CASE
WHEN toan < 1 THEN '0-1'
WHEN toan < 2 THEN '1-2'
@@ -62,30 +75,39 @@ ORDER BY toan DESC LIMIT 50`,
FROM student WHERE toan IS NOT NULL
GROUP BY khoang_diem
ORDER BY khoang_diem`,
},
{
label: "Số thí sinh theo ngoại ngữ",
sql: `SELECT
},
{
label: "Số TS theo ngoại ngữ",
sql: `SELECT
SUM(CASE WHEN tieng_anh IS NOT NULL THEN 1 ELSE 0 END) AS tieng_anh,
SUM(CASE WHEN tieng_phap IS NOT NULL THEN 1 ELSE 0 END) AS tieng_phap,
SUM(CASE WHEN tieng_nga IS NOT NULL THEN 1 ELSE 0 END) AS tieng_nga,
SUM(CASE WHEN tieng_trung IS NOT NULL THEN 1 ELSE 0 END) AS tieng_trung
FROM student`,
},
{
label: "Số thí sinh chọn KHTN vs KHXH",
sql: `SELECT
},
{
label: "KHTN vs KHXH",
sql: `SELECT
SUM(CASE WHEN khtn IS NOT NULL THEN 1 ELSE 0 END) AS chon_khtn,
SUM(CASE WHEN khxh IS NOT NULL THEN 1 ELSE 0 END) AS chon_khxh,
SUM(CASE WHEN khtn IS NOT NULL AND khxh IS NOT NULL THEN 1 ELSE 0 END) AS chon_ca_hai
FROM student`,
},
],
},
{
label: "Schema bảng student",
sql: `PRAGMA table_info(student)`,
category: "Hệ thống",
queries: [
{
label: "Schema bảng student",
sql: `PRAGMA table_info(student)`,
},
],
},
];
const SCHEMA_PRESET = PRESET_GROUPS[PRESET_GROUPS.length - 1].queries[0];
export function CustomQuery({ db, disabled }) {
const [sql, setSql] = useState("");
const [columns, setColumns] = useState([]);
@@ -158,19 +180,35 @@ export function CustomQuery({ db, disabled }) {
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 && columns.length === 0 && !sql) {
executeQuery(SCHEMA_PRESET.sql);
setSql(SCHEMA_PRESET.sql);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [db]);
return (
<div className="custom-query">
<div className="preset-list">
<span className="preset-label">Mẫu truy vấn:</span>
{PRESET_QUERIES.map((p, i) => (
<button
key={i}
className="preset-btn"
onClick={() => handlePreset(p.sql)}
disabled={disabled}
>
{p.label}
</button>
<div className="preset-groups">
{PRESET_GROUPS.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>
+11 -3
View File
@@ -19,13 +19,18 @@ function detectMode(raw) {
: { mode: "name-short", hint: `Cần ít nhất ${MIN_NAME_CHARS} ký tự` };
}
export function SearchForm({ onSearch, onClear, disabled }) {
const [query, setQuery] = useState("");
export function SearchForm({ value = "", onSearch, onClear, disabled }) {
const [query, setQuery] = useState(value);
const inputRef = useRef(null);
const timerRef = useRef(null);
const { mode, hint } = detectMode(query);
const canSearch = mode === "sbd" || mode === "name";
// Sync from external value (deep-link URL, clear button in parent)
useEffect(() => {
setQuery(value);
}, [value]);
// Debounced live search
useEffect(() => {
clearTimeout(timerRef.current);
@@ -33,9 +38,12 @@ export function SearchForm({ onSearch, onClear, disabled }) {
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]);
}, [query, canSearch, mode, onSearch, onClear, value]);
function handleSubmit(e) {
e.preventDefault();
+66 -7
View File
@@ -29,18 +29,56 @@ function fmt(n) {
return n === null || n === undefined ? "—" : Number(n).toFixed(2);
}
// Visible tier legend — keeps symbol + range + label co-located so users
// don't have to hover tiles to decode colors.
const TIER_LEGEND = [
{ key: "poor", symbol: "▽", range: "< 5", label: "Chưa đạt" },
{ key: "weak", symbol: "○", range: "5-6.5", label: "Trung bình" },
{ key: "fair", symbol: "◆", range: "6.5-8", label: "Khá" },
{ key: "good", symbol: "★", range: "8-9", label: "Giỏi" },
{ key: "excellent", symbol: "✦", range: "≥ 9", label: "Xuất sắc" },
];
export function StudentDetail({ student }) {
const [copied, setCopied] = useState(false);
const [copied, setCopied] = useState(null); // null | 'sbd' | 'share' | 'url'
const blocks = computeBlocks(student);
const subjects = SUBJECT_ORDER
.filter((k) => student[k] !== null && student[k] !== undefined)
.map((k) => ({ key: k, label: SUBJECT_LABELS[k], score: student[k] }));
function flash(kind) {
setCopied(kind);
setTimeout(() => setCopied(null), 1500);
}
function copySbd() {
navigator.clipboard.writeText(student.so_bao_danh).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
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 (
@@ -59,7 +97,7 @@ export function StudentDetail({ student }) {
onClick={copySbd}
aria-label="Sao chép số báo danh"
>
{copied ? "✓ Đã chép" : "Chép"}
{copied === "sbd" ? "✓ Đã chép" : "Chép"}
</button>
</dd>
</div>
@@ -71,10 +109,31 @@ export function StudentDetail({ student }) {
)}
</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</h3>
<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);