CVNSS commited on
Commit
4179c74
·
verified ·
1 Parent(s): ecaf6f4

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +605 -18
index.html CHANGED
@@ -1,19 +1,606 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
+ <!DOCTYPE html>
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>