tudienhoangphe / index.html
CVNSS's picture
Update index.html
c609be2 verified
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<title>TỪ ĐIỂN TIẾNG VIỆT – HOÀNG PHÊ (INSTANT)</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#7a0000" />
<style>
:root{
--red:#b30000; --yellow:#ffd700; --dark-red:#7a0000;
--bg:#fffaf2; --border:#e0c97f; --card:#ffffff;
--muted:#666; --ink:#222;
}
*{ box-sizing:border-box; font-family:"Times New Roman", Georgia, serif; }
body{ margin:0; background:var(--bg); color:var(--ink); }
header{
background:linear-gradient(90deg,var(--red),var(--dark-red));
color:var(--yellow); padding:16px 24px; border-bottom:5px solid var(--yellow);
}
header h1{ margin:0; font-size:22px; text-transform:uppercase; letter-spacing:1px; }
header small{ display:block; margin-top:6px; font-size:13px; color:#ffeaa7; }
main{ padding:20px; max-width:1100px; margin:auto; }
.panel{ background:var(--card); border:2px solid var(--border); padding:16px; margin-bottom:16px; }
.hint{
background:#fff3c4; border:1px dashed #d6b657; padding:12px; line-height:1.55; font-size:14px;
}
.row{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
.searchBox{ position:relative; flex:1 1 520px; min-width:280px; }
input[type="text"]{
width:100%; padding:12px; font-size:16px;
border:2px solid var(--red); outline:none; background:#fff;
}
input[type="text"]:focus{ border-color:var(--dark-red); }
select, button{
padding:10px 10px; font-size:14px;
border:2px solid var(--border); background:#fff; cursor:pointer;
}
button.primary{ border-color:var(--red); }
.stats{ font-size:14px; color:#555; margin-top:10px; display:flex; gap:12px; flex-wrap:wrap; }
/* Suggest dropdown */
.suggest{
position:absolute; left:0; right:0; top:100%;
background:#fff; border:1px solid #ddd; z-index:50;
max-height: 320px; overflow:auto; display:none;
}
.suggest.open{ display:block; }
.suggest .item{
padding:10px 10px; border-bottom:1px dotted #ddd;
display:flex; justify-content:space-between; gap:10px;
cursor:pointer;
}
.suggest .item:hover{ background:#fff7dc; }
.entry{ padding:12px 8px; border-bottom:1px dotted #ccc; line-height:1.65; }
.entry:last-child{ border-bottom:none; }
.word{ font-size:20px; font-weight:bold; color:var(--dark-red); display:flex; gap:8px; flex-wrap:wrap; align-items:baseline; }
.pos{ font-style:italic; color:var(--muted); font-size:14px; }
.meaning{ margin-top:6px; padding-left:12px; }
.meaning span{ display:block; margin-bottom:4px; }
.highlight{ background:#fff2b2; font-weight:bold; }
footer{
text-align:center; padding:12px; font-size:13px; color:#555;
border-top:2px solid var(--border); margin-top:30px;
}
.kbd{ border:1px solid #bbb; background:#f7f7f7; padding:1px 6px; border-radius:4px; }
</style>
</head>
<body>
<header>
<h1>TỪ ĐIỂN TIẾNG VIỆT – HOÀNG PHÊ</h1>
<small>Gõ là ra liền · Không dấu · Gợi ý · dev, 2026</small>
</header>
<main>
<div class="panel hint">
<b>Gợi ý tra cứu (100 từ):</b>
Chỉ cần gõ vào ô tìm kiếm, kết quả sẽ hiển thị ngay lập tức. Bạn có thể gõ <b>không dấu</b>:
“an” sẽ khớp “ăn, ân, ắn…”. Danh sách <b>gợi ý</b> xuất hiện ngay dưới ô, bấm vào là tra nhanh.
Dùng bộ lọc <b>Từ loại</b> để chỉ xem danh từ/động từ/tính từ… Khi từ khóa quá ngắn, hãy gõ thêm 1–2 ký tự để
kết quả chính xác hơn. Nhấn <span class="kbd">Esc</span> để xoá nhanh, nhấn <span class="kbd">Enter</span> để “chốt” kết quả.
Nếu bạn tìm theo nghĩa, cứ gõ cụm từ, hệ thống sẽ dò trong phần định nghĩa.
</div>
<div class="panel">
<div class="row">
<div class="searchBox">
<input id="q" type="text" placeholder="Nhập từ cần tra… (không dấu cũng được)" autocomplete="off" />
<div id="suggest" class="suggest" aria-label="Gợi ý"></div>
</div>
<select id="pos">
<option value="">Tất cả từ loại</option>
<option value="d">Danh từ</option>
<option value="đg">Động từ</option>
<option value="t">Tính từ</option>
<option value="tr">Trợ từ</option>
<option value="c">Cảm từ</option>
<option value="p">Phụ từ</option>
<option value="k">Kết từ</option>
<option value="đ">Đại từ</option>
</select>
<button class="primary" id="clear">Xoá</button>
</div>
<div class="stats" id="stats">Đang tải dữ liệu…</div>
</div>
<div class="panel" id="results"></div>
</main>
<footer>
Dữ liệu: Từ điển tiếng Việt (Hoàng Phê) · JSON đặt cùng thư mục
</footer>
<script>
/* =========================
1) Normalize không dấu
========================= */
function normalizeVN(s) {
return (s || "")
.toLowerCase()
.replace(/đ/g, "d")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9\s\-]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function escapeHTML(str){
return (str ?? "").replace(/[&<>"']/g, m => ({
"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"
}[m]));
}
function highlightText(text, qNorm){
const safe = escapeHTML(text);
if(!qNorm) return safe;
// highlight theo qNorm (không dấu) trên chính text hiển thị: nhẹ và đủ dùng
const needle = qNorm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if(!needle) return safe;
const re = new RegExp("(" + needle + ")", "ig");
return safe.replace(re, '<span class="highlight">$1</span>');
}
/* =========================
2) Data + Index tối ưu
========================= */
const posLabel = {
"d":"danh từ","đg":"động từ","t":"tính từ","tr":"trợ từ","c":"cảm từ","p":"phụ từ","k":"kết từ","đ":"đại từ"
};
let ALL = []; // entries đã precompute
let READY = false;
const LIMIT_RESULTS = 350; // render tối đa
const LIMIT_SUGGEST = 12; // gợi ý
function prepEntry(e){
const tu = e.tu || "";
const nghiaArr = Array.isArray(e.nghia) ? e.nghia : [];
const nghiaText = nghiaArr.join(" ");
return {
...e,
_tuNorm: normalizeVN(tu),
_nghiaNorm: normalizeVN(nghiaText),
_posText: e.tu_loai_day_du || posLabel[e.tu_loai] || e.tu_loai
};
}
/* =========================
3) Search tức thì (xếp hạng)
========================= */
function searchInstant(qRaw, pos){
const qNorm = normalizeVN(qRaw);
if(!qNorm){
// mặc định hiển thị một phần đầu để chứng minh đã load
const base = pos ? ALL.filter(x => x.tu_loai === pos) : ALL;
return { qNorm, list: base.slice(0, 200) };
}
const pool = pos ? ALL.filter(x => x.tu_loai === pos) : ALL;
const exact = [];
const prefix = [];
const contains = [];
const meaning = [];
for(const e of pool){
if(e._tuNorm === qNorm) exact.push(e);
else if(e._tuNorm.startsWith(qNorm)) prefix.push(e);
else if(e._tuNorm.includes(qNorm)) contains.push(e);
else if(e._nghiaNorm.includes(qNorm)) meaning.push(e);
// chặn sớm để luôn “ra liền”
if(exact.length + prefix.length + contains.length >= LIMIT_RESULTS) break;
}
const merged = [...exact, ...prefix, ...contains, ...meaning];
// loại trùng
const seen = new Set();
const out = [];
for(const e of merged){
const key = e.tu + "||" + e.tu_loai + "||" + (e.nghia?.[0] || "");
if(seen.has(key)) continue;
seen.add(key);
out.push(e);
if(out.length >= LIMIT_RESULTS) break;
}
return { qNorm, list: out };
}
function buildSuggest(qRaw, pos){
const qNorm = normalizeVN(qRaw);
if(!qNorm) return [];
const pool = pos ? ALL.filter(x => x.tu_loai === pos) : ALL;
const out = [];
for(const e of pool){
if(e._tuNorm.startsWith(qNorm) || e._tuNorm === qNorm){
out.push(e);
if(out.length >= LIMIT_SUGGEST) break;
}
}
if(out.length < LIMIT_SUGGEST){
for(const e of pool){
if(e._tuNorm.includes(qNorm) && !out.includes(e)){
out.push(e);
if(out.length >= LIMIT_SUGGEST) break;
}
}
}
return out;
}
/* =========================
4) Render UI
========================= */
const $ = (id) => document.getElementById(id);
const input = $("q");
const posSel = $("pos");
const resultsBox = $("results");
const statsBox = $("stats");
const suggestBox = $("suggest");
const btnClear = $("clear");
function renderResults(list, qNorm){
resultsBox.innerHTML = "";
if(!list.length){
resultsBox.innerHTML = `<div class="entry"><span class="muted">Không tìm thấy.</span></div>`;
return;
}
for(const item of list){
const div = document.createElement("div");
div.className = "entry";
const wordHtml = highlightText(item.tu || "", qNorm);
let html = `
<div class="word">${wordHtml} <span class="pos">(${escapeHTML(item._posText || "")})</span></div>
<div class="meaning">
`;
const nghia = Array.isArray(item.nghia) ? item.nghia : [];
for(let i=0;i<nghia.length;i++){
html += `<span>${i+1}. ${highlightText(nghia[i], qNorm)}</span>`;
}
html += `</div>`;
div.innerHTML = html;
resultsBox.appendChild(div);
}
}
function renderSuggest(items){
suggestBox.innerHTML = "";
if(!items.length){
suggestBox.classList.remove("open");
return;
}
for(const it of items){
const row = document.createElement("div");
row.className = "item";
row.innerHTML = `
<div><b>${escapeHTML(it.tu || "")}</b> <span class="muted">(${escapeHTML(it._posText || "")})</span></div>
<div class="muted">↵</div>
`;
row.addEventListener("mousedown", (ev) => {
ev.preventDefault();
input.value = it.tu || "";
run(true);
suggestBox.classList.remove("open");
});
suggestBox.appendChild(row);
}
suggestBox.classList.add("open");
}
/* =========================
5) Run: “gõ là ra liền”
- requestAnimationFrame để mượt
- không debounce dài (chỉ 1 frame)
========================= */
let raf = 0;
function run(fromPick=false){
if(!READY) return;
const qRaw = input.value || "";
const pos = posSel.value || "";
const { qNorm, list } = searchInstant(qRaw, pos);
statsBox.textContent =
`Kết quả: ${list.length.toLocaleString("vi-VN")} · Tổng: ${ALL.length.toLocaleString("vi-VN")}` +
(pos ? ` · Lọc: ${posLabel[pos] || pos}` : "") +
(qRaw ? " · (hiển thị tức thì)" : "");
renderResults(list, qNorm);
if(!fromPick){
const sug = buildSuggest(qRaw, pos);
renderSuggest(sug);
}
}
function runNextFrame(){
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => run(false));
}
/* =========================
6) Events
========================= */
input.addEventListener("input", () => runNextFrame());
posSel.addEventListener("change", () => run(true));
btnClear.addEventListener("click", () => {
input.value = "";
suggestBox.classList.remove("open");
run(true);
input.focus();
});
document.addEventListener("click", (e) => {
if(!suggestBox.contains(e.target) && e.target !== input){
suggestBox.classList.remove("open");
}
});
input.addEventListener("keydown", (e) => {
if(e.key === "Escape"){
input.value = "";
suggestBox.classList.remove("open");
run(true);
}
if(e.key === "Enter"){
suggestBox.classList.remove("open");
run(true);
}
});
/* =========================
7) Load JSON (cùng thư mục)
========================= */
(async function boot(){
try{
statsBox.textContent = "Đang tải dữ liệu…";
const res = await fetch("./tu_dien_hoang_phe_clean.json", { cache: "no-cache" });
const data = await res.json();
const muc = Array.isArray(data.muc_tu) ? data.muc_tu : [];
ALL = muc.map(prepEntry);
READY = true;
statsBox.textContent = `Đã tải: ${ALL.length.toLocaleString("vi-VN")} mục từ · Gõ để tra ngay`;
run(true);
input.focus();
}catch(err){
console.error(err);
statsBox.textContent = "❌ Không load được JSON. Kiểm tra file cùng thư mục và quyền truy cập.";
resultsBox.innerHTML = `<div class="entry"><span class="muted">Không tải được dữ liệu.</span></div>`;
}
})();
</script>
</body>
</html>