vomebook commited on
Commit
eb88517
·
1 Parent(s): d5cb752

Upload 4 files

Browse files
Files changed (4) hide show
  1. static/app.js +12 -215
  2. static/index.html +5 -36
  3. static/style.css +68 -105
  4. static/sw.js +0 -14
static/app.js CHANGED
@@ -1,13 +1,3 @@
1
- /**
2
- * VoiceOfML Search — Frontend Application
3
- * MD3 Design System | SPA Router | Debounced Search | Keyboard Navigation
4
- * Left Sidebar: enter/exit browser navigation
5
- */
6
-
7
- /* ═══════════════════════════════════════════════════════════
8
- Constants
9
- ═══════════════════════════════════════════════════════════ */
10
-
11
  const ORDERED_EXTENSIONS = [
12
  "pdf", "txt",
13
  "epub", "mobi", "azw3", "fb2", "djvu", "chm", "caj",
@@ -60,13 +50,8 @@ const ICONS = {
60
  file: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
61
  database: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>',
62
  };
63
-
64
  const MIRROR_HOST = "hf-mirror.com";
65
 
66
- /* ═══════════════════════════════════════════════════════════
67
- State
68
- ═══════════════════════════════════════════════════════════ */
69
-
70
  const STATE = {
71
  mode: "global",
72
  repo: null,
@@ -106,7 +91,6 @@ const STATE = {
106
  _pageCache: {},
107
  _deferredAppendWhileDragging: false,
108
  };
109
-
110
  let searchAbortController = null;
111
  let searchRequestId = 0;
112
  let scrollTicking = false;
@@ -125,10 +109,6 @@ const VSCROLL = {
125
  isDraggingThumb: false,
126
  };
127
 
128
- /* ═══════════════════════════════════════════════════════════
129
- Size helpers (global — used by syncURLToState + syncStateToURL)
130
- ═══════════════════════════════════════════════════════════ */
131
-
132
  function bytesToDisplay(bytes) {
133
  if (bytes === null || bytes === undefined || bytes === 0) return { value: "", unit: "MB" };
134
  if (bytes >= 1073741824) return { value: (bytes / 1073741824).toFixed(2).replace(/\.?0+$/, ""), unit: "GB" };
@@ -154,11 +134,6 @@ function parseSizeStr(str) {
154
  else if (unit === "KB") val *= 1024;
155
  return Math.round(val);
156
  }
157
-
158
- /* ═══════════════════════════════════════════════════════════
159
- Search History (localStorage)
160
- ═══════════════════════════════════════════════════════════ */
161
-
162
  var HISTORY_KEY = "voml_search_history";
163
  var HISTORY_MAX = 20;
164
 
@@ -205,7 +180,6 @@ function removeHistoryItem(q) {
205
  saveHistory(list);
206
  renderDropdown();
207
  }
208
-
209
  var selectedIndices = {};
210
  var lastSelectedIndex = -1;
211
 
@@ -236,11 +210,8 @@ function updateSelectionUI() {
236
  }
237
  }
238
 
239
- /* ═══════════════════════════════════════════════════════════
240
- DOM References
241
- ═══════════════════════════════════════════════════════════ */
242
-
243
  const $ = (sel) => document.querySelector(sel);
 
244
  const DOM = {};
245
 
246
  function cacheDOM() {
@@ -309,16 +280,13 @@ function cacheDOM() {
309
  DOM.multiDeselect = $("#multi-deselect");
310
  }
311
 
312
- /* ═══════════════════════════════════════════════════════════
313
- Utilities
314
- ═══════════════════════════════════════════════════════════ */
315
-
316
  const HTML_ESCAPE_MAP = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
 
317
  function escapeHTML(str) {
318
  return String(str).replace(/[&<>"']/g, (ch) => HTML_ESCAPE_MAP[ch]);
319
  }
320
-
321
  const sizeCache = {};
 
322
  function formatSize(bytes) {
323
  if (!bytes && bytes !== 0) return "";
324
  if (sizeCache[bytes] !== undefined) return sizeCache[bytes];
@@ -442,10 +410,6 @@ function getRecordPath(rec) {
442
  return `https://huggingface.co/datasets/${rec.Repo || ""}/blob/main/${encodeRecordPath(buildRecordRelativePath(rec))}`;
443
  }
444
 
445
- /* ═══════════════════════════════════════════════════════════
446
- API Client
447
- ═══════════════════════════════════════════════════════════ */
448
-
449
  const API = {
450
  async search(params = {}) {
451
  const q = params.q || "";
@@ -472,14 +436,12 @@ const API = {
472
  body.sort = DOM.sortSelect.value;
473
  if (!STATE.searchFolders) body.search_folders = false;
474
  if (STATE.exact) body.exact = true;
475
-
476
  const fetchOptions = {
477
  method: "POST",
478
  headers: { "Content-Type": "application/json" },
479
  body: JSON.stringify(body),
480
  };
481
  if (params.signal) fetchOptions.signal = params.signal;
482
-
483
  const resp = await fetch(base, fetchOptions);
484
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
485
  return resp.json();
@@ -542,10 +504,6 @@ function scheduleBrowserRootPrefetch(repos) {
542
  else setTimeout(run, 200);
543
  }
544
 
545
- /* ═══════════════════════════════════════════════════════════
546
- Router
547
- ═══════════════════════════════════════════════════════════ */
548
-
549
  const ROUTER = {
550
  parse() {
551
  const path = window.location.pathname.replace(/\/+$/, "");
@@ -581,7 +539,6 @@ const ROUTER = {
581
  STATE.mode = route.mode;
582
  STATE.repo = route.repo;
583
  STATE.repoFull = route.repo ? `VoiceOfML/${route.repo}` : null;
584
-
585
  if (prevMode !== STATE.mode || prevRepo !== STATE.repo) {
586
  STATE.page = 1;
587
  STATE.results = [];
@@ -595,9 +552,7 @@ const ROUTER = {
595
  API._extCache = null;
596
  syncURLToState();
597
  }
598
-
599
  this.updateUI();
600
-
601
  if (prevMode !== STATE.mode || prevRepo !== STATE.repo) {
602
  const wasWide = new URLSearchParams(window.location.search).get("wide") === "1";
603
  this.onModeChanged();
@@ -607,7 +562,6 @@ const ROUTER = {
607
  syncStateToURL(true);
608
  }
609
  } else {
610
- // 模式没变但 URL 参数变了(前进/后退)
611
  syncURLToState();
612
  renderSidebar();
613
  renderFilters();
@@ -628,7 +582,6 @@ const ROUTER = {
628
  }
629
  },
630
  onModeChanged() {
631
- // 清除展开状态
632
  DOM.leftSidebar.classList.remove("expanded-wide");
633
  DOM.sidebarExpandBtn.textContent = "↔";
634
  if (!STATE.isMobile) {
@@ -643,11 +596,8 @@ const ROUTER = {
643
  },
644
  };
645
 
646
- // ── URL 同步 ──────────────────────────────────────────
647
-
648
  function syncStateToURL(replace = true) {
649
  const params = new URLSearchParams();
650
-
651
  if (STATE.query) params.set("q", STATE.query);
652
  if (STATE.mode === "global") {
653
  STATE.filterRepos.forEach(r => {
@@ -674,11 +624,9 @@ function syncStateToURL(replace = true) {
674
  if (!STATE.searchFolders) params.set("search_folders", "false");
675
  if (!STATE.exact) params.set("exact", "0");
676
  if (!STATE.recordHistory) params.set("history", "0");
677
-
678
  const qs = params.toString();
679
  const path = STATE.mode === "global" ? "/" : `/${STATE.repo}`;
680
  const url = qs ? `${path}?${qs}` : path;
681
-
682
  if (replace) {
683
  history.replaceState(null, "", url);
684
  } else {
@@ -688,20 +636,14 @@ function syncStateToURL(replace = true) {
688
 
689
  function syncURLToState() {
690
  const params = new URLSearchParams(window.location.search);
691
-
692
  STATE.query = params.get("q") || "";
693
  DOM.searchInput.value = STATE.query;
694
-
695
  STATE.filterRepos = params.getAll("repo").map(r => `VoiceOfML/${r}`);
696
-
697
  const extStr = params.get("ext") || "";
698
  STATE.filterExtensions = extStr ? extStr.split(",").filter(Boolean) : [];
699
-
700
  STATE.browserPath = params.get("path") || "";
701
-
702
  const sort = params.get("sort") || "relevance";
703
  DOM.sortSelect.value = sort;
704
-
705
  const minSize = params.get("min_size");
706
  if (minSize) {
707
  var parsed = parseSizeStr(minSize);
@@ -714,7 +656,6 @@ function syncURLToState() {
714
  DOM.filterMinSize.value = "";
715
  DOM.filterMinUnit.value = "MB";
716
  }
717
-
718
  const maxSize = params.get("max_size");
719
  if (maxSize) {
720
  var parsedMx = parseSizeStr(maxSize);
@@ -727,39 +668,28 @@ function syncURLToState() {
727
  DOM.filterMaxSize.value = "";
728
  DOM.filterMaxUnit.value = "MB";
729
  }
730
-
731
  const searchFolders = params.get("search_folders");
732
  STATE.searchFolders = searchFolders !== "false";
733
  DOM.searchFoldersToggle.checked = STATE.searchFolders;
734
-
735
  STATE.exact = params.get("exact") !== "0";
736
  DOM.exactSearchToggle.checked = STATE.exact;
737
-
738
  STATE.recordHistory = params.get("history") !== "0";
739
  if (DOM.historyToggle) DOM.historyToggle.checked = STATE.recordHistory;
740
  STATE.useMirrorLinks = params.get("mirror") !== "0";
741
  if (DOM.mirrorLinksToggle) DOM.mirrorLinksToggle.checked = STATE.useMirrorLinks;
742
-
743
  if (params.get("wide") === "1") {
744
  DOM.leftSidebar.classList.add("expanded-wide");
745
  DOM.sidebarExpandBtn.textContent = "→";
746
  }
747
-
748
  if (params.get("sidebar") === "0") {
749
  STATE.leftSidebarOpen = false;
750
  updateSidebarVisibility();
751
  }
752
  STATE.rightSidebarOpen = params.get("filters") === "1";
753
  updateSidebarVisibility();
754
-
755
  STATE.page = 1;
756
  STATE.results = [];
757
  }
758
-
759
- /* ═══════════════════════════════════════════════════════════
760
- Debounce & Search
761
- ═══════════════════════════════════════════════════════════ */
762
-
763
  let searchTimer = null;
764
 
765
  async function renderSidebar() {
@@ -902,10 +832,6 @@ function animateResultsReveal() {
902
  }, 180);
903
  }
904
 
905
- /* ═══════════════════════════════════════════════════════════
906
- Results Rendering
907
- ═══════════════════════════════════════════════════════════ */
908
-
909
  function renderResults() {
910
  clearResultsSkeleton();
911
  if (STATE.results.length === 0) {
@@ -917,7 +843,6 @@ function renderResults() {
917
  DOM.multiActionBar.style.display = "none";
918
  return;
919
  }
920
-
921
  DOM.emptyState.style.display = "none";
922
  DOM.multiToggleLabel.style.display = "";
923
  const cacheKey = getResultsHTMLCacheKey();
@@ -925,11 +850,9 @@ function renderResults() {
925
  VSCROLL.htmlCache = [];
926
  VSCROLL.htmlCacheKey = cacheKey;
927
  }
928
-
929
  ensureVirtualHeights(STATE.results.length);
930
  VSCROLL.renderStart = 0;
931
  VSCROLL.renderEnd = 0;
932
-
933
  if (STATE.resultsSkeletonActive) animateResultsReveal();
934
  renderVisible();
935
  }
@@ -946,7 +869,6 @@ function buildResultHTML(rec, idx) {
946
  const folderDisplay = STATE.searchFolders ? highlightText(f, STATE.query) : escapeHTML(f);
947
  return `<span class="path-sep">/</span><span class="path-folder" data-folder="${escapeHTML(accum)}" data-repo="${repoShort}">${folderDisplay}</span>`;
948
  }).join("");
949
-
950
  return `
951
  <input type="checkbox" class="result-checkbox" data-index="${idx}">
952
  <div class="result-file-icon">${ICONS[iconType] || ICONS.file}</div>
@@ -1007,7 +929,6 @@ function renderVisible() {
1007
  updateScrollTrack();
1008
  return;
1009
  }
1010
-
1011
  const container = DOM.resultsContainer;
1012
  const scrollTop = container.scrollTop;
1013
  const viewH = container.clientHeight;
@@ -1015,19 +936,14 @@ function renderVisible() {
1015
  const overscanItems = Math.max(10, Math.floor(viewH / (est || 60)));
1016
  const overscanPx = overscanItems * (est || 60);
1017
  ensurePrefixHeights();
1018
-
1019
  let start = findVirtualIndex(Math.max(0, scrollTop - overscanPx));
1020
  let end = Math.min(len, findVirtualIndex(scrollTop + viewH + overscanPx) + 1);
1021
  if (end - start < 10 && len > 10) end = Math.min(start + 30, len);
1022
-
1023
  if (start === VSCROLL.renderStart && end === VSCROLL.renderEnd) return;
1024
-
1025
  VSCROLL.renderStart = start;
1026
  VSCROLL.renderEnd = end;
1027
-
1028
  const totalH = getVirtualTotalHeight();
1029
  const topH = getVirtualOffset(start);
1030
-
1031
  let html = "";
1032
  if (topH > 0) html += '<div style="height:' + topH + 'px;flex-shrink:0"></div>';
1033
  for (let ri = start; ri < end; ri++) {
@@ -1036,13 +952,10 @@ function renderVisible() {
1036
  }
1037
  const bottomH = Math.max(0, totalH - getVirtualOffset(end));
1038
  if (bottomH > 0) html += '<div style="height:' + bottomH + 'px;flex-shrink:0"></div>';
1039
-
1040
  const tpl = document.createElement("template");
1041
  tpl.innerHTML = html;
1042
  DOM.resultsList.replaceChildren(tpl.content);
1043
-
1044
  if (DOM.multiSelectToggle && DOM.multiSelectToggle.checked) updateSelectionUI();
1045
-
1046
  requestAnimationFrame(() => {
1047
  if (measureHeights()) {
1048
  VSCROLL.renderStart = -1;
@@ -1204,16 +1117,11 @@ function updateLoadInfo() {
1204
  requestAnimationFrame(updateScrollTrack);
1205
  }
1206
 
1207
- /* ═══════════════════════════════════════════════════════════
1208
- Prefetch next page (background, fire-and-forget)
1209
- ═══════════════════════════════════════════════════════════ */
1210
-
1211
  function prefetchNextPage() {
1212
  var nextPage = STATE._loadedPage + 1;
1213
  var totalPages = Math.ceil(STATE.total / STATE.pageSize);
1214
  if (nextPage > totalPages) return;
1215
  if (STATE._pageCache[nextPage]) return;
1216
-
1217
  var reqId = searchRequestId;
1218
  var q = STATE.query || "";
1219
  var isRepo = !!STATE.repo;
@@ -1237,7 +1145,6 @@ function prefetchNextPage() {
1237
  body.sort = DOM.sortSelect.value;
1238
  if (!STATE.searchFolders) body.search_folders = false;
1239
  if (STATE.exact) body.exact = true;
1240
-
1241
  fetch(base, {
1242
  method: "POST",
1243
  headers: { "Content-Type": "application/json" },
@@ -1280,13 +1187,7 @@ function consumeCachedAppendPage() {
1280
  return true;
1281
  }
1282
 
1283
- /* ═══════════════════════════════════════════════════════════
1284
- Sidebar — Browser (Enter/Exit Navigation)
1285
- ═══════════════════════════════════════════════════════════ */
1286
-
1287
  async function doSearch(append = false) {
1288
- // ── 1. 竞态控制 + 防重 ─────────────────────────
1289
- // 新搜索时取消旧请求
1290
  if (!append) {
1291
  selectedIndices = {};
1292
  lastSelectedIndex = -1;
@@ -1304,11 +1205,7 @@ async function doSearch(append = false) {
1304
  scrollLoadTimer = null;
1305
  }
1306
  }
1307
-
1308
- // 防止同一页重复请求
1309
  if (append && STATE._pendingPage === STATE.page) return;
1310
-
1311
- // Cache hit: consume prefetched page directly, skip network
1312
  if (append && STATE._pageCache[STATE.page]) {
1313
  if (VSCROLL.isDraggingThumb) {
1314
  STATE._deferredAppendWhileDragging = true;
@@ -1319,7 +1216,6 @@ async function doSearch(append = false) {
1319
  consumeCachedAppendPage();
1320
  return;
1321
  }
1322
-
1323
  STATE.isLoading = true;
1324
  if (!append) setSearchVisualLoading(true);
1325
  STATE._pendingPage = STATE.page;
@@ -1345,7 +1241,6 @@ async function doSearch(append = false) {
1345
  if (STATE.filterMaxSize !== null) requestBody.max_size = STATE.filterMaxSize;
1346
  if (!STATE.searchFolders) requestBody.search_folders = false;
1347
  if (STATE.exact) requestBody.exact = true;
1348
-
1349
  STATE.resultsSkeletonActive = shouldShowResultsSkeleton(append);
1350
  DOM.resultsLoading.style.display = (!append && !STATE.resultsSkeletonActive) ? 'flex' : 'none';
1351
  if (!append) {
@@ -1358,16 +1253,12 @@ async function doSearch(append = false) {
1358
  clearResultsSkeleton();
1359
  }
1360
  }
1361
-
1362
  try {
1363
  const data = await API.search(requestBody);
1364
- // 如果不是最新请求,直接丢弃
1365
  if (currentRequestId !== searchRequestId) return;
1366
-
1367
  STATE.total = data.total;
1368
  STATE.didYouMean = data.did_you_mean || null;
1369
  const newItems = data.results;
1370
-
1371
  if (append) {
1372
  if (VSCROLL.isDraggingThumb) {
1373
  STATE._pageCache[data.page] = newItems;
@@ -1377,10 +1268,8 @@ async function doSearch(append = false) {
1377
  DOM.resultsLoading.style.display = 'none';
1378
  return;
1379
  }
1380
- // ── 按序缓存 + 拼接 ──────────────────────
1381
  STATE._pageCache[data.page] = newItems;
1382
  let nextPage = STATE._loadedPage + 1;
1383
-
1384
  while (STATE._pageCache[nextPage]) {
1385
  const pageItems = STATE._pageCache[nextPage];
1386
  STATE.results = STATE.results.concat(pageItems);
@@ -1393,7 +1282,6 @@ async function doSearch(append = false) {
1393
  VSCROLL.renderEnd = 0;
1394
  requestAnimationFrame(renderVisible);
1395
  } else {
1396
- // 新搜索
1397
  STATE.results = newItems;
1398
  STATE._loadedPage = 1;
1399
  STATE._pageCache = {};
@@ -1401,19 +1289,15 @@ async function doSearch(append = false) {
1401
  resetVirtualScrollState();
1402
  renderResults();
1403
  }
1404
-
1405
  STATE.hasMore = STATE.results.length < STATE.total;
1406
  updateStatusBar();
1407
  updateLoadInfo();
1408
-
1409
  if (STATE.didYouMean) {
1410
  DOM.didYouMean.textContent = `你是不是想找: ${STATE.didYouMean}`;
1411
  DOM.didYouMean.style.display = 'inline';
1412
  }
1413
-
1414
  syncStateToURL(true);
1415
  prefetchNextPage();
1416
-
1417
  } catch (err) {
1418
  if (err.name === 'AbortError') return;
1419
  if (append && currentRequestId === searchRequestId) {
@@ -1433,12 +1317,7 @@ async function doSearch(append = false) {
1433
  }
1434
  }
1435
 
1436
- /* ═══════════════════════════════════════════════════════════
1437
- Filters (Right Sidebar)
1438
- ═══════════════════════════════════════════════════════════ */
1439
-
1440
  async function renderFilters() {
1441
- // Repo filter
1442
  if (STATE.mode === "global") {
1443
  DOM.filterRepoSection.style.display = "";
1444
  if (!STATE.repoList || STATE.repoList.length === 0) try { STATE.repoList = await API.getRepos(); } catch (e) { }
@@ -1446,7 +1325,6 @@ async function renderFilters() {
1446
  } else {
1447
  DOM.filterRepoSection.style.display = "none";
1448
  }
1449
- // Folder tree
1450
  if (STATE.mode === "repo") {
1451
  DOM.filterFolderSection.style.display = "";
1452
  if (!STATE.folderTree) try {
@@ -1459,7 +1337,6 @@ async function renderFilters() {
1459
  } else {
1460
  DOM.filterFolderSection.style.display = "none";
1461
  }
1462
- // Extensions
1463
  try {
1464
  STATE.extensionList = await API.getExtensions(STATE.repo);
1465
  } catch (e) {
@@ -1470,8 +1347,6 @@ async function renderFilters() {
1470
 
1471
  function renderExtensionFilter() {
1472
  const allExt = STATE.extensionList || [];
1473
-
1474
- // Clean up extensions that don't exist in current repo
1475
  if (STATE.filterExtensions.length > 0 && allExt.length > 0) {
1476
  var availNames = {};
1477
  for (var axe = 0; axe < allExt.length; axe++) { availNames[allExt[axe].name] = true; }
@@ -1483,12 +1358,10 @@ function renderExtensionFilter() {
1483
  doSearch();
1484
  }
1485
  }
1486
-
1487
  if (!Array.isArray(allExt) || allExt.length === 0) {
1488
  DOM.filterExtList.innerHTML = '<div style="font-size:12px;color:var(--on-surface-variant);opacity:0.6;padding:4px 0">暂无</div>';
1489
  return;
1490
  }
1491
-
1492
  const ordered = [];
1493
  const rest = [];
1494
  for (const e of allExt) {
@@ -1501,13 +1374,11 @@ function renderExtensionFilter() {
1501
  }
1502
  }
1503
  ordered.sort((a, b) => a._idx - b._idx);
1504
-
1505
  const items = ordered.map(e => ({ key: e.key, label: e.label, count: e.count }));
1506
  if (rest.length > 0) {
1507
  const total = rest.reduce((s, e) => s + (e.count || 0), 0);
1508
  items.push({ key: "__OTHER__", label: `其他 (${rest.length}种)`, count: total });
1509
  }
1510
-
1511
  renderCheckboxList(DOM.filterExtList, items, STATE.filterExtensions, (vals) => {
1512
  STATE.filterExtensions = vals.filter(v => v !== "__OTHER__");
1513
  if (vals.includes("__OTHER__")) {
@@ -1577,7 +1448,6 @@ function toggleFolderChildrenAnimated(childContainer, toggle, expanding) {
1577
  toggle.getAnimations().forEach((animation) => animation.cancel());
1578
  const glyph = toggle.querySelector(".tree-toggle-glyph");
1579
  if (glyph) glyph.getAnimations().forEach((animation) => animation.cancel());
1580
-
1581
  const resetChildStyles = () => {
1582
  childContainer.style.height = "";
1583
  childContainer.style.opacity = "";
@@ -1585,7 +1455,6 @@ function toggleFolderChildrenAnimated(childContainer, toggle, expanding) {
1585
  childContainer.style.overflow = "";
1586
  childContainer.style.transition = "";
1587
  };
1588
-
1589
  const stopTransition = () => {
1590
  if (childContainer._transitionCleanup) {
1591
  childContainer.removeEventListener("transitionend", childContainer._transitionCleanup);
@@ -1596,10 +1465,8 @@ function toggleFolderChildrenAnimated(childContainer, toggle, expanding) {
1596
  childContainer._transitionTimer = null;
1597
  }
1598
  };
1599
-
1600
  stopTransition();
1601
  resetChildStyles();
1602
-
1603
  if (expanding) {
1604
  childContainer.style.display = "block";
1605
  const targetHeight = childContainer.scrollHeight;
@@ -1635,7 +1502,6 @@ function toggleFolderChildrenAnimated(childContainer, toggle, expanding) {
1635
  }
1636
  return;
1637
  }
1638
-
1639
  childContainer.style.display = "block";
1640
  const startHeight = childContainer.scrollHeight;
1641
  childContainer.style.height = `${startHeight}px`;
@@ -1756,12 +1622,10 @@ function setNodeSubtreeSelection(node, enabled, subtreeSet, selfSet) {
1756
  function persistFolderSelection(subtreeSet, selfSet) {
1757
  STATE.filterFolderSubtrees = Array.from(subtreeSet);
1758
  STATE.filterFolderSelfs = Array.from(selfSet);
1759
-
1760
  const merged = [];
1761
  selfSet.forEach(path => { if (path) merged.push(path); });
1762
  subtreeSet.forEach(path => { if (path && !merged.includes(path)) merged.push(path); });
1763
  STATE.filterFolders = merged;
1764
-
1765
  STATE.page = 1;
1766
  STATE.results = [];
1767
  doSearch();
@@ -1785,7 +1649,6 @@ function applyFolderSelectionToNode(node, row, subtreeSet, selfSet) {
1785
  const partial = isNodePartiallySelected(node, subtreeSet, selfSet);
1786
  cb.checked = full;
1787
  cb.indeterminate = !full && partial;
1788
-
1789
  const selfBtn = row.querySelector(".folder-self-toggle");
1790
  if (selfBtn) {
1791
  const selfOn = selfSet.has(node.path);
@@ -1857,10 +1720,6 @@ function handleFolderSelfToggle(node) {
1857
  renderFilterFolderTree();
1858
  }
1859
 
1860
- /* ═══════════════════════════════════════════════════════════
1861
- Hitokoto
1862
- ═══════════════════════════════════════════════════════════ */
1863
-
1864
  async function fetchHitokoto() {
1865
  try {
1866
  const resp = await fetch("https://vomebook-hitokoto.hf.space/");
@@ -1881,10 +1740,6 @@ function typewriter(el, text, speed = 60) {
1881
  }, speed);
1882
  }
1883
 
1884
- /* ═══════════════════════════════════════════════════════════
1885
- Random Book
1886
- ═══════════════════════════════════════════════════════════ */
1887
-
1888
  async function randomBook() {
1889
  try {
1890
  const rec = await API.getRandom(STATE.repo);
@@ -1900,12 +1755,8 @@ async function randomBook() {
1900
  showToast("获取失败");
1901
  }
1902
  }
1903
-
1904
- /* ═══════════════════════════════════════════════════════════
1905
- Toast
1906
- ═══════════════════════════════════════════════════════════ */
1907
-
1908
  let toastTimer;
 
1909
  function showToast(msg, dur = 2000) {
1910
  DOM.toast.textContent = msg;
1911
  DOM.toast.style.display = "";
@@ -1915,12 +1766,8 @@ function showToast(msg, dur = 2000) {
1915
  clearTimeout(toastTimer);
1916
  toastTimer = setTimeout(() => { DOM.toast.style.display = "none"; }, dur);
1917
  }
1918
-
1919
- /* ════════════════════════════════���══════════════════════════
1920
- Infinite Scroll & Quick Scroll
1921
- ═══════════════════════════════════════════════════════════ */
1922
-
1923
  scrollTicking = false;
 
1924
  function maybeLoadNextPage() {
1925
  if (VSCROLL.isDraggingThumb) return;
1926
  if (STATE.isLoading || !STATE.hasMore) return;
@@ -1987,7 +1834,6 @@ function updateScrollThumb() {
1987
  function setupQuickScroll() {
1988
  let dragging = false, startY, startST;
1989
  let dragTicking = false;
1990
-
1991
  function setResultScrollTop(value) {
1992
  DOM.resultsContainer.scrollTop = value;
1993
  if (!dragTicking) {
@@ -1998,7 +1844,6 @@ function setupQuickScroll() {
1998
  });
1999
  }
2000
  }
2001
-
2002
  function onMouseMove(e) {
2003
  const delta = e.clientY - startY;
2004
  const dragRange = Math.max(1, DOM.scrollTrack.clientHeight - DOM.scrollThumb.clientHeight);
@@ -2006,7 +1851,6 @@ function setupQuickScroll() {
2006
  const scrollEl = DOM.resultsContainer;
2007
  setResultScrollTop(startST + ratio * Math.max(1, scrollEl.scrollHeight - scrollEl.clientHeight));
2008
  }
2009
-
2010
  function onMouseUp() {
2011
  dragging = false;
2012
  VSCROLL.isDraggingThumb = false;
@@ -2018,14 +1862,12 @@ function setupQuickScroll() {
2018
  }
2019
  maybeLoadNextPage();
2020
  }
2021
-
2022
  DOM.scrollThumb.addEventListener("mousedown", (e) => {
2023
  if (dragging) return;
2024
  dragging = true; VSCROLL.isDraggingThumb = true; startY = e.clientY; startST = DOM.resultsContainer.scrollTop; e.preventDefault(); e.stopPropagation();
2025
  document.addEventListener("mousemove", onMouseMove);
2026
  document.addEventListener("mouseup", onMouseUp);
2027
  });
2028
-
2029
  function onTouchMove(e) {
2030
  e.preventDefault();
2031
  const delta = e.touches[0].clientY - startY;
@@ -2034,7 +1876,6 @@ function setupQuickScroll() {
2034
  const scrollEl = DOM.resultsContainer;
2035
  setResultScrollTop(startST + ratio * Math.max(1, scrollEl.scrollHeight - scrollEl.clientHeight));
2036
  }
2037
-
2038
  function onTouchEnd() {
2039
  dragging = false;
2040
  VSCROLL.isDraggingThumb = false;
@@ -2047,7 +1888,6 @@ function setupQuickScroll() {
2047
  }
2048
  maybeLoadNextPage();
2049
  }
2050
-
2051
  DOM.scrollThumb.addEventListener("touchstart", (e) => {
2052
  dragging = true; VSCROLL.isDraggingThumb = true; startY = e.touches[0].clientY; startST = DOM.resultsContainer.scrollTop; e.stopPropagation();
2053
  document.addEventListener("touchmove", onTouchMove, { passive: false });
@@ -2056,10 +1896,6 @@ function setupQuickScroll() {
2056
  });
2057
  }
2058
 
2059
- /* ═══════════════════════════════════════════════════════════
2060
- Theme & Mobile
2061
- ═══════════════════════════════════════════════════════════ */
2062
-
2063
  function toggleTheme() {
2064
  const btn = DOM.themeBtn;
2065
  const rect = btn.getBoundingClientRect();
@@ -2072,21 +1908,22 @@ function toggleTheme() {
2072
  ripple.style.marginTop = "-0px";
2073
  document.body.appendChild(ripple);
2074
  document.body.classList.add("theme-transitioning");
2075
-
2076
  STATE.isDark = !STATE.isDark;
2077
  applyTheme();
2078
  localStorage.setItem("theme", STATE.isDark ? "dark" : "light");
2079
-
2080
  ripple.addEventListener("animationend", () => {
2081
  ripple.remove();
2082
  document.body.classList.remove("theme-transitioning");
2083
  });
2084
  }
 
2085
  function applyTheme() {
2086
  if (STATE.isDark) { document.body.classList.remove("light"); DOM.themeIconLight.style.display = "none"; DOM.themeIconDark.style.display = ""; }
2087
  else { document.body.classList.add("light"); DOM.themeIconLight.style.display = ""; DOM.themeIconDark.style.display = "none"; }
2088
  }
 
2089
  function toggleMobile() { STATE.isMobile = !STATE.isMobile; applyMobileMode(); localStorage.setItem("mobileMode", STATE.isMobile ? "mobile" : "desktop"); }
 
2090
  function applyMobileMode() {
2091
  if (STATE.isMobile) {
2092
  document.body.classList.add("mobile"); document.body.classList.remove("force-desktop");
@@ -2102,11 +1939,8 @@ function applyMobileMode() {
2102
  updateSelectionUI();
2103
  requestAnimationFrame(updateScrollTrack);
2104
  }
2105
- function autoDetectMobile() { return window.innerWidth <= 768; }
2106
 
2107
- /* ═══════════════════════════════════════════════════════════
2108
- Sidebar Visibility
2109
- ═══════════════════════════════════════════════════════════ */
2110
 
2111
  function toggleLeftSidebar() {
2112
  STATE.leftSidebarOpen = !STATE.leftSidebarOpen;
@@ -2118,12 +1952,14 @@ function toggleLeftSidebar() {
2118
  if (STATE.isMobile && STATE.leftSidebarOpen && STATE.rightSidebarOpen) STATE.rightSidebarOpen = false;
2119
  updateSidebarVisibility();
2120
  }
 
2121
  function toggleRightSidebar() {
2122
  STATE.rightSidebarOpen = !STATE.rightSidebarOpen;
2123
  if (STATE.rightSidebarOpen && STATE.leftSidebarOpen && STATE.isMobile) STATE.leftSidebarOpen = false;
2124
  updateSidebarVisibility();
2125
  syncStateToURL(true);
2126
  }
 
2127
  function updateSidebarVisibility() {
2128
  DOM.leftSidebar.classList.toggle("collapsed", !STATE.leftSidebarOpen);
2129
  DOM.leftSidebar.classList.toggle("open", STATE.leftSidebarOpen);
@@ -2131,12 +1967,8 @@ function updateSidebarVisibility() {
2131
  DOM.rightSidebar.classList.toggle("open", STATE.rightSidebarOpen);
2132
  DOM.overlay.style.display = (STATE.isMobile && (STATE.leftSidebarOpen || STATE.rightSidebarOpen)) ? "" : "none";
2133
  }
2134
-
2135
- /* ═══════════════════════════════════════════════════════════
2136
- Keyboard
2137
- ═══════════════════════════════════════════════════════════ */
2138
-
2139
  let keyboardResultIndex = -1;
 
2140
  function focusKeyboardResult(index) {
2141
  keyboardResultIndex = Math.max(0, Math.min(index, STATE.results.length - 1));
2142
  const top = getVirtualOffset(keyboardResultIndex);
@@ -2195,10 +2027,6 @@ function setupKeyboard() {
2195
  });
2196
  }
2197
 
2198
- /* ═══════════════════════════════════════════════════════════
2199
- Clear Filters
2200
- ═══════════════════════════════════════════════════════════ */
2201
-
2202
  function clearAllFilters() {
2203
  STATE.filterRepos = []; STATE.filterExtensions = []; STATE.filterFolders = []; STATE.filterFolderSubtrees = []; STATE.filterFolderSelfs = [];
2204
  STATE.filterMinSize = null; STATE.filterMaxSize = null;
@@ -2209,10 +2037,6 @@ function clearAllFilters() {
2209
  syncStateToURL(true);
2210
  }
2211
 
2212
- /* ═══════════════════════════════════════════════════════════
2213
- Event Delegation
2214
- ═══════════════════════════════════════════════════════════ */
2215
-
2216
  function setupResultDelegation() {
2217
  DOM.resultsList.addEventListener("click", async (e) => {
2218
  const actionBtn = e.target.closest("[data-action]");
@@ -2268,10 +2092,6 @@ function setupResultDelegation() {
2268
  });
2269
  }
2270
 
2271
- /* ═══════════════════════════════════════════════════════════
2272
- Init
2273
- ═══════════════════════════════════════════════════════════ */
2274
-
2275
  async function init() {
2276
  cacheDOM();
2277
  STATE.isDark = localStorage.getItem("theme") !== "light";
@@ -2281,9 +2101,7 @@ async function init() {
2281
  else if (savedMobile === "desktop") STATE.isMobile = false;
2282
  else STATE.isMobile = autoDetectMobile();
2283
  applyMobileMode();
2284
-
2285
  DOM.searchInput.addEventListener("input", debouncedSearch);
2286
-
2287
  const renderDropdown = () => {
2288
  var list = getHistory();
2289
  if (list.length === 0) { DOM.historyDropdown.style.display = "none"; return; }
@@ -2305,7 +2123,6 @@ async function init() {
2305
  var dropdownActive = false;
2306
  DOM.searchInput.addEventListener("focus", () => { renderDropdown(); });
2307
  DOM.searchInput.addEventListener("blur", hideDropdown);
2308
-
2309
  var longPressTimer = null;
2310
  DOM.historyDropdown.addEventListener("mouseenter", () => { dropdownActive = true; });
2311
  DOM.historyDropdown.addEventListener("mouseleave", () => { dropdownActive = false; });
@@ -2348,10 +2165,7 @@ async function init() {
2348
  syncStateToURL(true);
2349
  if (STATE.results.length > 0) renderResults();
2350
  });
2351
-
2352
- // Multi-select
2353
  if (DOM.multiSelectToggle) DOM.multiSelectToggle.addEventListener("change", updateSelectionUI);
2354
-
2355
  DOM.resultsList.addEventListener("click", (e) => {
2356
  if (!DOM.multiSelectToggle || !DOM.multiSelectToggle.checked) return;
2357
  var cb = e.target.closest(".result-checkbox");
@@ -2370,7 +2184,6 @@ async function init() {
2370
  lastSelectedIndex = idx;
2371
  updateSelectionUI();
2372
  });
2373
-
2374
  if (DOM.multiCopyLinks) DOM.multiCopyLinks.addEventListener("click", () => {
2375
  var indices = Object.keys(selectedIndices).map(Number);
2376
  var links = [];
@@ -2381,7 +2194,6 @@ async function init() {
2381
  if (links.length === 0) { showToast("未选中任何文件"); return; }
2382
  navigator.clipboard.writeText(links.join("\n")).then(() => showToast("已复制 " + links.length + " 条链接")).catch(() => showToast("复制失败"));
2383
  });
2384
-
2385
  if (DOM.multiBatchDownload) DOM.multiBatchDownload.addEventListener("click", () => {
2386
  var indices = Object.keys(selectedIndices).map(Number);
2387
  if (indices.length === 0) { showToast("未选中任何文件"); return; }
@@ -2392,29 +2204,24 @@ async function init() {
2392
  }
2393
  showToast("正在下载 " + indices.length + " 个文件");
2394
  });
2395
-
2396
  if (DOM.multiDeselect) DOM.multiDeselect.addEventListener("click", () => {
2397
  selectedIndices = {};
2398
  lastSelectedIndex = -1;
2399
  updateSelectionUI();
2400
  });
2401
-
2402
  if (DOM.multiSelectAll) DOM.multiSelectAll.addEventListener("click", () => {
2403
  for (var si = 0; si < STATE.results.length; si++) selectedIndices[si] = true;
2404
  lastSelectedIndex = STATE.results.length > 0 ? STATE.results.length - 1 : -1;
2405
  updateSelectionUI();
2406
  });
2407
-
2408
  DOM.hamburgerBtn.addEventListener("click", toggleLeftSidebar);
2409
  DOM.settingsBtn.addEventListener("click", toggleRightSidebar);
2410
  DOM.closeFiltersBtn.addEventListener("click", () => { STATE.rightSidebarOpen = false; updateSidebarVisibility(); syncStateToURL(true); });
2411
-
2412
  DOM.sidebarExpandBtn.addEventListener("click", () => {
2413
  DOM.leftSidebar.classList.toggle("expanded-wide");
2414
  DOM.sidebarExpandBtn.textContent = DOM.leftSidebar.classList.contains("expanded-wide") ? "→" : "↔";
2415
  syncStateToURL(true);
2416
  });
2417
-
2418
  DOM.themeBtn.addEventListener("click", toggleTheme);
2419
  DOM.mobileToggleBtn.addEventListener("click", toggleMobile);
2420
  DOM.clearFiltersBtn.addEventListener("click", clearAllFilters);
@@ -2439,7 +2246,6 @@ async function init() {
2439
  DOM.overlay.addEventListener("click", () => { STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; updateSidebarVisibility(); syncStateToURL(true); });
2440
  DOM.randomBookBtn.addEventListener('click', randomBook);
2441
  DOM.emptyRandomBtn.addEventListener('click', randomBook);
2442
-
2443
  let sizeTimer;
2444
  const sizeInputToBytes = (input, unitSelect) => {
2445
  var val = parseFloat(input.value);
@@ -2455,12 +2261,10 @@ async function init() {
2455
  DOM.filterMaxSize.addEventListener("input", applySizeFilter);
2456
  DOM.filterMinUnit.addEventListener("change", applySizeFilter);
2457
  DOM.filterMaxUnit.addEventListener("change", applySizeFilter);
2458
-
2459
  DOM.extSelectAll.addEventListener("click", () => { STATE.filterExtensions = (STATE.extensionList || []).map(e => e.name); STATE.page = 1; STATE.results = []; doSearch(); renderExtensionFilter(); });
2460
  DOM.extDeselectAll.addEventListener("click", () => {
2461
  const allExtNames = (STATE.extensionList || []).map(e => e.name);
2462
  const currentSet = new Set(STATE.filterExtensions);
2463
- // 反选:当前选中的取消,未选中的选中
2464
  STATE.filterExtensions = allExtNames.filter(e => !currentSet.has(e));
2465
  STATE.page = 1;
2466
  STATE.results = [];
@@ -2484,7 +2288,6 @@ async function init() {
2484
  const allSubtreePaths = [];
2485
  const allSelfPaths = [];
2486
  collectFolderNodePaths(STATE.folderTree, allSubtreePaths, allSelfPaths);
2487
-
2488
  const nextSubtreeSet = new Set();
2489
  const nextSelfSet = new Set();
2490
  for (let i = 0; i < allSubtreePaths.length; i++) {
@@ -2493,24 +2296,18 @@ async function init() {
2493
  for (let i = 0; i < allSelfPaths.length; i++) {
2494
  if (!selfSet.has(allSelfPaths[i])) nextSelfSet.add(allSelfPaths[i]);
2495
  }
2496
-
2497
  persistFolderSelection(nextSubtreeSet, nextSelfSet);
2498
  renderFilterFolderTree();
2499
  });
2500
-
2501
  DOM.didYouMean.addEventListener("click", () => { if (STATE.didYouMean) { DOM.searchInput.value = STATE.didYouMean; STATE.query = STATE.didYouMean; STATE.page = 1; STATE.results = []; addHistoryItem(STATE.query); doSearch(); } });
2502
-
2503
  setupInfiniteScroll();
2504
  setupQuickScroll();
2505
  setupKeyboard();
2506
  setupResultDelegation();
2507
-
2508
  window.addEventListener("popstate", () => ROUTER.apply());
2509
  window.addEventListener("resize", () => { if (!localStorage.getItem("mobileMode")) { const wm = STATE.isMobile; STATE.isMobile = autoDetectMobile(); if (wm !== STATE.isMobile) applyMobileMode(); } });
2510
-
2511
  ROUTER.apply();
2512
  fetchHitokoto();
2513
  setInterval(fetchHitokoto, 30000);
2514
  }
2515
-
2516
  document.addEventListener("DOMContentLoaded", init);
 
 
 
 
 
 
 
 
 
 
 
1
  const ORDERED_EXTENSIONS = [
2
  "pdf", "txt",
3
  "epub", "mobi", "azw3", "fb2", "djvu", "chm", "caj",
 
50
  file: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
51
  database: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>',
52
  };
 
53
  const MIRROR_HOST = "hf-mirror.com";
54
 
 
 
 
 
55
  const STATE = {
56
  mode: "global",
57
  repo: null,
 
91
  _pageCache: {},
92
  _deferredAppendWhileDragging: false,
93
  };
 
94
  let searchAbortController = null;
95
  let searchRequestId = 0;
96
  let scrollTicking = false;
 
109
  isDraggingThumb: false,
110
  };
111
 
 
 
 
 
112
  function bytesToDisplay(bytes) {
113
  if (bytes === null || bytes === undefined || bytes === 0) return { value: "", unit: "MB" };
114
  if (bytes >= 1073741824) return { value: (bytes / 1073741824).toFixed(2).replace(/\.?0+$/, ""), unit: "GB" };
 
134
  else if (unit === "KB") val *= 1024;
135
  return Math.round(val);
136
  }
 
 
 
 
 
137
  var HISTORY_KEY = "voml_search_history";
138
  var HISTORY_MAX = 20;
139
 
 
180
  saveHistory(list);
181
  renderDropdown();
182
  }
 
183
  var selectedIndices = {};
184
  var lastSelectedIndex = -1;
185
 
 
210
  }
211
  }
212
 
 
 
 
 
213
  const $ = (sel) => document.querySelector(sel);
214
+
215
  const DOM = {};
216
 
217
  function cacheDOM() {
 
280
  DOM.multiDeselect = $("#multi-deselect");
281
  }
282
 
 
 
 
 
283
  const HTML_ESCAPE_MAP = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
284
+
285
  function escapeHTML(str) {
286
  return String(str).replace(/[&<>"']/g, (ch) => HTML_ESCAPE_MAP[ch]);
287
  }
 
288
  const sizeCache = {};
289
+
290
  function formatSize(bytes) {
291
  if (!bytes && bytes !== 0) return "";
292
  if (sizeCache[bytes] !== undefined) return sizeCache[bytes];
 
410
  return `https://huggingface.co/datasets/${rec.Repo || ""}/blob/main/${encodeRecordPath(buildRecordRelativePath(rec))}`;
411
  }
412
 
 
 
 
 
413
  const API = {
414
  async search(params = {}) {
415
  const q = params.q || "";
 
436
  body.sort = DOM.sortSelect.value;
437
  if (!STATE.searchFolders) body.search_folders = false;
438
  if (STATE.exact) body.exact = true;
 
439
  const fetchOptions = {
440
  method: "POST",
441
  headers: { "Content-Type": "application/json" },
442
  body: JSON.stringify(body),
443
  };
444
  if (params.signal) fetchOptions.signal = params.signal;
 
445
  const resp = await fetch(base, fetchOptions);
446
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
447
  return resp.json();
 
504
  else setTimeout(run, 200);
505
  }
506
 
 
 
 
 
507
  const ROUTER = {
508
  parse() {
509
  const path = window.location.pathname.replace(/\/+$/, "");
 
539
  STATE.mode = route.mode;
540
  STATE.repo = route.repo;
541
  STATE.repoFull = route.repo ? `VoiceOfML/${route.repo}` : null;
 
542
  if (prevMode !== STATE.mode || prevRepo !== STATE.repo) {
543
  STATE.page = 1;
544
  STATE.results = [];
 
552
  API._extCache = null;
553
  syncURLToState();
554
  }
 
555
  this.updateUI();
 
556
  if (prevMode !== STATE.mode || prevRepo !== STATE.repo) {
557
  const wasWide = new URLSearchParams(window.location.search).get("wide") === "1";
558
  this.onModeChanged();
 
562
  syncStateToURL(true);
563
  }
564
  } else {
 
565
  syncURLToState();
566
  renderSidebar();
567
  renderFilters();
 
582
  }
583
  },
584
  onModeChanged() {
 
585
  DOM.leftSidebar.classList.remove("expanded-wide");
586
  DOM.sidebarExpandBtn.textContent = "↔";
587
  if (!STATE.isMobile) {
 
596
  },
597
  };
598
 
 
 
599
  function syncStateToURL(replace = true) {
600
  const params = new URLSearchParams();
 
601
  if (STATE.query) params.set("q", STATE.query);
602
  if (STATE.mode === "global") {
603
  STATE.filterRepos.forEach(r => {
 
624
  if (!STATE.searchFolders) params.set("search_folders", "false");
625
  if (!STATE.exact) params.set("exact", "0");
626
  if (!STATE.recordHistory) params.set("history", "0");
 
627
  const qs = params.toString();
628
  const path = STATE.mode === "global" ? "/" : `/${STATE.repo}`;
629
  const url = qs ? `${path}?${qs}` : path;
 
630
  if (replace) {
631
  history.replaceState(null, "", url);
632
  } else {
 
636
 
637
  function syncURLToState() {
638
  const params = new URLSearchParams(window.location.search);
 
639
  STATE.query = params.get("q") || "";
640
  DOM.searchInput.value = STATE.query;
 
641
  STATE.filterRepos = params.getAll("repo").map(r => `VoiceOfML/${r}`);
 
642
  const extStr = params.get("ext") || "";
643
  STATE.filterExtensions = extStr ? extStr.split(",").filter(Boolean) : [];
 
644
  STATE.browserPath = params.get("path") || "";
 
645
  const sort = params.get("sort") || "relevance";
646
  DOM.sortSelect.value = sort;
 
647
  const minSize = params.get("min_size");
648
  if (minSize) {
649
  var parsed = parseSizeStr(minSize);
 
656
  DOM.filterMinSize.value = "";
657
  DOM.filterMinUnit.value = "MB";
658
  }
 
659
  const maxSize = params.get("max_size");
660
  if (maxSize) {
661
  var parsedMx = parseSizeStr(maxSize);
 
668
  DOM.filterMaxSize.value = "";
669
  DOM.filterMaxUnit.value = "MB";
670
  }
 
671
  const searchFolders = params.get("search_folders");
672
  STATE.searchFolders = searchFolders !== "false";
673
  DOM.searchFoldersToggle.checked = STATE.searchFolders;
 
674
  STATE.exact = params.get("exact") !== "0";
675
  DOM.exactSearchToggle.checked = STATE.exact;
 
676
  STATE.recordHistory = params.get("history") !== "0";
677
  if (DOM.historyToggle) DOM.historyToggle.checked = STATE.recordHistory;
678
  STATE.useMirrorLinks = params.get("mirror") !== "0";
679
  if (DOM.mirrorLinksToggle) DOM.mirrorLinksToggle.checked = STATE.useMirrorLinks;
 
680
  if (params.get("wide") === "1") {
681
  DOM.leftSidebar.classList.add("expanded-wide");
682
  DOM.sidebarExpandBtn.textContent = "→";
683
  }
 
684
  if (params.get("sidebar") === "0") {
685
  STATE.leftSidebarOpen = false;
686
  updateSidebarVisibility();
687
  }
688
  STATE.rightSidebarOpen = params.get("filters") === "1";
689
  updateSidebarVisibility();
 
690
  STATE.page = 1;
691
  STATE.results = [];
692
  }
 
 
 
 
 
693
  let searchTimer = null;
694
 
695
  async function renderSidebar() {
 
832
  }, 180);
833
  }
834
 
 
 
 
 
835
  function renderResults() {
836
  clearResultsSkeleton();
837
  if (STATE.results.length === 0) {
 
843
  DOM.multiActionBar.style.display = "none";
844
  return;
845
  }
 
846
  DOM.emptyState.style.display = "none";
847
  DOM.multiToggleLabel.style.display = "";
848
  const cacheKey = getResultsHTMLCacheKey();
 
850
  VSCROLL.htmlCache = [];
851
  VSCROLL.htmlCacheKey = cacheKey;
852
  }
 
853
  ensureVirtualHeights(STATE.results.length);
854
  VSCROLL.renderStart = 0;
855
  VSCROLL.renderEnd = 0;
 
856
  if (STATE.resultsSkeletonActive) animateResultsReveal();
857
  renderVisible();
858
  }
 
869
  const folderDisplay = STATE.searchFolders ? highlightText(f, STATE.query) : escapeHTML(f);
870
  return `<span class="path-sep">/</span><span class="path-folder" data-folder="${escapeHTML(accum)}" data-repo="${repoShort}">${folderDisplay}</span>`;
871
  }).join("");
 
872
  return `
873
  <input type="checkbox" class="result-checkbox" data-index="${idx}">
874
  <div class="result-file-icon">${ICONS[iconType] || ICONS.file}</div>
 
929
  updateScrollTrack();
930
  return;
931
  }
 
932
  const container = DOM.resultsContainer;
933
  const scrollTop = container.scrollTop;
934
  const viewH = container.clientHeight;
 
936
  const overscanItems = Math.max(10, Math.floor(viewH / (est || 60)));
937
  const overscanPx = overscanItems * (est || 60);
938
  ensurePrefixHeights();
 
939
  let start = findVirtualIndex(Math.max(0, scrollTop - overscanPx));
940
  let end = Math.min(len, findVirtualIndex(scrollTop + viewH + overscanPx) + 1);
941
  if (end - start < 10 && len > 10) end = Math.min(start + 30, len);
 
942
  if (start === VSCROLL.renderStart && end === VSCROLL.renderEnd) return;
 
943
  VSCROLL.renderStart = start;
944
  VSCROLL.renderEnd = end;
 
945
  const totalH = getVirtualTotalHeight();
946
  const topH = getVirtualOffset(start);
 
947
  let html = "";
948
  if (topH > 0) html += '<div style="height:' + topH + 'px;flex-shrink:0"></div>';
949
  for (let ri = start; ri < end; ri++) {
 
952
  }
953
  const bottomH = Math.max(0, totalH - getVirtualOffset(end));
954
  if (bottomH > 0) html += '<div style="height:' + bottomH + 'px;flex-shrink:0"></div>';
 
955
  const tpl = document.createElement("template");
956
  tpl.innerHTML = html;
957
  DOM.resultsList.replaceChildren(tpl.content);
 
958
  if (DOM.multiSelectToggle && DOM.multiSelectToggle.checked) updateSelectionUI();
 
959
  requestAnimationFrame(() => {
960
  if (measureHeights()) {
961
  VSCROLL.renderStart = -1;
 
1117
  requestAnimationFrame(updateScrollTrack);
1118
  }
1119
 
 
 
 
 
1120
  function prefetchNextPage() {
1121
  var nextPage = STATE._loadedPage + 1;
1122
  var totalPages = Math.ceil(STATE.total / STATE.pageSize);
1123
  if (nextPage > totalPages) return;
1124
  if (STATE._pageCache[nextPage]) return;
 
1125
  var reqId = searchRequestId;
1126
  var q = STATE.query || "";
1127
  var isRepo = !!STATE.repo;
 
1145
  body.sort = DOM.sortSelect.value;
1146
  if (!STATE.searchFolders) body.search_folders = false;
1147
  if (STATE.exact) body.exact = true;
 
1148
  fetch(base, {
1149
  method: "POST",
1150
  headers: { "Content-Type": "application/json" },
 
1187
  return true;
1188
  }
1189
 
 
 
 
 
1190
  async function doSearch(append = false) {
 
 
1191
  if (!append) {
1192
  selectedIndices = {};
1193
  lastSelectedIndex = -1;
 
1205
  scrollLoadTimer = null;
1206
  }
1207
  }
 
 
1208
  if (append && STATE._pendingPage === STATE.page) return;
 
 
1209
  if (append && STATE._pageCache[STATE.page]) {
1210
  if (VSCROLL.isDraggingThumb) {
1211
  STATE._deferredAppendWhileDragging = true;
 
1216
  consumeCachedAppendPage();
1217
  return;
1218
  }
 
1219
  STATE.isLoading = true;
1220
  if (!append) setSearchVisualLoading(true);
1221
  STATE._pendingPage = STATE.page;
 
1241
  if (STATE.filterMaxSize !== null) requestBody.max_size = STATE.filterMaxSize;
1242
  if (!STATE.searchFolders) requestBody.search_folders = false;
1243
  if (STATE.exact) requestBody.exact = true;
 
1244
  STATE.resultsSkeletonActive = shouldShowResultsSkeleton(append);
1245
  DOM.resultsLoading.style.display = (!append && !STATE.resultsSkeletonActive) ? 'flex' : 'none';
1246
  if (!append) {
 
1253
  clearResultsSkeleton();
1254
  }
1255
  }
 
1256
  try {
1257
  const data = await API.search(requestBody);
 
1258
  if (currentRequestId !== searchRequestId) return;
 
1259
  STATE.total = data.total;
1260
  STATE.didYouMean = data.did_you_mean || null;
1261
  const newItems = data.results;
 
1262
  if (append) {
1263
  if (VSCROLL.isDraggingThumb) {
1264
  STATE._pageCache[data.page] = newItems;
 
1268
  DOM.resultsLoading.style.display = 'none';
1269
  return;
1270
  }
 
1271
  STATE._pageCache[data.page] = newItems;
1272
  let nextPage = STATE._loadedPage + 1;
 
1273
  while (STATE._pageCache[nextPage]) {
1274
  const pageItems = STATE._pageCache[nextPage];
1275
  STATE.results = STATE.results.concat(pageItems);
 
1282
  VSCROLL.renderEnd = 0;
1283
  requestAnimationFrame(renderVisible);
1284
  } else {
 
1285
  STATE.results = newItems;
1286
  STATE._loadedPage = 1;
1287
  STATE._pageCache = {};
 
1289
  resetVirtualScrollState();
1290
  renderResults();
1291
  }
 
1292
  STATE.hasMore = STATE.results.length < STATE.total;
1293
  updateStatusBar();
1294
  updateLoadInfo();
 
1295
  if (STATE.didYouMean) {
1296
  DOM.didYouMean.textContent = `你是不是想找: ${STATE.didYouMean}`;
1297
  DOM.didYouMean.style.display = 'inline';
1298
  }
 
1299
  syncStateToURL(true);
1300
  prefetchNextPage();
 
1301
  } catch (err) {
1302
  if (err.name === 'AbortError') return;
1303
  if (append && currentRequestId === searchRequestId) {
 
1317
  }
1318
  }
1319
 
 
 
 
 
1320
  async function renderFilters() {
 
1321
  if (STATE.mode === "global") {
1322
  DOM.filterRepoSection.style.display = "";
1323
  if (!STATE.repoList || STATE.repoList.length === 0) try { STATE.repoList = await API.getRepos(); } catch (e) { }
 
1325
  } else {
1326
  DOM.filterRepoSection.style.display = "none";
1327
  }
 
1328
  if (STATE.mode === "repo") {
1329
  DOM.filterFolderSection.style.display = "";
1330
  if (!STATE.folderTree) try {
 
1337
  } else {
1338
  DOM.filterFolderSection.style.display = "none";
1339
  }
 
1340
  try {
1341
  STATE.extensionList = await API.getExtensions(STATE.repo);
1342
  } catch (e) {
 
1347
 
1348
  function renderExtensionFilter() {
1349
  const allExt = STATE.extensionList || [];
 
 
1350
  if (STATE.filterExtensions.length > 0 && allExt.length > 0) {
1351
  var availNames = {};
1352
  for (var axe = 0; axe < allExt.length; axe++) { availNames[allExt[axe].name] = true; }
 
1358
  doSearch();
1359
  }
1360
  }
 
1361
  if (!Array.isArray(allExt) || allExt.length === 0) {
1362
  DOM.filterExtList.innerHTML = '<div style="font-size:12px;color:var(--on-surface-variant);opacity:0.6;padding:4px 0">暂无</div>';
1363
  return;
1364
  }
 
1365
  const ordered = [];
1366
  const rest = [];
1367
  for (const e of allExt) {
 
1374
  }
1375
  }
1376
  ordered.sort((a, b) => a._idx - b._idx);
 
1377
  const items = ordered.map(e => ({ key: e.key, label: e.label, count: e.count }));
1378
  if (rest.length > 0) {
1379
  const total = rest.reduce((s, e) => s + (e.count || 0), 0);
1380
  items.push({ key: "__OTHER__", label: `其他 (${rest.length}种)`, count: total });
1381
  }
 
1382
  renderCheckboxList(DOM.filterExtList, items, STATE.filterExtensions, (vals) => {
1383
  STATE.filterExtensions = vals.filter(v => v !== "__OTHER__");
1384
  if (vals.includes("__OTHER__")) {
 
1448
  toggle.getAnimations().forEach((animation) => animation.cancel());
1449
  const glyph = toggle.querySelector(".tree-toggle-glyph");
1450
  if (glyph) glyph.getAnimations().forEach((animation) => animation.cancel());
 
1451
  const resetChildStyles = () => {
1452
  childContainer.style.height = "";
1453
  childContainer.style.opacity = "";
 
1455
  childContainer.style.overflow = "";
1456
  childContainer.style.transition = "";
1457
  };
 
1458
  const stopTransition = () => {
1459
  if (childContainer._transitionCleanup) {
1460
  childContainer.removeEventListener("transitionend", childContainer._transitionCleanup);
 
1465
  childContainer._transitionTimer = null;
1466
  }
1467
  };
 
1468
  stopTransition();
1469
  resetChildStyles();
 
1470
  if (expanding) {
1471
  childContainer.style.display = "block";
1472
  const targetHeight = childContainer.scrollHeight;
 
1502
  }
1503
  return;
1504
  }
 
1505
  childContainer.style.display = "block";
1506
  const startHeight = childContainer.scrollHeight;
1507
  childContainer.style.height = `${startHeight}px`;
 
1622
  function persistFolderSelection(subtreeSet, selfSet) {
1623
  STATE.filterFolderSubtrees = Array.from(subtreeSet);
1624
  STATE.filterFolderSelfs = Array.from(selfSet);
 
1625
  const merged = [];
1626
  selfSet.forEach(path => { if (path) merged.push(path); });
1627
  subtreeSet.forEach(path => { if (path && !merged.includes(path)) merged.push(path); });
1628
  STATE.filterFolders = merged;
 
1629
  STATE.page = 1;
1630
  STATE.results = [];
1631
  doSearch();
 
1649
  const partial = isNodePartiallySelected(node, subtreeSet, selfSet);
1650
  cb.checked = full;
1651
  cb.indeterminate = !full && partial;
 
1652
  const selfBtn = row.querySelector(".folder-self-toggle");
1653
  if (selfBtn) {
1654
  const selfOn = selfSet.has(node.path);
 
1720
  renderFilterFolderTree();
1721
  }
1722
 
 
 
 
 
1723
  async function fetchHitokoto() {
1724
  try {
1725
  const resp = await fetch("https://vomebook-hitokoto.hf.space/");
 
1740
  }, speed);
1741
  }
1742
 
 
 
 
 
1743
  async function randomBook() {
1744
  try {
1745
  const rec = await API.getRandom(STATE.repo);
 
1755
  showToast("获取失败");
1756
  }
1757
  }
 
 
 
 
 
1758
  let toastTimer;
1759
+
1760
  function showToast(msg, dur = 2000) {
1761
  DOM.toast.textContent = msg;
1762
  DOM.toast.style.display = "";
 
1766
  clearTimeout(toastTimer);
1767
  toastTimer = setTimeout(() => { DOM.toast.style.display = "none"; }, dur);
1768
  }
 
 
 
 
 
1769
  scrollTicking = false;
1770
+
1771
  function maybeLoadNextPage() {
1772
  if (VSCROLL.isDraggingThumb) return;
1773
  if (STATE.isLoading || !STATE.hasMore) return;
 
1834
  function setupQuickScroll() {
1835
  let dragging = false, startY, startST;
1836
  let dragTicking = false;
 
1837
  function setResultScrollTop(value) {
1838
  DOM.resultsContainer.scrollTop = value;
1839
  if (!dragTicking) {
 
1844
  });
1845
  }
1846
  }
 
1847
  function onMouseMove(e) {
1848
  const delta = e.clientY - startY;
1849
  const dragRange = Math.max(1, DOM.scrollTrack.clientHeight - DOM.scrollThumb.clientHeight);
 
1851
  const scrollEl = DOM.resultsContainer;
1852
  setResultScrollTop(startST + ratio * Math.max(1, scrollEl.scrollHeight - scrollEl.clientHeight));
1853
  }
 
1854
  function onMouseUp() {
1855
  dragging = false;
1856
  VSCROLL.isDraggingThumb = false;
 
1862
  }
1863
  maybeLoadNextPage();
1864
  }
 
1865
  DOM.scrollThumb.addEventListener("mousedown", (e) => {
1866
  if (dragging) return;
1867
  dragging = true; VSCROLL.isDraggingThumb = true; startY = e.clientY; startST = DOM.resultsContainer.scrollTop; e.preventDefault(); e.stopPropagation();
1868
  document.addEventListener("mousemove", onMouseMove);
1869
  document.addEventListener("mouseup", onMouseUp);
1870
  });
 
1871
  function onTouchMove(e) {
1872
  e.preventDefault();
1873
  const delta = e.touches[0].clientY - startY;
 
1876
  const scrollEl = DOM.resultsContainer;
1877
  setResultScrollTop(startST + ratio * Math.max(1, scrollEl.scrollHeight - scrollEl.clientHeight));
1878
  }
 
1879
  function onTouchEnd() {
1880
  dragging = false;
1881
  VSCROLL.isDraggingThumb = false;
 
1888
  }
1889
  maybeLoadNextPage();
1890
  }
 
1891
  DOM.scrollThumb.addEventListener("touchstart", (e) => {
1892
  dragging = true; VSCROLL.isDraggingThumb = true; startY = e.touches[0].clientY; startST = DOM.resultsContainer.scrollTop; e.stopPropagation();
1893
  document.addEventListener("touchmove", onTouchMove, { passive: false });
 
1896
  });
1897
  }
1898
 
 
 
 
 
1899
  function toggleTheme() {
1900
  const btn = DOM.themeBtn;
1901
  const rect = btn.getBoundingClientRect();
 
1908
  ripple.style.marginTop = "-0px";
1909
  document.body.appendChild(ripple);
1910
  document.body.classList.add("theme-transitioning");
 
1911
  STATE.isDark = !STATE.isDark;
1912
  applyTheme();
1913
  localStorage.setItem("theme", STATE.isDark ? "dark" : "light");
 
1914
  ripple.addEventListener("animationend", () => {
1915
  ripple.remove();
1916
  document.body.classList.remove("theme-transitioning");
1917
  });
1918
  }
1919
+
1920
  function applyTheme() {
1921
  if (STATE.isDark) { document.body.classList.remove("light"); DOM.themeIconLight.style.display = "none"; DOM.themeIconDark.style.display = ""; }
1922
  else { document.body.classList.add("light"); DOM.themeIconLight.style.display = ""; DOM.themeIconDark.style.display = "none"; }
1923
  }
1924
+
1925
  function toggleMobile() { STATE.isMobile = !STATE.isMobile; applyMobileMode(); localStorage.setItem("mobileMode", STATE.isMobile ? "mobile" : "desktop"); }
1926
+
1927
  function applyMobileMode() {
1928
  if (STATE.isMobile) {
1929
  document.body.classList.add("mobile"); document.body.classList.remove("force-desktop");
 
1939
  updateSelectionUI();
1940
  requestAnimationFrame(updateScrollTrack);
1941
  }
 
1942
 
1943
+ function autoDetectMobile() { return window.innerWidth <= 768; }
 
 
1944
 
1945
  function toggleLeftSidebar() {
1946
  STATE.leftSidebarOpen = !STATE.leftSidebarOpen;
 
1952
  if (STATE.isMobile && STATE.leftSidebarOpen && STATE.rightSidebarOpen) STATE.rightSidebarOpen = false;
1953
  updateSidebarVisibility();
1954
  }
1955
+
1956
  function toggleRightSidebar() {
1957
  STATE.rightSidebarOpen = !STATE.rightSidebarOpen;
1958
  if (STATE.rightSidebarOpen && STATE.leftSidebarOpen && STATE.isMobile) STATE.leftSidebarOpen = false;
1959
  updateSidebarVisibility();
1960
  syncStateToURL(true);
1961
  }
1962
+
1963
  function updateSidebarVisibility() {
1964
  DOM.leftSidebar.classList.toggle("collapsed", !STATE.leftSidebarOpen);
1965
  DOM.leftSidebar.classList.toggle("open", STATE.leftSidebarOpen);
 
1967
  DOM.rightSidebar.classList.toggle("open", STATE.rightSidebarOpen);
1968
  DOM.overlay.style.display = (STATE.isMobile && (STATE.leftSidebarOpen || STATE.rightSidebarOpen)) ? "" : "none";
1969
  }
 
 
 
 
 
1970
  let keyboardResultIndex = -1;
1971
+
1972
  function focusKeyboardResult(index) {
1973
  keyboardResultIndex = Math.max(0, Math.min(index, STATE.results.length - 1));
1974
  const top = getVirtualOffset(keyboardResultIndex);
 
2027
  });
2028
  }
2029
 
 
 
 
 
2030
  function clearAllFilters() {
2031
  STATE.filterRepos = []; STATE.filterExtensions = []; STATE.filterFolders = []; STATE.filterFolderSubtrees = []; STATE.filterFolderSelfs = [];
2032
  STATE.filterMinSize = null; STATE.filterMaxSize = null;
 
2037
  syncStateToURL(true);
2038
  }
2039
 
 
 
 
 
2040
  function setupResultDelegation() {
2041
  DOM.resultsList.addEventListener("click", async (e) => {
2042
  const actionBtn = e.target.closest("[data-action]");
 
2092
  });
2093
  }
2094
 
 
 
 
 
2095
  async function init() {
2096
  cacheDOM();
2097
  STATE.isDark = localStorage.getItem("theme") !== "light";
 
2101
  else if (savedMobile === "desktop") STATE.isMobile = false;
2102
  else STATE.isMobile = autoDetectMobile();
2103
  applyMobileMode();
 
2104
  DOM.searchInput.addEventListener("input", debouncedSearch);
 
2105
  const renderDropdown = () => {
2106
  var list = getHistory();
2107
  if (list.length === 0) { DOM.historyDropdown.style.display = "none"; return; }
 
2123
  var dropdownActive = false;
2124
  DOM.searchInput.addEventListener("focus", () => { renderDropdown(); });
2125
  DOM.searchInput.addEventListener("blur", hideDropdown);
 
2126
  var longPressTimer = null;
2127
  DOM.historyDropdown.addEventListener("mouseenter", () => { dropdownActive = true; });
2128
  DOM.historyDropdown.addEventListener("mouseleave", () => { dropdownActive = false; });
 
2165
  syncStateToURL(true);
2166
  if (STATE.results.length > 0) renderResults();
2167
  });
 
 
2168
  if (DOM.multiSelectToggle) DOM.multiSelectToggle.addEventListener("change", updateSelectionUI);
 
2169
  DOM.resultsList.addEventListener("click", (e) => {
2170
  if (!DOM.multiSelectToggle || !DOM.multiSelectToggle.checked) return;
2171
  var cb = e.target.closest(".result-checkbox");
 
2184
  lastSelectedIndex = idx;
2185
  updateSelectionUI();
2186
  });
 
2187
  if (DOM.multiCopyLinks) DOM.multiCopyLinks.addEventListener("click", () => {
2188
  var indices = Object.keys(selectedIndices).map(Number);
2189
  var links = [];
 
2194
  if (links.length === 0) { showToast("未选中任何文件"); return; }
2195
  navigator.clipboard.writeText(links.join("\n")).then(() => showToast("已复制 " + links.length + " 条链接")).catch(() => showToast("复制失败"));
2196
  });
 
2197
  if (DOM.multiBatchDownload) DOM.multiBatchDownload.addEventListener("click", () => {
2198
  var indices = Object.keys(selectedIndices).map(Number);
2199
  if (indices.length === 0) { showToast("未选中任何文件"); return; }
 
2204
  }
2205
  showToast("正在下载 " + indices.length + " 个文件");
2206
  });
 
2207
  if (DOM.multiDeselect) DOM.multiDeselect.addEventListener("click", () => {
2208
  selectedIndices = {};
2209
  lastSelectedIndex = -1;
2210
  updateSelectionUI();
2211
  });
 
2212
  if (DOM.multiSelectAll) DOM.multiSelectAll.addEventListener("click", () => {
2213
  for (var si = 0; si < STATE.results.length; si++) selectedIndices[si] = true;
2214
  lastSelectedIndex = STATE.results.length > 0 ? STATE.results.length - 1 : -1;
2215
  updateSelectionUI();
2216
  });
 
2217
  DOM.hamburgerBtn.addEventListener("click", toggleLeftSidebar);
2218
  DOM.settingsBtn.addEventListener("click", toggleRightSidebar);
2219
  DOM.closeFiltersBtn.addEventListener("click", () => { STATE.rightSidebarOpen = false; updateSidebarVisibility(); syncStateToURL(true); });
 
2220
  DOM.sidebarExpandBtn.addEventListener("click", () => {
2221
  DOM.leftSidebar.classList.toggle("expanded-wide");
2222
  DOM.sidebarExpandBtn.textContent = DOM.leftSidebar.classList.contains("expanded-wide") ? "→" : "↔";
2223
  syncStateToURL(true);
2224
  });
 
2225
  DOM.themeBtn.addEventListener("click", toggleTheme);
2226
  DOM.mobileToggleBtn.addEventListener("click", toggleMobile);
2227
  DOM.clearFiltersBtn.addEventListener("click", clearAllFilters);
 
2246
  DOM.overlay.addEventListener("click", () => { STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; updateSidebarVisibility(); syncStateToURL(true); });
2247
  DOM.randomBookBtn.addEventListener('click', randomBook);
2248
  DOM.emptyRandomBtn.addEventListener('click', randomBook);
 
2249
  let sizeTimer;
2250
  const sizeInputToBytes = (input, unitSelect) => {
2251
  var val = parseFloat(input.value);
 
2261
  DOM.filterMaxSize.addEventListener("input", applySizeFilter);
2262
  DOM.filterMinUnit.addEventListener("change", applySizeFilter);
2263
  DOM.filterMaxUnit.addEventListener("change", applySizeFilter);
 
2264
  DOM.extSelectAll.addEventListener("click", () => { STATE.filterExtensions = (STATE.extensionList || []).map(e => e.name); STATE.page = 1; STATE.results = []; doSearch(); renderExtensionFilter(); });
2265
  DOM.extDeselectAll.addEventListener("click", () => {
2266
  const allExtNames = (STATE.extensionList || []).map(e => e.name);
2267
  const currentSet = new Set(STATE.filterExtensions);
 
2268
  STATE.filterExtensions = allExtNames.filter(e => !currentSet.has(e));
2269
  STATE.page = 1;
2270
  STATE.results = [];
 
2288
  const allSubtreePaths = [];
2289
  const allSelfPaths = [];
2290
  collectFolderNodePaths(STATE.folderTree, allSubtreePaths, allSelfPaths);
 
2291
  const nextSubtreeSet = new Set();
2292
  const nextSelfSet = new Set();
2293
  for (let i = 0; i < allSubtreePaths.length; i++) {
 
2296
  for (let i = 0; i < allSelfPaths.length; i++) {
2297
  if (!selfSet.has(allSelfPaths[i])) nextSelfSet.add(allSelfPaths[i]);
2298
  }
 
2299
  persistFolderSelection(nextSubtreeSet, nextSelfSet);
2300
  renderFilterFolderTree();
2301
  });
 
2302
  DOM.didYouMean.addEventListener("click", () => { if (STATE.didYouMean) { DOM.searchInput.value = STATE.didYouMean; STATE.query = STATE.didYouMean; STATE.page = 1; STATE.results = []; addHistoryItem(STATE.query); doSearch(); } });
 
2303
  setupInfiniteScroll();
2304
  setupQuickScroll();
2305
  setupKeyboard();
2306
  setupResultDelegation();
 
2307
  window.addEventListener("popstate", () => ROUTER.apply());
2308
  window.addEventListener("resize", () => { if (!localStorage.getItem("mobileMode")) { const wm = STATE.isMobile; STATE.isMobile = autoDetectMobile(); if (wm !== STATE.isMobile) applyMobileMode(); } });
 
2309
  ROUTER.apply();
2310
  fetchHitokoto();
2311
  setInterval(fetchHitokoto, 30000);
2312
  }
 
2313
  document.addEventListener("DOMContentLoaded", init);
static/index.html CHANGED
@@ -4,8 +4,6 @@
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>VoiceOfML Search</title>
7
-
8
- <!-- PWA -->
9
  <link rel="manifest" href="/manifest.json">
10
  <meta name="theme-color" content="#1a1c1e">
11
  <meta name="mobile-web-app-capable" content="yes">
@@ -16,12 +14,10 @@
16
  <link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
17
  <link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png">
18
  <link rel="icon" type="image/svg+xml" href="/icons/icon.svg">
19
-
20
  <link rel="stylesheet" href="/static/style.css">
21
  </head>
22
  <body class="dark">
23
 
24
- <!-- ═══════════ Header ═══════════ -->
25
  <header id="header">
26
  <div class="header-left">
27
  <button id="hamburger-btn" class="icon-btn" aria-label="菜单" title="侧边栏 (Ctrl+B)">
@@ -37,7 +33,6 @@
37
  <span id="header-title">VoiceOfML</span>
38
  </a>
39
  </div>
40
-
41
  <div class="header-center">
42
  <div class="search-box" id="search-box">
43
  <svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -51,7 +46,6 @@
51
  <div id="search-history-dropdown" class="search-history-dropdown" style="display:none"></div>
52
  </div>
53
  </div>
54
-
55
  <div class="header-right">
56
  <button id="settings-btn" class="icon-btn" aria-label="搜索设置" title="搜索过滤">
57
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -85,12 +79,10 @@
85
  </svg>
86
  </button>
87
  </div>
88
- </header>
89
 
90
- <!-- ═══════════ Layout Body ═══════════ -->
91
  <div id="app-body" class="app-body">
92
 
93
- <!-- Left Sidebar -->
94
  <aside id="left-sidebar" class="sidebar left-sidebar">
95
  <div class="sidebar-header">
96
  <span id="sidebar-title">仓库列表</span>
@@ -99,12 +91,10 @@
99
  <div id="sidebar-content" class="sidebar-content">
100
  <div class="sidebar-loading">加载中...</div>
101
  </div>
 
102
  </aside>
103
 
104
- <!-- Main Content -->
105
  <main id="main-content" class="main-content">
106
-
107
- <!-- Status Bar -->
108
  <div id="status-bar" class="status-bar">
109
  <div class="status-left">
110
  <span id="result-count" class="result-count"></span>
@@ -126,7 +116,6 @@
126
  </div>
127
  </div>
128
  </div>
129
-
130
  <div id="multi-action-bar" class="multi-action-bar" style="display:none">
131
  <button id="multi-select-all" class="multi-action-btn">全选</button>
132
  <button id="multi-deselect" class="multi-action-btn">取消选择</button>
@@ -134,8 +123,6 @@
134
  <button id="multi-batch-download" class="multi-action-btn primary">批量下载</button>
135
  <span id="multi-selected-count" class="multi-selected-count"></span>
136
  </div>
137
-
138
- <!-- Results -->
139
  <div id="results-container" class="results-container">
140
  <div id="results-list" class="results-list"></div>
141
  <div id="results-loading" class="loading-spinner" style="display:none">
@@ -157,21 +144,16 @@
157
  </button>
158
  </div>
159
  </div>
160
-
161
- <!-- Load Info -->
162
  <div id="load-info" class="load-info" style="display:none">
163
  <span id="mobile-selected-count" class="mobile-selected-count" style="display:none"></span>
164
  已加载 <span id="loaded-count">0</span> / <span id="total-count">0</span> 条
165
  </div>
166
-
167
- <!-- Quick Scroll -->
168
  <div id="scroll-track" class="scroll-track">
169
  <div id="scroll-thumb" class="scroll-thumb"></div>
170
  </div>
171
 
172
  </main>
173
 
174
- <!-- Right Sidebar (Filters) -->
175
  <aside id="right-sidebar" class="sidebar right-sidebar">
176
  <div class="sidebar-header">
177
  <span>搜索过滤</span>
@@ -185,7 +167,6 @@
185
  <span class="toggle-switch"></span>
186
  </label>
187
  </div>
188
- <!-- 记录搜索历史开关 -->
189
  <div class="filter-section" id="history-toggle-section">
190
  <label class="toggle-row">
191
  <span class="toggle-label">记录搜索历史</span>
@@ -193,7 +174,6 @@
193
  <span class="toggle-switch"></span>
194
  </label>
195
  </div>
196
- <!-- 搜索文件夹名开关 -->
197
  <div class="filter-section" id="search-folders-section">
198
  <label class="toggle-row">
199
  <span class="toggle-label">搜索文件夹名</span>
@@ -201,7 +181,6 @@
201
  <span class="toggle-switch"></span>
202
  </label>
203
  </div>
204
- <!-- 精准搜索开关:开启后跳过模糊匹配,直接子串搜索 -->
205
  <div class="filter-section" id="exact-search-section">
206
  <label class="toggle-row">
207
  <span class="toggle-label">精准搜索(支持通配符*和?)</span>
@@ -209,14 +188,10 @@
209
  <span class="toggle-switch"></span>
210
  </label>
211
  </div>
212
-
213
- <!-- Repo Filter -->
214
  <div id="filter-repo-section" class="filter-section">
215
  <div class="filter-section-title">仓库过滤</div>
216
  <div id="filter-repo-list" class="filter-checkbox-list"></div>
217
  </div>
218
-
219
- <!-- Folder Tree Filter -->
220
  <div id="filter-folder-section" class="filter-section">
221
  <div class="filter-section-title">
222
  <span>路径过滤</span>
@@ -227,8 +202,6 @@
227
  </div>
228
  <div id="filter-folder-tree" class="filter-folder-tree"></div>
229
  </div>
230
-
231
- <!-- Extension Filter -->
232
  <div class="filter-section">
233
  <div class="filter-section-title">
234
  <span>文件类型</span>
@@ -239,8 +212,6 @@
239
  </div>
240
  <div id="filter-ext-list" class="filter-checkbox-list"></div>
241
  </div>
242
-
243
- <!-- Size Filter -->
244
  <div class="filter-section">
245
  <div class="filter-section-title">文件大小</div>
246
  <div class="filter-size-row">
@@ -262,10 +233,10 @@
262
  </div>
263
  </div>
264
  </div>
 
265
  </aside>
266
  </div>
267
 
268
- <!-- ═══════════ Footer ═══════════ -->
269
  <footer id="footer" class="footer">
270
  <div id="hitokoto" class="hitokoto"></div>
271
  <button id="random-book-btn" class="random-book-btn" title="随机来本书">
@@ -275,12 +246,9 @@
275
  </svg>
276
  随机来本书
277
  </button>
278
- </footer>
279
 
280
- <!-- Overlay (mobile) -->
281
  <div id="overlay" class="overlay" style="display:none"></div>
282
-
283
- <!-- Toast -->
284
  <div id="toast" class="toast" style="display:none"></div>
285
 
286
  <script>
@@ -288,6 +256,7 @@ if ("serviceWorker" in navigator) {
288
  navigator.serviceWorker.register("/sw.js", { scope: "/" });
289
  }
290
  </script>
 
291
  <script src="/static/app.js"></script>
292
  </body>
293
  </html>
 
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>VoiceOfML Search</title>
 
 
7
  <link rel="manifest" href="/manifest.json">
8
  <meta name="theme-color" content="#1a1c1e">
9
  <meta name="mobile-web-app-capable" content="yes">
 
14
  <link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
15
  <link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png">
16
  <link rel="icon" type="image/svg+xml" href="/icons/icon.svg">
 
17
  <link rel="stylesheet" href="/static/style.css">
18
  </head>
19
  <body class="dark">
20
 
 
21
  <header id="header">
22
  <div class="header-left">
23
  <button id="hamburger-btn" class="icon-btn" aria-label="菜单" title="侧边栏 (Ctrl+B)">
 
33
  <span id="header-title">VoiceOfML</span>
34
  </a>
35
  </div>
 
36
  <div class="header-center">
37
  <div class="search-box" id="search-box">
38
  <svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 
46
  <div id="search-history-dropdown" class="search-history-dropdown" style="display:none"></div>
47
  </div>
48
  </div>
 
49
  <div class="header-right">
50
  <button id="settings-btn" class="icon-btn" aria-label="搜索设置" title="搜索过滤">
51
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 
79
  </svg>
80
  </button>
81
  </div>
 
82
 
83
+ </header>
84
  <div id="app-body" class="app-body">
85
 
 
86
  <aside id="left-sidebar" class="sidebar left-sidebar">
87
  <div class="sidebar-header">
88
  <span id="sidebar-title">仓库列表</span>
 
91
  <div id="sidebar-content" class="sidebar-content">
92
  <div class="sidebar-loading">加载中...</div>
93
  </div>
94
+
95
  </aside>
96
 
 
97
  <main id="main-content" class="main-content">
 
 
98
  <div id="status-bar" class="status-bar">
99
  <div class="status-left">
100
  <span id="result-count" class="result-count"></span>
 
116
  </div>
117
  </div>
118
  </div>
 
119
  <div id="multi-action-bar" class="multi-action-bar" style="display:none">
120
  <button id="multi-select-all" class="multi-action-btn">全选</button>
121
  <button id="multi-deselect" class="multi-action-btn">取消选择</button>
 
123
  <button id="multi-batch-download" class="multi-action-btn primary">批量下载</button>
124
  <span id="multi-selected-count" class="multi-selected-count"></span>
125
  </div>
 
 
126
  <div id="results-container" class="results-container">
127
  <div id="results-list" class="results-list"></div>
128
  <div id="results-loading" class="loading-spinner" style="display:none">
 
144
  </button>
145
  </div>
146
  </div>
 
 
147
  <div id="load-info" class="load-info" style="display:none">
148
  <span id="mobile-selected-count" class="mobile-selected-count" style="display:none"></span>
149
  已加载 <span id="loaded-count">0</span> / <span id="total-count">0</span> 条
150
  </div>
 
 
151
  <div id="scroll-track" class="scroll-track">
152
  <div id="scroll-thumb" class="scroll-thumb"></div>
153
  </div>
154
 
155
  </main>
156
 
 
157
  <aside id="right-sidebar" class="sidebar right-sidebar">
158
  <div class="sidebar-header">
159
  <span>搜索过滤</span>
 
167
  <span class="toggle-switch"></span>
168
  </label>
169
  </div>
 
170
  <div class="filter-section" id="history-toggle-section">
171
  <label class="toggle-row">
172
  <span class="toggle-label">记录搜索历史</span>
 
174
  <span class="toggle-switch"></span>
175
  </label>
176
  </div>
 
177
  <div class="filter-section" id="search-folders-section">
178
  <label class="toggle-row">
179
  <span class="toggle-label">搜索文件夹名</span>
 
181
  <span class="toggle-switch"></span>
182
  </label>
183
  </div>
 
184
  <div class="filter-section" id="exact-search-section">
185
  <label class="toggle-row">
186
  <span class="toggle-label">精准搜索(支持通配符*和?)</span>
 
188
  <span class="toggle-switch"></span>
189
  </label>
190
  </div>
 
 
191
  <div id="filter-repo-section" class="filter-section">
192
  <div class="filter-section-title">仓库过滤</div>
193
  <div id="filter-repo-list" class="filter-checkbox-list"></div>
194
  </div>
 
 
195
  <div id="filter-folder-section" class="filter-section">
196
  <div class="filter-section-title">
197
  <span>路径过滤</span>
 
202
  </div>
203
  <div id="filter-folder-tree" class="filter-folder-tree"></div>
204
  </div>
 
 
205
  <div class="filter-section">
206
  <div class="filter-section-title">
207
  <span>文件类型</span>
 
212
  </div>
213
  <div id="filter-ext-list" class="filter-checkbox-list"></div>
214
  </div>
 
 
215
  <div class="filter-section">
216
  <div class="filter-section-title">文件大小</div>
217
  <div class="filter-size-row">
 
233
  </div>
234
  </div>
235
  </div>
236
+
237
  </aside>
238
  </div>
239
 
 
240
  <footer id="footer" class="footer">
241
  <div id="hitokoto" class="hitokoto"></div>
242
  <button id="random-book-btn" class="random-book-btn" title="随机来本书">
 
246
  </svg>
247
  随机来本书
248
  </button>
 
249
 
250
+ </footer>
251
  <div id="overlay" class="overlay" style="display:none"></div>
 
 
252
  <div id="toast" class="toast" style="display:none"></div>
253
 
254
  <script>
 
256
  navigator.serviceWorker.register("/sw.js", { scope: "/" });
257
  }
258
  </script>
259
+
260
  <script src="/static/app.js"></script>
261
  </body>
262
  </html>
static/style.css CHANGED
@@ -1,85 +1,61 @@
1
- /* ═══════════════════════════════════════════════════════════
2
- VoiceOfML Search — MD3 Design System
3
- ═══════════════════════════════════════════════════════════ */
4
-
5
- /* ── CSS Variables (Dark Theme — Default) ────────────── */
6
  :root {
7
  --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
8
  --font-mono: "SF Mono", "Cascadia Code", "Consolas", "Menlo", monospace;
9
-
10
  --header-h: 48px;
11
-
12
  --surface: #1a1c1e;
13
  --surface-variant: #282a2d;
14
  --surface-container: #1e2022;
15
  --on-surface: #e2e2e6;
16
  --on-surface-variant: #c4c6cf;
17
-
18
  --primary: #8ab4f8;
19
  --on-primary: #003258;
20
  --primary-container: #004a7c;
21
  --on-primary-container: #d1e4ff;
22
-
23
  --secondary: #bcc7db;
24
  --on-secondary: #263141;
25
  --secondary-container: #3c4758;
26
  --on-secondary-container: #d8e3f8;
27
-
28
  --outline: #8d9099;
29
  --outline-variant: #43474e;
30
-
31
  --error: #f2b8b5;
32
  --on-error: #601410;
33
-
34
  --radius-sm: 6px;
35
  --radius-md: 12px;
36
  --radius-lg: 16px;
37
  --radius-full: 999px;
38
-
39
  --shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
40
  --shadow-md: 0 2px 8px rgba(0,0,0,0.4);
41
  --shadow-lg: 0 4px 16px rgba(0,0,0,0.5);
42
-
43
  --transition: 150ms ease;
44
-
45
  --sidebar-w: 280px;
46
  --sidebar-expanded-w: 100vw;
47
-
48
  color-scheme: dark;
49
  }
50
 
51
- /* ── Light Theme ─────────────────────────────────────── */
52
  body.light {
53
  --surface: #fdfbff;
54
  --surface-variant: #f0eff4;
55
  --surface-container: #f5f3f7;
56
  --on-surface: #1a1c1e;
57
  --on-surface-variant: #44474e;
58
-
59
  --primary: #1a6db5;
60
  --on-primary: #ffffff;
61
  --primary-container: #d1e4ff;
62
  --on-primary-container: #001d36;
63
-
64
  --secondary: #525f70;
65
  --on-secondary: #ffffff;
66
  --secondary-container: #d8e3f8;
67
  --on-secondary-container: #121c29;
68
-
69
  --outline: #74777f;
70
  --outline-variant: #c4c6cf;
71
-
72
  --error: #ba1a1a;
73
  --on-error: #ffffff;
74
-
75
  --shadow-sm: 0 1px 2px rgba(0,0,0,0.08);
76
  --shadow-md: 0 2px 8px rgba(0,0,0,0.12);
77
  --shadow-lg: 0 4px 16px rgba(0,0,0,0.16);
78
-
79
  color-scheme: light;
80
  }
81
 
82
- /* ── Reset & Base ────────────────────────────────────── */
83
  *, *::before, *::after {
84
  box-sizing: border-box;
85
  margin: 0;
@@ -107,6 +83,7 @@ body {
107
  outline: 2px solid var(--primary);
108
  outline-offset: 2px;
109
  }
 
110
  .icon-btn:focus-visible,
111
  .icon-btn-sm:focus-visible,
112
  .text-btn:focus-visible,
@@ -128,6 +105,7 @@ a {
128
  color: var(--primary);
129
  text-decoration: none;
130
  }
 
131
  a:hover, a:focus-visible {
132
  text-decoration: underline;
133
  }
@@ -154,26 +132,24 @@ ul, li {
154
  list-style: none;
155
  }
156
 
157
- /* ── Scrollbar ───────────────────────────────────────── */
158
  ::-webkit-scrollbar {
159
  width: 6px;
160
  height: 6px;
161
  }
 
162
  ::-webkit-scrollbar-track {
163
  background: transparent;
164
  }
 
165
  ::-webkit-scrollbar-thumb {
166
  background: var(--outline-variant);
167
  border-radius: 3px;
168
  }
 
169
  ::-webkit-scrollbar-thumb:hover {
170
  background: var(--outline);
171
  }
172
 
173
- /* ═══════════════════════════════════════════════════════════
174
- Header
175
- ═══════════════════════════════════════════════════════════ */
176
-
177
  #header {
178
  height: var(--header-h);
179
  display: flex;
@@ -205,6 +181,7 @@ ul, li {
205
  transition: background var(--transition);
206
  text-decoration: none;
207
  }
 
208
  .header-logo:hover, .header-logo:focus-visible {
209
  background: var(--surface-variant);
210
  text-decoration: none;
@@ -248,6 +225,7 @@ body.light .header-logo-icon-light {
248
  border-radius: var(--radius-full);
249
  transition: all var(--transition);
250
  }
 
251
  .search-box:focus-within {
252
  border-color: var(--primary);
253
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 25%, transparent);
@@ -275,6 +253,7 @@ body.light .header-logo-icon-light {
275
  background: transparent;
276
  min-width: 0;
277
  }
 
278
  .search-input::placeholder {
279
  color: var(--on-surface-variant);
280
  opacity: 0.6;
@@ -307,6 +286,7 @@ body.light .header-logo-icon-light {
307
  overflow-y: auto;
308
  padding: 4px 0;
309
  }
 
310
  .search-history-dropdown .history-item {
311
  display: flex;
312
  align-items: center;
@@ -317,20 +297,24 @@ body.light .header-logo-icon-light {
317
  color: var(--on-surface);
318
  transition: background 0.1s;
319
  }
 
320
  .search-history-dropdown .history-item:hover {
321
  background: var(--surface-variant);
322
  }
 
323
  .search-history-dropdown .history-icon {
324
  flex-shrink: 0;
325
  color: var(--on-surface-variant);
326
  opacity: 0.5;
327
  }
 
328
  .search-history-dropdown .history-text {
329
  flex: 1;
330
  overflow: hidden;
331
  text-overflow: ellipsis;
332
  white-space: nowrap;
333
  }
 
334
  .search-history-dropdown .history-del {
335
  flex-shrink: 0;
336
  background: none;
@@ -341,15 +325,18 @@ body.light .header-logo-icon-light {
341
  padding: 0 6px;
342
  opacity: 0.5;
343
  }
 
344
  .search-history-dropdown .history-del:hover {
345
  opacity: 1;
346
  color: var(--error);
347
  }
 
348
  .search-history-dropdown .history-footer {
349
  padding: 4px 12px;
350
  border-top: 1px solid var(--outline-variant);
351
  text-align: center;
352
  }
 
353
  .search-history-dropdown .history-clear-all {
354
  background: none;
355
  border: none;
@@ -358,6 +345,7 @@ body.light .header-logo-icon-light {
358
  cursor: pointer;
359
  opacity: 0.6;
360
  }
 
361
  .search-history-dropdown .history-clear-all:hover {
362
  opacity: 1;
363
  }
@@ -369,7 +357,6 @@ body.light .header-logo-icon-light {
369
  flex-shrink: 0;
370
  }
371
 
372
- /* Icon Buttons */
373
  .icon-btn {
374
  width: 36px;
375
  height: 36px;
@@ -380,6 +367,7 @@ body.light .header-logo-icon-light {
380
  color: var(--on-surface-variant);
381
  transition: all var(--transition);
382
  }
 
383
  .icon-btn:hover, .icon-btn:focus-visible {
384
  background: var(--surface-variant);
385
  color: var(--on-surface);
@@ -396,6 +384,7 @@ body.light .header-logo-icon-light {
396
  font-size: 18px;
397
  transition: all var(--transition);
398
  }
 
399
  .icon-btn-sm:hover, .icon-btn-sm:focus-visible {
400
  background: var(--surface-variant);
401
  color: var(--on-surface);
@@ -408,6 +397,7 @@ body.light .header-logo-icon-light {
408
  border-radius: var(--radius-sm);
409
  transition: background var(--transition);
410
  }
 
411
  .text-btn:hover, .text-btn:focus-visible {
412
  background: var(--surface-variant);
413
  }
@@ -419,14 +409,11 @@ body.light .header-logo-icon-light {
419
  border-radius: 4px;
420
  transition: background var(--transition);
421
  }
 
422
  .text-btn-sm:hover, .text-btn-sm:focus-visible {
423
  background: var(--surface-variant);
424
  }
425
 
426
- /* ═══════════════════════════════════════════════════════════
427
- Layout
428
- ═══════════════════════════════════════════════════════════ */
429
-
430
  .app-body {
431
  display: flex;
432
  flex: 1;
@@ -434,7 +421,6 @@ body.light .header-logo-icon-light {
434
  overflow: hidden;
435
  }
436
 
437
- /* ── Sidebar ─────────────────────────────────────────── */
438
  .sidebar {
439
  width: var(--sidebar-w);
440
  flex-shrink: 0;
@@ -445,14 +431,17 @@ body.light .header-logo-icon-light {
445
  transition: width var(--transition), opacity var(--transition);
446
  overflow: hidden;
447
  }
 
448
  .sidebar.collapsed {
449
  width: 0 !important;
450
  border-right: none;
451
  }
 
452
  .right-sidebar {
453
  border-right: none;
454
  border-left: 1px solid var(--outline-variant);
455
  }
 
456
  .right-sidebar.collapsed {
457
  border-left: none;
458
  }
@@ -487,11 +476,11 @@ body.light .header-logo-icon-light {
487
  color: var(--on-surface-variant);
488
  opacity: 0.7;
489
  }
 
490
  .sidebar.expanded-wide {
491
  width: var(--sidebar-expanded-w) !important;
492
  }
493
 
494
- /* ── Breadcrumb (Left Sidebar) ───────────────────────── */
495
  .sidebar-breadcrumb {
496
  display: flex;
497
  align-items: center;
@@ -512,6 +501,7 @@ body.light .header-logo-icon-light {
512
  white-space: nowrap;
513
  transition: background var(--transition);
514
  }
 
515
  .sidebar-breadcrumb .crumb-item:hover,
516
  .sidebar-breadcrumb .crumb-item:focus-visible {
517
  background: var(--surface-variant);
@@ -522,6 +512,7 @@ body.light .header-logo-icon-light {
522
  cursor: default;
523
  font-weight: 500;
524
  }
 
525
  .sidebar-breadcrumb .crumb-item.current:hover,
526
  .sidebar-breadcrumb .crumb-item.current:focus-visible {
527
  background: none;
@@ -532,7 +523,6 @@ body.light .header-logo-icon-light {
532
  flex-shrink: 0;
533
  }
534
 
535
- /* Back to global button */
536
  .back-to-global {
537
  display: flex;
538
  align-items: center;
@@ -547,11 +537,11 @@ body.light .header-logo-icon-light {
547
  flex-shrink: 0;
548
  box-sizing: border-box;
549
  }
 
550
  .back-to-global:hover, .back-to-global:focus-visible {
551
  background: var(--surface-variant);
552
  }
553
 
554
- /* ── Sidebar Browser Items (Folder + File) ───────────── */
555
  .browser-list {
556
  flex: 1;
557
  overflow-y: auto;
@@ -567,16 +557,15 @@ body.light .header-logo-icon-light {
567
  font-size: 13px;
568
  color: var(--on-surface);
569
  }
 
570
  .browser-item:hover, .browser-item:focus-visible {
571
  background: var(--surface-variant);
572
  }
573
 
574
- /* 默认:无条纹 */
575
  .browser-item:nth-child(even) {
576
  background: transparent;
577
  }
578
 
579
- /* 展开后:显示条纹 */
580
  .left-sidebar.expanded-wide .browser-item:nth-child(even) {
581
  background: rgba(255, 255, 255, 0.04);
582
  }
@@ -628,13 +617,13 @@ body.light .left-sidebar.expanded-wide .browser-item:nth-child(even) {
628
  transition: all var(--transition);
629
  white-space: nowrap;
630
  }
 
631
  .browser-item .browser-action:hover,
632
  .browser-item .browser-action:focus-visible {
633
  background: var(--primary-container);
634
  color: var(--on-primary-container);
635
  }
636
 
637
- /* ── Main Content ────────────────────────────────────── */
638
  .main-content {
639
  flex: 1;
640
  display: flex;
@@ -644,10 +633,6 @@ body.light .left-sidebar.expanded-wide .browser-item:nth-child(even) {
644
  overflow: hidden;
645
  }
646
 
647
- /* ═══════════════════════════════════════════════════════════
648
- Repo List (Left Sidebar — Global Mode)
649
- ═══════════════════════════════════════════════════════════ */
650
-
651
  .repo-list-item {
652
  display: flex;
653
  align-items: center;
@@ -656,9 +641,11 @@ body.light .left-sidebar.expanded-wide .browser-item:nth-child(even) {
656
  cursor: pointer;
657
  transition: background var(--transition);
658
  }
 
659
  .repo-list-item:hover, .repo-list-item:focus-visible {
660
  background: var(--surface-variant);
661
  }
 
662
  .repo-list-item.active {
663
  background: var(--secondary-container);
664
  }
@@ -687,10 +674,6 @@ body.light .left-sidebar.expanded-wide .browser-item:nth-child(even) {
687
  flex-shrink: 0;
688
  }
689
 
690
- /* ═══════════════════════════════════════════════════════════
691
- Status Bar
692
- ═══════════════════════════════════════════════════════════ */
693
-
694
  .status-bar {
695
  display: flex;
696
  align-items: center;
@@ -720,8 +703,11 @@ body.mobile .status-bar {
720
  padding: 4px 8px;
721
  gap: 4px;
722
  }
 
723
  body.mobile .status-left { gap: 4px; }
 
724
  body.mobile .status-right { gap: 4px; }
 
725
  body.mobile .multi-toggle { padding: 1px 4px; font-size: 11px; }
726
 
727
  .sort-group {
@@ -747,14 +733,11 @@ body.mobile .multi-toggle { padding: 1px 4px; font-size: 11px; }
747
  color: var(--primary);
748
  cursor: pointer;
749
  }
 
750
  .did-you-mean:hover, .did-you-mean:focus-visible {
751
  text-decoration: underline;
752
  }
753
 
754
- /* ═══════════════════════════════════════════════════════════
755
- Results
756
- ═══════════════════════════════════════════════════════════ */
757
-
758
  .results-container {
759
  flex: 1;
760
  overflow-y: auto;
@@ -764,7 +747,6 @@ body.mobile .multi-toggle { padding: 1px 4px; font-size: 11px; }
764
 
765
  .results-container::-webkit-scrollbar { width: 0; height: 0; }
766
 
767
- /* ── Result Item ─────────────────────────────────────── */
768
  .result-item {
769
  display: flex;
770
  gap: 10px;
@@ -772,12 +754,15 @@ body.mobile .multi-toggle { padding: 1px 4px; font-size: 11px; }
772
  border-bottom: 1px solid var(--outline-variant);
773
  transition: background var(--transition);
774
  }
 
775
  .result-item.is-alt {
776
  background: var(--surface-variant);
777
  }
 
778
  .result-item:hover, .result-item:focus-visible {
779
  background: var(--secondary-container) !important;
780
  }
 
781
  .result-item.keyboard-focus {
782
  background: var(--secondary-container) !important;
783
  }
@@ -807,6 +792,7 @@ body.mobile .multi-toggle { padding: 1px 4px; font-size: 11px; }
807
  opacity: 0;
808
  transform: translateY(10px);
809
  }
 
810
  to {
811
  opacity: 1;
812
  transform: translateY(0);
@@ -898,6 +884,7 @@ body.mobile .multi-toggle { padding: 1px 4px; font-size: 11px; }
898
  opacity: 0.78;
899
  transform: translateY(3px);
900
  }
 
901
  to {
902
  opacity: 1;
903
  transform: translateY(0);
@@ -914,6 +901,7 @@ body.mobile .multi-toggle { padding: 1px 4px; font-size: 11px; }
914
  align-items: center;
915
  justify-content: center;
916
  }
 
917
  .result-file-icon svg {
918
  width: 22px;
919
  height: 22px;
@@ -963,6 +951,7 @@ body.mobile .multi-toggle { padding: 1px 4px; font-size: 11px; }
963
  color: var(--secondary);
964
  transition: color var(--transition);
965
  }
 
966
  .result-path .path-folder:hover,
967
  .result-path .path-folder:focus-visible {
968
  color: var(--primary);
@@ -1004,6 +993,7 @@ body.mobile .multi-toggle { padding: 1px 4px; font-size: 11px; }
1004
  font-weight: 500;
1005
  transition: opacity var(--transition);
1006
  }
 
1007
  .result-repo-tag:hover, .result-repo-tag:focus-visible {
1008
  opacity: 0.8;
1009
  }
@@ -1030,14 +1020,17 @@ body.mobile .multi-toggle { padding: 1px 4px; font-size: 11px; }
1030
  white-space: nowrap;
1031
  transition: all var(--transition);
1032
  }
 
1033
  .result-action-btn:hover, .result-action-btn:focus-visible {
1034
  background: var(--surface-variant);
1035
  border-color: var(--outline);
1036
  }
 
1037
  .result-action-btn.primary {
1038
  color: var(--primary);
1039
  border-color: var(--primary);
1040
  }
 
1041
  .result-action-btn.primary:hover,
1042
  .result-action-btn.primary:focus-visible {
1043
  background: var(--primary-container);
@@ -1045,10 +1038,6 @@ body.mobile .multi-toggle { padding: 1px 4px; font-size: 11px; }
1045
  color: var(--on-primary-container);
1046
  }
1047
 
1048
- /* ═══════════════════════════════════════════════════════════
1049
- Load Info
1050
- ═══════════════════════════════════════════════════════════ */
1051
-
1052
  .load-info {
1053
  text-align: center;
1054
  padding: 12px;
@@ -1077,10 +1066,6 @@ body.mobile .mobile-selected-count {
1077
  transform: translateY(-50%);
1078
  }
1079
 
1080
- /* ═══════════════════════════════════════════════════════════
1081
- Quick Scroll Track
1082
- ═══════════════════════════════════════════════════════════ */
1083
-
1084
  .scroll-track {
1085
  position: absolute;
1086
  right: 2px;
@@ -1092,10 +1077,12 @@ body.mobile .mobile-selected-count {
1092
  pointer-events: none;
1093
  z-index: 40;
1094
  }
 
1095
  .scroll-track.visible {
1096
  opacity: 1;
1097
  pointer-events: auto;
1098
  }
 
1099
  .scroll-thumb {
1100
  position: absolute;
1101
  width: 8px;
@@ -1106,15 +1093,13 @@ body.mobile .mobile-selected-count {
1106
  min-height: 40px;
1107
  transition: background var(--transition);
1108
  }
 
1109
  .scroll-thumb:active { cursor: grabbing; }
 
1110
  .scroll-thumb:hover, .scroll-thumb:focus-visible {
1111
  background: var(--on-surface-variant);
1112
  }
1113
 
1114
- /* ═══════════════════════════════════════════════════════════
1115
- Footer
1116
- ═══════════════════════════════════════════════════════════ */
1117
-
1118
  .footer {
1119
  height: 44px;
1120
  display: flex;
@@ -1154,16 +1139,13 @@ body.mobile .mobile-selected-count {
1154
  border: 1px solid var(--outline-variant);
1155
  transition: all var(--transition);
1156
  }
 
1157
  .random-book-btn:hover, .random-book-btn:focus-visible {
1158
  background: var(--surface-variant);
1159
  border-color: var(--outline);
1160
  color: var(--primary);
1161
  }
1162
 
1163
- /* ═══════════════════════════════════════════════════════════
1164
- Filter Section (Right Sidebar)
1165
- ═══════════════════════════════════════════════════════════ */
1166
-
1167
  .filter-section {
1168
  border-bottom: 1px solid var(--outline-variant);
1169
  padding: 10px 12px;
@@ -1217,6 +1199,7 @@ body.mobile .mobile-selected-count {
1217
  cursor: pointer;
1218
  transition: color var(--transition);
1219
  }
 
1220
  .filter-checkbox-item:hover, .filter-checkbox-item:focus-visible {
1221
  color: var(--primary);
1222
  }
@@ -1234,7 +1217,6 @@ body.mobile .mobile-selected-count {
1234
  margin-left: auto;
1235
  }
1236
 
1237
- /* ── Folder Tree Filter ──────────────────────────────── */
1238
  .filter-folder-tree {
1239
  max-height: 280px;
1240
  overflow-y: auto;
@@ -1353,7 +1335,6 @@ body.mobile .mobile-selected-count {
1353
  color: var(--primary);
1354
  }
1355
 
1356
- /* ── Size Filter ─────────────────────────────────────── */
1357
  .filter-size-row {
1358
  display: flex;
1359
  align-items: center;
@@ -1370,9 +1351,11 @@ body.mobile .mobile-selected-count {
1370
  color: var(--on-surface);
1371
  min-width: 0;
1372
  }
 
1373
  .size-input:focus {
1374
  border-color: var(--primary);
1375
  }
 
1376
  .size-unit {
1377
  font-size: 12px;
1378
  padding: 6px 4px;
@@ -1389,10 +1372,6 @@ body.mobile .mobile-selected-count {
1389
  flex-shrink: 0;
1390
  }
1391
 
1392
- /* ═══════════════════════════════════════════════════════════
1393
- Empty State
1394
- ═══════════════════════════════════════════════════════════ */
1395
-
1396
  .empty-state {
1397
  display: flex;
1398
  flex-direction: column;
@@ -1435,14 +1414,11 @@ body.mobile .mobile-selected-count {
1435
  align-items: center;
1436
  gap: 8px;
1437
  }
 
1438
  .random-big-btn:hover, .random-big-btn:focus-visible {
1439
  opacity: 0.85;
1440
  }
1441
 
1442
- /* ═══════════════════════════════════════════════════════════
1443
- Loading Spinner
1444
- ═══════════════════════════════════════════════════════════ */
1445
-
1446
  .loading-spinner {
1447
  display: flex;
1448
  align-items: center;
@@ -1463,10 +1439,6 @@ body.mobile .mobile-selected-count {
1463
  to { transform: rotate(360deg); }
1464
  }
1465
 
1466
- /* ══════════════════════════════════���════════════════════════
1467
- Overlay
1468
- ═══════════════════════════════════════════════════════════ */
1469
-
1470
  .overlay {
1471
  position: fixed;
1472
  inset: 0;
@@ -1474,10 +1446,6 @@ body.mobile .mobile-selected-count {
1474
  z-index: 90;
1475
  }
1476
 
1477
- /* ═══════════════════════════════════════════════════════════
1478
- Toast
1479
- ═══════════════════════════════════════════════════════════ */
1480
-
1481
  .toast {
1482
  position: fixed;
1483
  bottom: 60px;
@@ -1498,16 +1466,13 @@ body.mobile .mobile-selected-count {
1498
  opacity: 0;
1499
  transform: translateX(-50%) translateY(10px);
1500
  }
 
1501
  to {
1502
  opacity: 1;
1503
  transform: translateX(-50%) translateY(0);
1504
  }
1505
  }
1506
 
1507
- /* ═══════════════════════════════════════════════════════════
1508
- Mobile
1509
- ═══════════════════════════════════════════════════════════ */
1510
-
1511
  body.mobile .sidebar:not(.open) {
1512
  width: 0;
1513
  border-right: none;
@@ -1572,10 +1537,6 @@ body.mobile .result-actions {
1572
  align-items: flex-end;
1573
  }
1574
 
1575
- /* ═══════════════════════════════════════════════════════════
1576
- Responsive (auto-detect)
1577
- ═══════════════════════════════════════════════════════════ */
1578
-
1579
  @media (max-width: 768px) {
1580
  body:not(.force-desktop) .sidebar:not(.open) {
1581
  width: 0;
@@ -1629,10 +1590,6 @@ body.mobile .result-actions {
1629
  }
1630
  }
1631
 
1632
- /* ═══════════════════════════════════════════════════════════
1633
- Utility
1634
- ═══════════════════════════════════════════════════════════ */
1635
-
1636
  .hidden {
1637
  display: none !important;
1638
  }
@@ -1643,7 +1600,6 @@ body.mobile .result-actions {
1643
  white-space: nowrap;
1644
  }
1645
 
1646
- /* ── Toggle Switch ──────────────────────────────────── */
1647
  .toggle-row {
1648
  display: flex;
1649
  align-items: center;
@@ -1693,6 +1649,7 @@ body.mobile .result-actions {
1693
  input[type="checkbox"]:indeterminate {
1694
  accent-color: var(--primary);
1695
  }
 
1696
  body.theme-transitioning,
1697
  body.theme-transitioning #header,
1698
  body.theme-transitioning .sidebar,
@@ -1718,6 +1675,7 @@ body.theme-transitioning .footer {
1718
 
1719
  @keyframes theme-ripple-expand {
1720
  0% { width: 0; height: 0; opacity: 0.6; }
 
1721
  100% { width: 300vmax; height: 300vmax; opacity: 0; transform: translate(-150vmax, -150vmax); }
1722
  }
1723
 
@@ -1729,10 +1687,6 @@ body.theme-transitioning .footer {
1729
  }
1730
  }
1731
 
1732
- /* ═══════════════════════════════════════════════════════════
1733
- Multi-Select
1734
- ═══════════════════════════════════════════════════════════ */
1735
-
1736
  .multi-toggle {
1737
  display: inline-flex;
1738
  align-items: center;
@@ -1745,7 +1699,9 @@ body.theme-transitioning .footer {
1745
  border-radius: var(--radius-sm);
1746
  border: 1px solid transparent;
1747
  }
 
1748
  .multi-toggle:hover { border-color: var(--outline-variant); }
 
1749
  .multi-toggle input { margin: 0; }
1750
 
1751
  .multi-action-bar {
@@ -1759,6 +1715,7 @@ body.theme-transitioning .footer {
1759
  box-sizing: border-box;
1760
  flex-wrap: nowrap;
1761
  }
 
1762
  .multi-action-btn {
1763
  padding: 4px 12px;
1764
  border: 1px solid var(--outline-variant);
@@ -1768,12 +1725,15 @@ body.theme-transitioning .footer {
1768
  font-size: 12px;
1769
  cursor: pointer;
1770
  }
 
1771
  .multi-action-btn.primary {
1772
  background: var(--primary);
1773
  color: var(--on-primary);
1774
  border-color: var(--primary);
1775
  }
 
1776
  .multi-action-btn:hover { opacity: 0.85; }
 
1777
  .multi-selected-count {
1778
  margin-left: auto;
1779
  font-size: 12px;
@@ -1795,8 +1755,11 @@ body.mobile .multi-action-btn {
1795
  align-self: center;
1796
  accent-color: var(--primary);
1797
  }
 
1798
  body.multiselect .result-checkbox { display: block; }
 
1799
  body.multiselect .result-file-icon { display: none; }
 
1800
  .result-item.selected {
1801
  background: color-mix(in srgb, var(--primary) 15%, transparent) !important;
1802
  }
 
 
 
 
 
 
1
  :root {
2
  --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
3
  --font-mono: "SF Mono", "Cascadia Code", "Consolas", "Menlo", monospace;
 
4
  --header-h: 48px;
 
5
  --surface: #1a1c1e;
6
  --surface-variant: #282a2d;
7
  --surface-container: #1e2022;
8
  --on-surface: #e2e2e6;
9
  --on-surface-variant: #c4c6cf;
 
10
  --primary: #8ab4f8;
11
  --on-primary: #003258;
12
  --primary-container: #004a7c;
13
  --on-primary-container: #d1e4ff;
 
14
  --secondary: #bcc7db;
15
  --on-secondary: #263141;
16
  --secondary-container: #3c4758;
17
  --on-secondary-container: #d8e3f8;
 
18
  --outline: #8d9099;
19
  --outline-variant: #43474e;
 
20
  --error: #f2b8b5;
21
  --on-error: #601410;
 
22
  --radius-sm: 6px;
23
  --radius-md: 12px;
24
  --radius-lg: 16px;
25
  --radius-full: 999px;
 
26
  --shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
27
  --shadow-md: 0 2px 8px rgba(0,0,0,0.4);
28
  --shadow-lg: 0 4px 16px rgba(0,0,0,0.5);
 
29
  --transition: 150ms ease;
 
30
  --sidebar-w: 280px;
31
  --sidebar-expanded-w: 100vw;
 
32
  color-scheme: dark;
33
  }
34
 
 
35
  body.light {
36
  --surface: #fdfbff;
37
  --surface-variant: #f0eff4;
38
  --surface-container: #f5f3f7;
39
  --on-surface: #1a1c1e;
40
  --on-surface-variant: #44474e;
 
41
  --primary: #1a6db5;
42
  --on-primary: #ffffff;
43
  --primary-container: #d1e4ff;
44
  --on-primary-container: #001d36;
 
45
  --secondary: #525f70;
46
  --on-secondary: #ffffff;
47
  --secondary-container: #d8e3f8;
48
  --on-secondary-container: #121c29;
 
49
  --outline: #74777f;
50
  --outline-variant: #c4c6cf;
 
51
  --error: #ba1a1a;
52
  --on-error: #ffffff;
 
53
  --shadow-sm: 0 1px 2px rgba(0,0,0,0.08);
54
  --shadow-md: 0 2px 8px rgba(0,0,0,0.12);
55
  --shadow-lg: 0 4px 16px rgba(0,0,0,0.16);
 
56
  color-scheme: light;
57
  }
58
 
 
59
  *, *::before, *::after {
60
  box-sizing: border-box;
61
  margin: 0;
 
83
  outline: 2px solid var(--primary);
84
  outline-offset: 2px;
85
  }
86
+
87
  .icon-btn:focus-visible,
88
  .icon-btn-sm:focus-visible,
89
  .text-btn:focus-visible,
 
105
  color: var(--primary);
106
  text-decoration: none;
107
  }
108
+
109
  a:hover, a:focus-visible {
110
  text-decoration: underline;
111
  }
 
132
  list-style: none;
133
  }
134
 
 
135
  ::-webkit-scrollbar {
136
  width: 6px;
137
  height: 6px;
138
  }
139
+
140
  ::-webkit-scrollbar-track {
141
  background: transparent;
142
  }
143
+
144
  ::-webkit-scrollbar-thumb {
145
  background: var(--outline-variant);
146
  border-radius: 3px;
147
  }
148
+
149
  ::-webkit-scrollbar-thumb:hover {
150
  background: var(--outline);
151
  }
152
 
 
 
 
 
153
  #header {
154
  height: var(--header-h);
155
  display: flex;
 
181
  transition: background var(--transition);
182
  text-decoration: none;
183
  }
184
+
185
  .header-logo:hover, .header-logo:focus-visible {
186
  background: var(--surface-variant);
187
  text-decoration: none;
 
225
  border-radius: var(--radius-full);
226
  transition: all var(--transition);
227
  }
228
+
229
  .search-box:focus-within {
230
  border-color: var(--primary);
231
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 25%, transparent);
 
253
  background: transparent;
254
  min-width: 0;
255
  }
256
+
257
  .search-input::placeholder {
258
  color: var(--on-surface-variant);
259
  opacity: 0.6;
 
286
  overflow-y: auto;
287
  padding: 4px 0;
288
  }
289
+
290
  .search-history-dropdown .history-item {
291
  display: flex;
292
  align-items: center;
 
297
  color: var(--on-surface);
298
  transition: background 0.1s;
299
  }
300
+
301
  .search-history-dropdown .history-item:hover {
302
  background: var(--surface-variant);
303
  }
304
+
305
  .search-history-dropdown .history-icon {
306
  flex-shrink: 0;
307
  color: var(--on-surface-variant);
308
  opacity: 0.5;
309
  }
310
+
311
  .search-history-dropdown .history-text {
312
  flex: 1;
313
  overflow: hidden;
314
  text-overflow: ellipsis;
315
  white-space: nowrap;
316
  }
317
+
318
  .search-history-dropdown .history-del {
319
  flex-shrink: 0;
320
  background: none;
 
325
  padding: 0 6px;
326
  opacity: 0.5;
327
  }
328
+
329
  .search-history-dropdown .history-del:hover {
330
  opacity: 1;
331
  color: var(--error);
332
  }
333
+
334
  .search-history-dropdown .history-footer {
335
  padding: 4px 12px;
336
  border-top: 1px solid var(--outline-variant);
337
  text-align: center;
338
  }
339
+
340
  .search-history-dropdown .history-clear-all {
341
  background: none;
342
  border: none;
 
345
  cursor: pointer;
346
  opacity: 0.6;
347
  }
348
+
349
  .search-history-dropdown .history-clear-all:hover {
350
  opacity: 1;
351
  }
 
357
  flex-shrink: 0;
358
  }
359
 
 
360
  .icon-btn {
361
  width: 36px;
362
  height: 36px;
 
367
  color: var(--on-surface-variant);
368
  transition: all var(--transition);
369
  }
370
+
371
  .icon-btn:hover, .icon-btn:focus-visible {
372
  background: var(--surface-variant);
373
  color: var(--on-surface);
 
384
  font-size: 18px;
385
  transition: all var(--transition);
386
  }
387
+
388
  .icon-btn-sm:hover, .icon-btn-sm:focus-visible {
389
  background: var(--surface-variant);
390
  color: var(--on-surface);
 
397
  border-radius: var(--radius-sm);
398
  transition: background var(--transition);
399
  }
400
+
401
  .text-btn:hover, .text-btn:focus-visible {
402
  background: var(--surface-variant);
403
  }
 
409
  border-radius: 4px;
410
  transition: background var(--transition);
411
  }
412
+
413
  .text-btn-sm:hover, .text-btn-sm:focus-visible {
414
  background: var(--surface-variant);
415
  }
416
 
 
 
 
 
417
  .app-body {
418
  display: flex;
419
  flex: 1;
 
421
  overflow: hidden;
422
  }
423
 
 
424
  .sidebar {
425
  width: var(--sidebar-w);
426
  flex-shrink: 0;
 
431
  transition: width var(--transition), opacity var(--transition);
432
  overflow: hidden;
433
  }
434
+
435
  .sidebar.collapsed {
436
  width: 0 !important;
437
  border-right: none;
438
  }
439
+
440
  .right-sidebar {
441
  border-right: none;
442
  border-left: 1px solid var(--outline-variant);
443
  }
444
+
445
  .right-sidebar.collapsed {
446
  border-left: none;
447
  }
 
476
  color: var(--on-surface-variant);
477
  opacity: 0.7;
478
  }
479
+
480
  .sidebar.expanded-wide {
481
  width: var(--sidebar-expanded-w) !important;
482
  }
483
 
 
484
  .sidebar-breadcrumb {
485
  display: flex;
486
  align-items: center;
 
501
  white-space: nowrap;
502
  transition: background var(--transition);
503
  }
504
+
505
  .sidebar-breadcrumb .crumb-item:hover,
506
  .sidebar-breadcrumb .crumb-item:focus-visible {
507
  background: var(--surface-variant);
 
512
  cursor: default;
513
  font-weight: 500;
514
  }
515
+
516
  .sidebar-breadcrumb .crumb-item.current:hover,
517
  .sidebar-breadcrumb .crumb-item.current:focus-visible {
518
  background: none;
 
523
  flex-shrink: 0;
524
  }
525
 
 
526
  .back-to-global {
527
  display: flex;
528
  align-items: center;
 
537
  flex-shrink: 0;
538
  box-sizing: border-box;
539
  }
540
+
541
  .back-to-global:hover, .back-to-global:focus-visible {
542
  background: var(--surface-variant);
543
  }
544
 
 
545
  .browser-list {
546
  flex: 1;
547
  overflow-y: auto;
 
557
  font-size: 13px;
558
  color: var(--on-surface);
559
  }
560
+
561
  .browser-item:hover, .browser-item:focus-visible {
562
  background: var(--surface-variant);
563
  }
564
 
 
565
  .browser-item:nth-child(even) {
566
  background: transparent;
567
  }
568
 
 
569
  .left-sidebar.expanded-wide .browser-item:nth-child(even) {
570
  background: rgba(255, 255, 255, 0.04);
571
  }
 
617
  transition: all var(--transition);
618
  white-space: nowrap;
619
  }
620
+
621
  .browser-item .browser-action:hover,
622
  .browser-item .browser-action:focus-visible {
623
  background: var(--primary-container);
624
  color: var(--on-primary-container);
625
  }
626
 
 
627
  .main-content {
628
  flex: 1;
629
  display: flex;
 
633
  overflow: hidden;
634
  }
635
 
 
 
 
 
636
  .repo-list-item {
637
  display: flex;
638
  align-items: center;
 
641
  cursor: pointer;
642
  transition: background var(--transition);
643
  }
644
+
645
  .repo-list-item:hover, .repo-list-item:focus-visible {
646
  background: var(--surface-variant);
647
  }
648
+
649
  .repo-list-item.active {
650
  background: var(--secondary-container);
651
  }
 
674
  flex-shrink: 0;
675
  }
676
 
 
 
 
 
677
  .status-bar {
678
  display: flex;
679
  align-items: center;
 
703
  padding: 4px 8px;
704
  gap: 4px;
705
  }
706
+
707
  body.mobile .status-left { gap: 4px; }
708
+
709
  body.mobile .status-right { gap: 4px; }
710
+
711
  body.mobile .multi-toggle { padding: 1px 4px; font-size: 11px; }
712
 
713
  .sort-group {
 
733
  color: var(--primary);
734
  cursor: pointer;
735
  }
736
+
737
  .did-you-mean:hover, .did-you-mean:focus-visible {
738
  text-decoration: underline;
739
  }
740
 
 
 
 
 
741
  .results-container {
742
  flex: 1;
743
  overflow-y: auto;
 
747
 
748
  .results-container::-webkit-scrollbar { width: 0; height: 0; }
749
 
 
750
  .result-item {
751
  display: flex;
752
  gap: 10px;
 
754
  border-bottom: 1px solid var(--outline-variant);
755
  transition: background var(--transition);
756
  }
757
+
758
  .result-item.is-alt {
759
  background: var(--surface-variant);
760
  }
761
+
762
  .result-item:hover, .result-item:focus-visible {
763
  background: var(--secondary-container) !important;
764
  }
765
+
766
  .result-item.keyboard-focus {
767
  background: var(--secondary-container) !important;
768
  }
 
792
  opacity: 0;
793
  transform: translateY(10px);
794
  }
795
+
796
  to {
797
  opacity: 1;
798
  transform: translateY(0);
 
884
  opacity: 0.78;
885
  transform: translateY(3px);
886
  }
887
+
888
  to {
889
  opacity: 1;
890
  transform: translateY(0);
 
901
  align-items: center;
902
  justify-content: center;
903
  }
904
+
905
  .result-file-icon svg {
906
  width: 22px;
907
  height: 22px;
 
951
  color: var(--secondary);
952
  transition: color var(--transition);
953
  }
954
+
955
  .result-path .path-folder:hover,
956
  .result-path .path-folder:focus-visible {
957
  color: var(--primary);
 
993
  font-weight: 500;
994
  transition: opacity var(--transition);
995
  }
996
+
997
  .result-repo-tag:hover, .result-repo-tag:focus-visible {
998
  opacity: 0.8;
999
  }
 
1020
  white-space: nowrap;
1021
  transition: all var(--transition);
1022
  }
1023
+
1024
  .result-action-btn:hover, .result-action-btn:focus-visible {
1025
  background: var(--surface-variant);
1026
  border-color: var(--outline);
1027
  }
1028
+
1029
  .result-action-btn.primary {
1030
  color: var(--primary);
1031
  border-color: var(--primary);
1032
  }
1033
+
1034
  .result-action-btn.primary:hover,
1035
  .result-action-btn.primary:focus-visible {
1036
  background: var(--primary-container);
 
1038
  color: var(--on-primary-container);
1039
  }
1040
 
 
 
 
 
1041
  .load-info {
1042
  text-align: center;
1043
  padding: 12px;
 
1066
  transform: translateY(-50%);
1067
  }
1068
 
 
 
 
 
1069
  .scroll-track {
1070
  position: absolute;
1071
  right: 2px;
 
1077
  pointer-events: none;
1078
  z-index: 40;
1079
  }
1080
+
1081
  .scroll-track.visible {
1082
  opacity: 1;
1083
  pointer-events: auto;
1084
  }
1085
+
1086
  .scroll-thumb {
1087
  position: absolute;
1088
  width: 8px;
 
1093
  min-height: 40px;
1094
  transition: background var(--transition);
1095
  }
1096
+
1097
  .scroll-thumb:active { cursor: grabbing; }
1098
+
1099
  .scroll-thumb:hover, .scroll-thumb:focus-visible {
1100
  background: var(--on-surface-variant);
1101
  }
1102
 
 
 
 
 
1103
  .footer {
1104
  height: 44px;
1105
  display: flex;
 
1139
  border: 1px solid var(--outline-variant);
1140
  transition: all var(--transition);
1141
  }
1142
+
1143
  .random-book-btn:hover, .random-book-btn:focus-visible {
1144
  background: var(--surface-variant);
1145
  border-color: var(--outline);
1146
  color: var(--primary);
1147
  }
1148
 
 
 
 
 
1149
  .filter-section {
1150
  border-bottom: 1px solid var(--outline-variant);
1151
  padding: 10px 12px;
 
1199
  cursor: pointer;
1200
  transition: color var(--transition);
1201
  }
1202
+
1203
  .filter-checkbox-item:hover, .filter-checkbox-item:focus-visible {
1204
  color: var(--primary);
1205
  }
 
1217
  margin-left: auto;
1218
  }
1219
 
 
1220
  .filter-folder-tree {
1221
  max-height: 280px;
1222
  overflow-y: auto;
 
1335
  color: var(--primary);
1336
  }
1337
 
 
1338
  .filter-size-row {
1339
  display: flex;
1340
  align-items: center;
 
1351
  color: var(--on-surface);
1352
  min-width: 0;
1353
  }
1354
+
1355
  .size-input:focus {
1356
  border-color: var(--primary);
1357
  }
1358
+
1359
  .size-unit {
1360
  font-size: 12px;
1361
  padding: 6px 4px;
 
1372
  flex-shrink: 0;
1373
  }
1374
 
 
 
 
 
1375
  .empty-state {
1376
  display: flex;
1377
  flex-direction: column;
 
1414
  align-items: center;
1415
  gap: 8px;
1416
  }
1417
+
1418
  .random-big-btn:hover, .random-big-btn:focus-visible {
1419
  opacity: 0.85;
1420
  }
1421
 
 
 
 
 
1422
  .loading-spinner {
1423
  display: flex;
1424
  align-items: center;
 
1439
  to { transform: rotate(360deg); }
1440
  }
1441
 
 
 
 
 
1442
  .overlay {
1443
  position: fixed;
1444
  inset: 0;
 
1446
  z-index: 90;
1447
  }
1448
 
 
 
 
 
1449
  .toast {
1450
  position: fixed;
1451
  bottom: 60px;
 
1466
  opacity: 0;
1467
  transform: translateX(-50%) translateY(10px);
1468
  }
1469
+
1470
  to {
1471
  opacity: 1;
1472
  transform: translateX(-50%) translateY(0);
1473
  }
1474
  }
1475
 
 
 
 
 
1476
  body.mobile .sidebar:not(.open) {
1477
  width: 0;
1478
  border-right: none;
 
1537
  align-items: flex-end;
1538
  }
1539
 
 
 
 
 
1540
  @media (max-width: 768px) {
1541
  body:not(.force-desktop) .sidebar:not(.open) {
1542
  width: 0;
 
1590
  }
1591
  }
1592
 
 
 
 
 
1593
  .hidden {
1594
  display: none !important;
1595
  }
 
1600
  white-space: nowrap;
1601
  }
1602
 
 
1603
  .toggle-row {
1604
  display: flex;
1605
  align-items: center;
 
1649
  input[type="checkbox"]:indeterminate {
1650
  accent-color: var(--primary);
1651
  }
1652
+
1653
  body.theme-transitioning,
1654
  body.theme-transitioning #header,
1655
  body.theme-transitioning .sidebar,
 
1675
 
1676
  @keyframes theme-ripple-expand {
1677
  0% { width: 0; height: 0; opacity: 0.6; }
1678
+
1679
  100% { width: 300vmax; height: 300vmax; opacity: 0; transform: translate(-150vmax, -150vmax); }
1680
  }
1681
 
 
1687
  }
1688
  }
1689
 
 
 
 
 
1690
  .multi-toggle {
1691
  display: inline-flex;
1692
  align-items: center;
 
1699
  border-radius: var(--radius-sm);
1700
  border: 1px solid transparent;
1701
  }
1702
+
1703
  .multi-toggle:hover { border-color: var(--outline-variant); }
1704
+
1705
  .multi-toggle input { margin: 0; }
1706
 
1707
  .multi-action-bar {
 
1715
  box-sizing: border-box;
1716
  flex-wrap: nowrap;
1717
  }
1718
+
1719
  .multi-action-btn {
1720
  padding: 4px 12px;
1721
  border: 1px solid var(--outline-variant);
 
1725
  font-size: 12px;
1726
  cursor: pointer;
1727
  }
1728
+
1729
  .multi-action-btn.primary {
1730
  background: var(--primary);
1731
  color: var(--on-primary);
1732
  border-color: var(--primary);
1733
  }
1734
+
1735
  .multi-action-btn:hover { opacity: 0.85; }
1736
+
1737
  .multi-selected-count {
1738
  margin-left: auto;
1739
  font-size: 12px;
 
1755
  align-self: center;
1756
  accent-color: var(--primary);
1757
  }
1758
+
1759
  body.multiselect .result-checkbox { display: block; }
1760
+
1761
  body.multiselect .result-file-icon { display: none; }
1762
+
1763
  .result-item.selected {
1764
  background: color-mix(in srgb, var(--primary) 15%, transparent) !important;
1765
  }
static/sw.js CHANGED
@@ -11,8 +11,6 @@ const PRECACHE_URLS = [
11
  "/icons/icon-192.png",
12
  "/icons/icon-512.png"
13
  ];
14
-
15
- // ── Install: precache app shell ─────────────────────
16
  self.addEventListener("install", (event) => {
17
  event.waitUntil(
18
  caches.open(CACHE_NAME).then((cache) => {
@@ -22,8 +20,6 @@ self.addEventListener("install", (event) => {
22
  }).then(() => self.skipWaiting())
23
  );
24
  });
25
-
26
- // ── Activate: clean old caches ──────────────────────
27
  self.addEventListener("activate", (event) => {
28
  event.waitUntil(
29
  caches.keys().then((keys) => {
@@ -33,27 +29,17 @@ self.addEventListener("activate", (event) => {
33
  }).then(() => self.clients.claim())
34
  );
35
  });
36
-
37
- // ── Fetch: cache strategies ─────────────────────────
38
  self.addEventListener("fetch", (event) => {
39
  const url = new URL(event.request.url);
40
-
41
- // External domains: network only
42
  if (url.hostname !== self.location.hostname) {
43
  return;
44
  }
45
-
46
- // ── API: network first, no caching ────────────────
47
  if (url.pathname.startsWith("/api/")) {
48
  return;
49
  }
50
-
51
- // ── Download proxy: network only ──────────────────
52
  if (url.pathname.startsWith("/txt/")) {
53
  return;
54
  }
55
-
56
- // ── App shell / static: cache first, network update
57
  event.respondWith(
58
  caches.match(event.request).then((cached) => {
59
  const fetchPromise = fetch(event.request).then((response) => {
 
11
  "/icons/icon-192.png",
12
  "/icons/icon-512.png"
13
  ];
 
 
14
  self.addEventListener("install", (event) => {
15
  event.waitUntil(
16
  caches.open(CACHE_NAME).then((cache) => {
 
20
  }).then(() => self.skipWaiting())
21
  );
22
  });
 
 
23
  self.addEventListener("activate", (event) => {
24
  event.waitUntil(
25
  caches.keys().then((keys) => {
 
29
  }).then(() => self.clients.claim())
30
  );
31
  });
 
 
32
  self.addEventListener("fetch", (event) => {
33
  const url = new URL(event.request.url);
 
 
34
  if (url.hostname !== self.location.hostname) {
35
  return;
36
  }
 
 
37
  if (url.pathname.startsWith("/api/")) {
38
  return;
39
  }
 
 
40
  if (url.pathname.startsWith("/txt/")) {
41
  return;
42
  }
 
 
43
  event.respondWith(
44
  caches.match(event.request).then((cached) => {
45
  const fetchPromise = fetch(event.request).then((response) => {