CVNSS commited on
Commit
86bbff2
·
verified ·
1 Parent(s): 440bd32

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +78 -541
index.html CHANGED
@@ -2,605 +2,142 @@
2
  <html lang="vi">
3
  <head>
4
  <meta charset="UTF-8" />
5
- <title>TỪ ĐIỂN TIẾNG VIỆT – HOÀNG PHÊ (OFFLINE PRO)</title>
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
 
8
  <!-- PWA -->
9
- <link rel="manifest" href="manifest.webmanifest" />
10
  <meta name="theme-color" content="#7a0000" />
11
 
12
  <style>
13
- :root{
14
- --red:#b30000; --yellow:#ffd700; --dark-red:#7a0000;
15
- --bg:#fffaf2; --border:#e0c97f; --card:#ffffff;
16
- --muted:#666; --ink:#222;
17
  }
18
- *{ box-sizing:border-box; font-family:"Times New Roman", Georgia, serif; }
19
- body{ margin:0; background:var(--bg); color:var(--ink); }
20
- header{
21
- background:linear-gradient(90deg,var(--red),var(--dark-red));
22
- color:var(--yellow); padding:16px 24px; border-bottom:5px solid var(--yellow);
23
- }
24
- header h1{ margin:0; font-size:22px; text-transform:uppercase; letter-spacing:1px; }
25
- header small{ display:block; margin-top:6px; font-size:13px; color:#ffeaa7; }
26
 
27
- main{ padding:20px; max-width:1100px; margin:auto; }
28
- .panel{ background:var(--card); border:2px solid var(--border); padding:16px; margin-bottom:16px; }
29
- .grid{ display:grid; grid-template-columns: 1fr; gap:10px; }
30
- @media (min-width: 860px){
31
- .grid{ grid-template-columns: 2fr 1fr; }
 
 
32
  }
33
 
34
- .hint{
35
- background:#fff3c4; border:1px dashed #d6b657; padding:12px; line-height:1.5;
36
- font-size:14px;
 
37
  }
38
 
39
- .searchRow{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
40
- .searchBox{ position:relative; flex: 1 1 420px; min-width: 280px; }
41
- input[type="text"]{
42
- width:100%; padding:12px; font-size:16px;
43
- border:2px solid var(--red); outline:none; background:#fff;
44
  }
45
- input[type="text"]:focus{ border-color:var(--dark-red); }
46
 
47
- select, button{
48
- padding:10px 10px; font-size:14px;
49
- border:2px solid var(--border); background:#fff; cursor:pointer;
50
  }
51
- button.primary{ border-color:var(--red); }
52
- .stats{ font-size:14px; color:#555; margin-top:10px; display:flex; gap:12px; flex-wrap:wrap; }
53
 
54
- /* Suggestions dropdown */
55
- .suggest{
56
- position:absolute; left:0; right:0; top:100%;
57
- background:#fff; border:1px solid #ddd; z-index:20;
58
- max-height: 320px; overflow:auto; display:none;
59
- }
60
- .suggest.open{ display:block; }
61
- .suggest .item{
62
- padding:10px 10px; border-bottom:1px dotted #ddd;
63
- display:flex; justify-content:space-between; gap:10px;
64
  }
65
- .suggest .item:hover{ background:#fff7dc; }
66
- .pill{ font-size:12px; color:#444; border:1px solid #ddd; padding:2px 6px; border-radius:999px; white-space:nowrap; }
67
 
68
- /* A–Z bar */
69
- .azbar{ display:flex; flex-wrap:wrap; gap:6px; }
70
- .azbar button{
71
- padding:6px 8px; font-size:13px; border:1px solid #ddd;
72
  }
73
- .azbar button.active{ border-color:var(--red); background:#fff2b2; }
74
-
75
- .entry{ padding:12px 8px; border-bottom:1px dotted #ccc; line-height:1.65; }
76
- .entry:last-child{ border-bottom:none; }
77
- .word{ font-size:20px; font-weight:bold; color:var(--dark-red); display:flex; gap:8px; flex-wrap:wrap; align-items:baseline; }
78
- .pos{ font-style:italic; color:var(--muted); font-size:14px; }
79
- .meaning{ margin-top:6px; padding-left:12px; }
80
- .meaning span{ display:block; margin-bottom:4px; }
81
- .muted{ color:var(--muted); }
82
-
83
- .highlight{ background:#fff2b2; font-weight:bold; }
84
 
85
- footer{
86
- text-align:center; padding:12px; font-size:13px; color:#555;
87
- border-top:2px solid var(--border); margin-top:30px;
88
  }
89
- .kbd{ border:1px solid #bbb; background:#f7f7f7; padding:1px 6px; border-radius:4px; }
90
  </style>
91
  </head>
92
 
93
  <body>
94
- <header>
95
- <h1>TỪ ĐIỂN TIẾNG VIỆT – HOÀNG PHÊ</h1>
96
- <small>Tra cứu offline · Không dấu + gợi ý · A–Z bucket · PWA · dev, 2026</small>
97
- </header>
98
 
99
  <main>
100
- <div class="panel hint" id="hint100">
101
- <b>Gợi ý tra cứu (100 từ):</b>
102
- Gõ từ cần tra ở ô tìm kiếm; bạn có thể gõ <b>không dấu</b> (ví dụ <i>an</i> sẽ tìm <i>ăn, ân, ắn…</i>).
103
- Dùng <b>gợi ý</b> xuất hiện ngay dưới ô để chọn nhanh (Enter để tra).
104
- Bấm các nút <b>A–Z</b> để giới hạn phạm vi, giúp kết quả nhanh hơn khi từ khóa ngắn.
105
- Dùng bộ lọc <b>Từ loại</b> để chỉ xem danh từ/động từ/tính từ…
106
- Bật <b>Tìm mờ</b> để chịu lỗi gõ gần đúng (ví dụ “anng” vẫn ra “ăn”).
107
- Mẹo: nhấn <span class="kbd">Esc</span> để xoá gõ, nhấn <span class="kbd">↓</span> để đi xuống danh sách gợi ý.
108
- </div>
109
-
110
- <div class="panel">
111
- <div class="searchRow">
112
- <div class="searchBox">
113
- <input id="searchInput" type="text" placeholder="Nhập từ cần tra… (không dấu cũng được)" autocomplete="off" />
114
- <div id="suggest" class="suggest" aria-label="Gợi ý"></div>
115
- </div>
116
-
117
- <select id="posFilter" title="Lọc theo từ loại">
118
- <option value="">Tất cả từ loại</option>
119
- <option value="d">Danh từ</option>
120
- <option value="đg">Động từ</option>
121
- <option value="t">Tính từ</option>
122
- <option value="tr">Trợ từ</option>
123
- <option value="c">Cảm từ</option>
124
- <option value="p">Phụ từ</option>
125
- <option value="k">Kết từ</option>
126
- <option value="đ">Đại từ</option>
127
- </select>
128
-
129
- <label style="display:flex; align-items:center; gap:8px;">
130
- <input id="fuzzyToggle" type="checkbox" />
131
- <span>Tìm mờ (fuzzy)</span>
132
- </label>
133
-
134
- <button class="primary" id="btnInstall" style="display:none;">Cài như App</button>
135
- <button id="btnClear">Xoá</button>
136
- </div>
137
-
138
- <div class="stats" id="stats">Đang tải dữ liệu…</div>
139
- </div>
140
-
141
- <div class="panel">
142
- <div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; align-items:center;">
143
- <div>
144
- <b>Giới hạn A–Z:</b> <span class="muted">giúp tìm nhanh khi từ khóa ngắn</span>
145
- </div>
146
- <div class="azbar" id="azbar"></div>
147
- </div>
148
- </div>
149
-
150
- <div class="panel" id="results"></div>
151
  </main>
152
 
153
- <footer>
154
- Dữ liệu: Từ điển tiếng Việt (Hoàng Phê) · Chạy offline hoàn toàn (qua localhost)
155
- </footer>
156
-
157
  <script>
158
- /* =========================
159
- 0) PWA: Service Worker
160
- ========================= */
161
- if ("serviceWorker" in navigator) {
162
- window.addEventListener("load", () => {
163
- navigator.serviceWorker.register("./sw.js").catch(() => {});
164
- });
165
- }
166
 
167
- /* =========================
168
- 1) Utils: Normalize + Highlight
169
- ========================= */
170
  function normalizeVN(s) {
171
- // NFD tách dấu, bỏ dấu; riêng đ/Đ -> d
172
- return (s || "")
173
- .toLowerCase()
174
  .replace(/đ/g, "d")
175
  .normalize("NFD")
176
- .replace(/[\u0300-\u036f]/g, "")
177
- .replace(/[^a-z0-9\s\-]/g, " ")
178
- .replace(/\s+/g, " ")
179
- .trim();
180
- }
181
-
182
- function escapeHTML(str){
183
- return (str ?? "").replace(/[&<>"']/g, m => ({
184
- "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"
185
- }[m]));
186
- }
187
-
188
- function highlight(htmlText, needleNorm){
189
- if(!needleNorm) return htmlText;
190
- // highlight theo "không dấu": ta highlight thẳng keyword thô trong hiển thị nghĩa/từ (an toàn)
191
- // tránh làm chậm: chỉ highlight theo chuỗi gõ gốc (không dấu) bằng regex nhẹ.
192
- const raw = escapeHTML(htmlText);
193
- const needle = escapeHTML(needleNorm);
194
- if(!needle) return raw;
195
- // regex an toàn (đã escape)
196
- const re = new RegExp("(" + needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ")", "ig");
197
- return raw.replace(re, '<span class="highlight">$1</span>');
198
- }
199
-
200
- /* =========================
201
- 2) Fuzzy: Edit Distance (có chặn)
202
- ========================= */
203
- function editDistanceWithin(a, b, maxDist){
204
- // Damerau-Levenshtein giản lược + early-exit
205
- if (Math.abs(a.length - b.length) > maxDist) return maxDist + 1;
206
- const n=a.length, m=b.length;
207
- const dp = Array.from({length:n+1}, () => new Array(m+1).fill(0));
208
- for(let i=0;i<=n;i++) dp[i][0]=i;
209
- for(let j=0;j<=m;j++) dp[0][j]=j;
210
-
211
- for(let i=1;i<=n;i++){
212
- let rowMin = Infinity;
213
- for(let j=1;j<=m;j++){
214
- const cost = a[i-1]===b[j-1] ? 0 : 1;
215
- let val = Math.min(
216
- dp[i-1][j] + 1,
217
- dp[i][j-1] + 1,
218
- dp[i-1][j-1] + cost
219
- );
220
- // transposition
221
- if(i>1 && j>1 && a[i-1]===b[j-2] && a[i-2]===b[j-1]){
222
- val = Math.min(val, dp[i-2][j-2] + 1);
223
- }
224
- dp[i][j]=val;
225
- rowMin = Math.min(rowMin, val);
226
- }
227
- if(rowMin > maxDist) return maxDist + 1; // early exit
228
- }
229
- return dp[n][m];
230
- }
231
-
232
- /* =========================
233
- 3) Data: Load + Index A–Z
234
- ========================= */
235
- const posLabel = {
236
- "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ừ"
237
- };
238
-
239
- let ALL = []; // toàn bộ mục
240
- let INDEX = new Map(); // bucket A–Z + "0" (khác)
241
- let ACTIVE_BUCKET = ""; // "" = tất cả
242
- let SUGGEST_LIMIT = 12;
243
- let RESULTS_LIMIT = 400;
244
-
245
- function bucketKeyFromWord(word){
246
- const n = normalizeVN(word);
247
- const ch = n[0] || "";
248
- if(ch >= "a" && ch <= "z") return ch.toUpperCase();
249
- if(ch >= "0" && ch <= "9") return "0";
250
- return "#";
251
- }
252
-
253
- function buildIndex(entries){
254
- INDEX = new Map();
255
- for(const e of entries){
256
- const k = bucketKeyFromWord(e.tu);
257
- if(!INDEX.has(k)) INDEX.set(k, []);
258
- // precompute normalized fields để tìm nhanh
259
- const tuNorm = normalizeVN(e.tu);
260
- const nghiaText = (e.nghia || []).join(" ");
261
- const nghiaNorm = normalizeVN(nghiaText);
262
- INDEX.get(k).push({
263
- ...e,
264
- _tuNorm: tuNorm,
265
- _nghiaNorm: nghiaNorm
266
- });
267
- }
268
- }
269
-
270
- function getWorkingSet(){
271
- if(!ACTIVE_BUCKET) return ALL;
272
- return INDEX.get(ACTIVE_BUCKET) || [];
273
  }
274
 
275
- /* =========================
276
- 4) Search: exact / contains / fuzzy + pos filter
277
- ========================= */
278
- function searchEntries(queryRaw, pos, fuzzyOn){
279
- const qNorm = normalizeVN(queryRaw);
280
- const set = getWorkingSet();
281
 
282
- if(!qNorm){
283
- return set.slice(0, 200);
284
- }
285
 
286
- // Tối ưu: lọc từ loại trước (nhanh)
287
- let candidates = pos ? set.filter(e => e.tu_loai === pos) : set;
288
 
289
- // 1) Exact + Prefix + Contains (ưu tiên)
290
- const exact = [];
291
- const prefix = [];
292
- const contains = [];
293
- const inMeaning = [];
294
 
295
- for(const e of candidates){
296
- if(e._tuNorm === qNorm) exact.push(e);
297
- else if(e._tuNorm.startsWith(qNorm)) prefix.push(e);
298
- else if(e._tuNorm.includes(qNorm)) contains.push(e);
299
- else if(e._nghiaNorm.includes(qNorm)) inMeaning.push(e);
300
  }
301
 
302
- // 2) Fuzzy (chỉ chạy trên phần còn lại, giới hạn)
303
- let fuzzy = [];
304
- if(fuzzyOn){
305
- const pool = candidates
306
- .filter(e => !exact.includes(e) && !prefix.includes(e) && !contains.includes(e))
307
- .slice(0, 2500);
308
-
309
- const maxDist = qNorm.length <= 4 ? 1 : (qNorm.length <= 7 ? 2 : 3);
310
- const scored = [];
311
- for(const e of pool){
312
- // chỉ fuzzy theo "từ", không fuzzy theo nghĩa (đỡ nặng)
313
- const d = editDistanceWithin(qNorm, e._tuNorm, maxDist);
314
- if(d <= maxDist){
315
- scored.push({e, d});
316
- }
317
- }
318
- scored.sort((a,b) => a.d - b.d || a.e._tuNorm.length - b.e._tuNorm.length);
319
- fuzzy = scored.slice(0, 120).map(x => x.e);
320
- }
321
 
322
- const merged = [...exact, ...prefix, ...contains, ...fuzzy, ...inMeaning];
323
- // loại trùng
324
- const seen = new Set();
325
- const out = [];
326
- for(const e of merged){
327
- const key = e.tu + "||" + e.tu_loai + "||" + (e.nghia?.[0] || "");
328
- if(seen.has(key)) continue;
329
- seen.add(key);
330
- out.push(e);
331
- if(out.length >= RESULTS_LIMIT) break;
332
- }
333
- return out;
334
- }
335
 
336
- /* =========================
337
- 5) UI: Render Results + Suggestions
338
- ========================= */
339
- const $ = (id) => document.getElementById(id);
340
- const input = $("searchInput");
341
- const suggestBox = $("suggest");
342
- const resultsBox = $("results");
343
- const statsBox = $("stats");
344
- const posFilter = $("posFilter");
345
- const fuzzyToggle = $("fuzzyToggle");
346
- const btnClear = $("btnClear");
347
 
348
- function renderResults(list, queryRaw){
349
- const qNorm = normalizeVN(queryRaw);
350
- resultsBox.innerHTML = "";
351
- if(!list.length){
352
- resultsBox.innerHTML = `<div class="muted">Không tìm thấy kết quả.</div>`;
353
- return;
354
- }
355
 
356
- for(const item of list){
357
  const div = document.createElement("div");
358
  div.className = "entry";
359
 
360
- const wordHTML = highlight(item.tu, qNorm);
361
- const posText = item.tu_loai_day_du || posLabel[item.tu_loai] || item.tu_loai;
362
-
363
  let html = `
364
  <div class="word">
365
- ${wordHTML}
366
- <span class="pos">(${escapeHTML(posText)})</span>
367
  </div>
368
  <div class="meaning">
369
  `;
370
- const nghia = item.nghia || [];
371
- for(let i=0;i<nghia.length;i++){
372
- const m = highlight(nghia[i], qNorm);
373
- html += `<span>${i+1}. ${m}</span>`;
374
- }
375
- html += `</div>`;
376
 
377
- div.innerHTML = html;
378
- resultsBox.appendChild(div);
379
- }
380
- }
381
-
382
- function renderSuggestions(list){
383
- suggestBox.innerHTML = "";
384
- if(!list.length){
385
- suggestBox.classList.remove("open");
386
- return;
387
- }
388
- for(const item of list){
389
- const row = document.createElement("div");
390
- row.className = "item";
391
- row.innerHTML = `
392
- <div><b>${escapeHTML(item.tu)}</b> <span class="muted">(${escapeHTML(item.tu_loai_day_du || posLabel[item.tu_loai] || item.tu_loai)})</span></div>
393
- <span class="pill">${escapeHTML(bucketKeyFromWord(item.tu))}</span>
394
- `;
395
- row.addEventListener("mousedown", (ev) => {
396
- ev.preventDefault();
397
- input.value = item.tu;
398
- runSearch(true);
399
- suggestBox.classList.remove("open");
400
  });
401
- suggestBox.appendChild(row);
402
- }
403
- suggestBox.classList.add("open");
404
- }
405
-
406
- function buildSuggestions(queryRaw){
407
- const qNorm = normalizeVN(queryRaw);
408
- if(!qNorm) return [];
409
- const set = getWorkingSet();
410
- const pos = posFilter.value;
411
- const fuzzyOn = fuzzyToggle.checked;
412
-
413
- // ưu tiên theo "từ" (không quét nghĩa) để nhanh
414
- let pool = pos ? set.filter(e => e.tu_loai === pos) : set;
415
-
416
- const exact = [];
417
- const prefix = [];
418
- const contains = [];
419
-
420
- for(const e of pool){
421
- if(e._tuNorm === qNorm) exact.push(e);
422
- else if(e._tuNorm.startsWith(qNorm)) prefix.push(e);
423
- else if(e._tuNorm.includes(qNorm)) contains.push(e);
424
- if(exact.length + prefix.length >= SUGGEST_LIMIT) break;
425
- }
426
-
427
- let fuzzy = [];
428
- if(fuzzyOn && (exact.length + prefix.length) < SUGGEST_LIMIT){
429
- const maxDist = qNorm.length <= 4 ? 1 : 2;
430
- const scored = [];
431
- // fuzzy trên mẫu nhỏ để mượt
432
- for(const e of pool.slice(0, 1800)){
433
- if(e._tuNorm.startsWith(qNorm) || e._tuNorm === qNorm) continue;
434
- const d = editDistanceWithin(qNorm, e._tuNorm, maxDist);
435
- if(d <= maxDist) scored.push({e,d});
436
- }
437
- scored.sort((a,b) => a.d - b.d || a.e._tuNorm.length - b.e._tuNorm.length);
438
- fuzzy = scored.slice(0, SUGGEST_LIMIT).map(x => x.e);
439
- }
440
-
441
- const merged = [...exact, ...prefix, ...contains, ...fuzzy];
442
- const seen = new Set();
443
- const out = [];
444
- for(const e of merged){
445
- const k = e.tu + "||" + e.tu_loai;
446
- if(seen.has(k)) continue;
447
- seen.add(k);
448
- out.push(e);
449
- if(out.length >= SUGGEST_LIMIT) break;
450
- }
451
- return out;
452
- }
453
-
454
- /* =========================
455
- 6) A–Z Bar
456
- ========================= */
457
- function buildAZBar(){
458
- const az = [];
459
- for(let c=65;c<=90;c++) az.push(String.fromCharCode(c));
460
- az.push("0", "#");
461
 
462
- const bar = $("azbar");
463
- bar.innerHTML = "";
464
-
465
- const btnAll = document.createElement("button");
466
- btnAll.textContent = "ALL";
467
- btnAll.className = ACTIVE_BUCKET ? "" : "active";
468
- btnAll.addEventListener("click", () => { ACTIVE_BUCKET=""; buildAZBar(); runSearch(true); });
469
- bar.appendChild(btnAll);
470
-
471
- for(const k of az){
472
- const b = document.createElement("button");
473
- b.textContent = k;
474
- b.className = (ACTIVE_BUCKET === k) ? "active" : "";
475
- b.title = "Giới hạn theo nhóm " + k;
476
- b.addEventListener("click", () => {
477
- ACTIVE_BUCKET = k;
478
- buildAZBar();
479
- runSearch(true);
480
- });
481
- bar.appendChild(b);
482
- }
483
- }
484
-
485
- /* =========================
486
- 7) Controller: Debounce Search
487
- ========================= */
488
- let tmr = null;
489
-
490
- function setStats(text){
491
- statsBox.textContent = text;
492
- }
493
-
494
- function runSearch(fromPick=false){
495
- const q = input.value || "";
496
- const pos = posFilter.value;
497
- const fuzzyOn = fuzzyToggle.checked;
498
-
499
- const res = searchEntries(q, pos, fuzzyOn);
500
- const scope = ACTIVE_BUCKET ? ` · Bucket ${ACTIVE_BUCKET}` : " · Tất cả";
501
-
502
- setStats(
503
- `Kết quả: ${res.length.toLocaleString("vi-VN")}${scope}` +
504
- (pos ? ` · Lọc: ${posLabel[pos] || pos}` : "") +
505
- (fuzzyOn ? " · Fuzzy: ON" : "")
506
- );
507
- renderResults(res, q);
508
-
509
- // suggestions: chỉ hiện khi đang gõ, không phải khi click chọn
510
- if(!fromPick){
511
- const sug = buildSuggestions(q);
512
- renderSuggestions(sug);
513
- }
514
- }
515
-
516
- function debounceSearch(){
517
- clearTimeout(tmr);
518
- tmr = setTimeout(() => runSearch(false), 60);
519
  }
520
-
521
- /* =========================
522
- 8) Events
523
- ========================= */
524
- input.addEventListener("input", () => debounceSearch());
525
- posFilter.addEventListener("change", () => runSearch(true));
526
- fuzzyToggle.addEventListener("change", () => runSearch(true));
527
-
528
- btnClear.addEventListener("click", () => {
529
- input.value = "";
530
- suggestBox.classList.remove("open");
531
- runSearch(true);
532
- input.focus();
533
- });
534
-
535
- document.addEventListener("click", (e) => {
536
- if(!suggestBox.contains(e.target) && e.target !== input){
537
- suggestBox.classList.remove("open");
538
- }
539
- });
540
-
541
- input.addEventListener("keydown", (e) => {
542
- if(e.key === "Escape"){
543
- input.value = "";
544
- suggestBox.classList.remove("open");
545
- runSearch(true);
546
- }
547
- if(e.key === "Enter"){
548
- suggestBox.classList.remove("open");
549
- runSearch(true);
550
- }
551
- });
552
-
553
- /* =========================
554
- 9) Load JSON (cùng thư mục)
555
- ========================= */
556
- (async function boot(){
557
- try{
558
- setStats("Đang tải dữ liệu…");
559
- const res = await fetch("./tu_dien_hoang_phe_clean.json", { cache: "no-cache" });
560
- const data = await res.json();
561
-
562
- // data.muc_tu theo file JSON bạn đang dùng
563
- ALL = (data.muc_tu || []);
564
- buildIndex(ALL);
565
- buildAZBar();
566
-
567
- setStats(`Đã tải: ${ALL.length.toLocaleString("vi-VN")} mục từ · Sẵn sàng tra cứu`);
568
- renderResults(getWorkingSet().slice(0, 160), "");
569
-
570
- }catch(err){
571
- console.error(err);
572
- setStats("❌ Không đọc được JSON. Hãy chạy bằng server nội bộ (localhost).");
573
- resultsBox.innerHTML = `
574
- <div class="muted">
575
- <b>Không load được dữ liệu.</b><br/>
576
- Mở terminal trong thư mục và chạy: <span class="kbd">python -m http.server</span><br/>
577
- Sau đó mở: <span class="kbd">http://localhost:8000</span>
578
- </div>
579
- `;
580
- }
581
- })();
582
- </script>
583
-
584
- <script>
585
- /* =========================
586
- 10) PWA Install Button
587
- ========================= */
588
- let deferredPrompt = null;
589
- const btnInstall = document.getElementById("btnInstall");
590
-
591
- window.addEventListener("beforeinstallprompt", (e) => {
592
- e.preventDefault();
593
- deferredPrompt = e;
594
- btnInstall.style.display = "inline-block";
595
- });
596
-
597
- btnInstall?.addEventListener("click", async () => {
598
- if(!deferredPrompt) return;
599
- deferredPrompt.prompt();
600
- await deferredPrompt.userChoice;
601
- deferredPrompt = null;
602
- btnInstall.style.display = "none";
603
- });
604
  </script>
605
  </body>
606
  </html>
 
2
  <html lang="vi">
3
  <head>
4
  <meta charset="UTF-8" />
5
+ <title>TỪ ĐIỂN TIẾNG VIỆT – HOÀNG PHÊ</title>
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
 
8
  <!-- PWA -->
9
+ <link rel="manifest" href="./manifest.webmanifest" />
10
  <meta name="theme-color" content="#7a0000" />
11
 
12
  <style>
13
+ body {
14
+ margin: 0;
15
+ font-family: "Times New Roman", serif;
16
+ background: #fffaf2;
17
  }
 
 
 
 
 
 
 
 
18
 
19
+ header {
20
+ background: #7a0000;
21
+ color: gold;
22
+ padding: 15px;
23
+ text-align: center;
24
+ font-size: 20px;
25
+ font-weight: bold;
26
  }
27
 
28
+ main {
29
+ max-width: 900px;
30
+ margin: auto;
31
+ padding: 20px;
32
  }
33
 
34
+ input {
35
+ width: 100%;
36
+ padding: 12px;
37
+ font-size: 16px;
38
+ border: 2px solid #7a0000;
39
  }
 
40
 
41
+ .entry {
42
+ padding: 10px;
43
+ border-bottom: 1px solid #ddd;
44
  }
 
 
45
 
46
+ .word {
47
+ font-size: 20px;
48
+ color: #7a0000;
49
+ font-weight: bold;
 
 
 
 
 
 
50
  }
 
 
51
 
52
+ .pos {
53
+ font-style: italic;
54
+ color: #666;
55
+ margin-left: 8px;
56
  }
 
 
 
 
 
 
 
 
 
 
 
57
 
58
+ .meaning {
59
+ margin-left: 12px;
 
60
  }
 
61
  </style>
62
  </head>
63
 
64
  <body>
65
+ <header>TỪ ĐIỂN TIẾNG VIỆT – HOÀNG PHÊ</header>
 
 
 
66
 
67
  <main>
68
+ <input id="searchInput" placeholder="Nhập từ cần tra (không dấu cũng được)" />
69
+ <div id="stats">Đang tải dữ liệu…</div>
70
+ <div id="results"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  </main>
72
 
 
 
 
 
73
  <script>
74
+ let dictionary = [];
 
 
 
 
 
 
 
75
 
 
 
 
76
  function normalizeVN(s) {
77
+ return s.toLowerCase()
 
 
78
  .replace(/đ/g, "d")
79
  .normalize("NFD")
80
+ .replace(/[\u0300-\u036f]/g, "");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  }
82
 
83
+ fetch("./tu_dien_hoang_phe_clean.json")
84
+ .then(res => res.json())
85
+ .then(data => {
86
+ dictionary = data.muc_tu;
87
+ document.getElementById("stats").innerText =
88
+ "Tổng mục từ: " + dictionary.length;
89
 
90
+ render(dictionary.slice(0, 200));
91
+ });
 
92
 
93
+ const input = document.getElementById("searchInput");
 
94
 
95
+ input.addEventListener("input", () => {
96
+ const q = input.value.trim();
97
+ const qn = normalizeVN(q);
 
 
98
 
99
+ if (!qn) {
100
+ render(dictionary.slice(0, 200));
101
+ return;
 
 
102
  }
103
 
104
+ const result = dictionary.filter(e => {
105
+ const w = normalizeVN(e.tu);
106
+ const m = normalizeVN(e.nghia.join(" "));
107
+ return w.includes(qn) || m.includes(qn);
108
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
+ document.getElementById("stats").innerText =
111
+ "Kết quả: " + result.length;
 
 
 
 
 
 
 
 
 
 
 
112
 
113
+ render(result.slice(0, 400));
114
+ });
 
 
 
 
 
 
 
 
 
115
 
116
+ function render(list) {
117
+ const box = document.getElementById("results");
118
+ box.innerHTML = "";
 
 
 
 
119
 
120
+ list.forEach(item => {
121
  const div = document.createElement("div");
122
  div.className = "entry";
123
 
 
 
 
124
  let html = `
125
  <div class="word">
126
+ ${item.tu}
127
+ <span class="pos">(${item.tu_loai_day_du})</span>
128
  </div>
129
  <div class="meaning">
130
  `;
 
 
 
 
 
 
131
 
132
+ item.nghia.forEach((n, i) => {
133
+ html += `<div>${i + 1}. ${n}</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
+ html += "</div>";
137
+ div.innerHTML = html;
138
+ box.appendChild(div);
139
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  </script>
142
  </body>
143
  </html>