Upload 3 files
Browse files- static/app.js +207 -39
- static/index.html +8 -6
- static/style.css +0 -10
static/app.js
CHANGED
|
@@ -41,6 +41,15 @@ const STATE = {
|
|
| 41 |
const DOM = {};
|
| 42 |
const HISTORY_KEY = "vomebook_search_history";
|
| 43 |
const SEARCH_CACHE_LIMIT = 60;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
function themeIcon(isDark) {
|
| 46 |
const shape = isDark
|
|
@@ -172,17 +181,39 @@ const API = {
|
|
| 172 |
return resp.json();
|
| 173 |
},
|
| 174 |
async getFolders(sourceSlug) {
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
},
|
| 178 |
async getContents(sourceSlug, path) {
|
| 179 |
const cacheKey = `${sourceSlug}\n${path || ""}`;
|
| 180 |
if (STATE.folderContentsCache.has(cacheKey)) return STATE.folderContentsCache.get(cacheKey);
|
|
|
|
| 181 |
const qs = path ? `?path=${encodeURIComponent(path)}` : "";
|
| 182 |
-
const
|
| 183 |
-
const
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
},
|
| 187 |
async preview(docId) {
|
| 188 |
const resp = await fetch(`/api/preview/${encodeURIComponent(docId)}`);
|
|
@@ -205,14 +236,18 @@ function stableStringify(value) {
|
|
| 205 |
|
| 206 |
function getCachedSearch(key) {
|
| 207 |
if (!STATE.searchCache.has(key)) return null;
|
| 208 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
STATE.searchCache.delete(key);
|
| 210 |
-
STATE.searchCache.set(key,
|
| 211 |
-
return value;
|
| 212 |
}
|
| 213 |
|
| 214 |
function setCachedSearch(key, value) {
|
| 215 |
-
STATE.searchCache.set(key, value);
|
| 216 |
while (STATE.searchCache.size > SEARCH_CACHE_LIMIT) {
|
| 217 |
STATE.searchCache.delete(STATE.searchCache.keys().next().value);
|
| 218 |
}
|
|
@@ -243,7 +278,6 @@ function cacheDom() {
|
|
| 243 |
DOM.loadInfo = $("#load-info");
|
| 244 |
DOM.loadedCount = $("#loaded-count");
|
| 245 |
DOM.totalCount = $("#total-count");
|
| 246 |
-
DOM.didYouMean = $("#did-you-mean");
|
| 247 |
DOM.clearFiltersBtn = $("#clear-filters-btn");
|
| 248 |
DOM.sortSelect = $("#sort-select");
|
| 249 |
DOM.previewPanel = $("#preview-panel");
|
|
@@ -301,8 +335,10 @@ function navigateToSource(sourceSlug) {
|
|
| 301 |
STATE.page = 1;
|
| 302 |
STATE.folderSelections = [];
|
| 303 |
syncRoute();
|
| 304 |
-
|
| 305 |
-
|
|
|
|
|
|
|
| 306 |
doSearch();
|
| 307 |
}
|
| 308 |
|
|
@@ -311,8 +347,10 @@ async function navigateToSourceFolder(sourceSlug, folderPath) {
|
|
| 311 |
STATE.page = 1;
|
| 312 |
STATE.folderSelections = folderPath ? [folderPath] : [];
|
| 313 |
syncRoute();
|
| 314 |
-
|
| 315 |
-
|
|
|
|
|
|
|
| 316 |
refreshFolderTreeSelectionState();
|
| 317 |
syncUrl();
|
| 318 |
doSearch();
|
|
@@ -323,8 +361,10 @@ function navigateHome() {
|
|
| 323 |
STATE.page = 1;
|
| 324 |
STATE.folderSelections = [];
|
| 325 |
syncRoute();
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
| 328 |
doSearch();
|
| 329 |
}
|
| 330 |
|
|
@@ -413,7 +453,7 @@ function applyTheme() {
|
|
| 413 |
DOM.themeBtn.innerHTML = themeIcon(STATE.isDark);
|
| 414 |
DOM.themeBtn.setAttribute("aria-label", STATE.isDark ? "切换到白天模式" : "切换到黑夜模式");
|
| 415 |
DOM.themeBtn.title = STATE.isDark ? "白天模式" : "黑夜模式";
|
| 416 |
-
const previewThemeBtn = DOM.previewPanel?.querySelector("[data-action='toggle-preview-theme']");
|
| 417 |
if (previewThemeBtn) {
|
| 418 |
previewThemeBtn.setAttribute("aria-label", STATE.isDark ? "切换到白天模式" : "切换到黑夜模式");
|
| 419 |
previewThemeBtn.title = STATE.isDark ? "白天模式" : "黑夜模式";
|
|
@@ -476,7 +516,7 @@ function syncUrl(replace = true) {
|
|
| 476 |
const basePath = STATE.source ? `/${encodeURIComponent(STATE.source)}` : "/";
|
| 477 |
const nextUrl = params.toString() ? `${basePath}?${params.toString()}` : basePath;
|
| 478 |
STATE.searchUrlWithoutPreview = searchUrlKey(nextUrl);
|
| 479 |
-
const previewEntry = STATE.previewDocId && (!replace || history.state
|
| 480 |
const historyState = STATE.previewDocId ? { preview: true, previewEntry } : null;
|
| 481 |
if (replace) history.replaceState(historyState, "", nextUrl);
|
| 482 |
else history.pushState(historyState, "", nextUrl);
|
|
@@ -528,9 +568,8 @@ function loadUrlState() {
|
|
| 528 |
restoreSizeInput(DOM.filterMaxSize, DOM.filterMaxUnit, STATE.maxSize);
|
| 529 |
}
|
| 530 |
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
const body = {
|
| 534 |
q: STATE.query,
|
| 535 |
sources: getSelectedSourcesForSearch(),
|
| 536 |
folders: STATE.folderSelections,
|
|
@@ -543,7 +582,15 @@ async function pathSearch(signal) {
|
|
| 543 |
search_paths: STATE.searchPaths,
|
| 544 |
fulltext: STATE.fulltext,
|
| 545 |
};
|
| 546 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
const cached = getCachedSearch(cacheKey);
|
| 548 |
if (cached) return { data: cached, page, cached: true };
|
| 549 |
const data = await API.search(body, STATE.source, signal);
|
|
@@ -551,8 +598,36 @@ async function pathSearch(signal) {
|
|
| 551 |
return { data, page };
|
| 552 |
}
|
| 553 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 554 |
async function doSearch() {
|
| 555 |
if (STATE.searchController) STATE.searchController.abort();
|
|
|
|
| 556 |
const controller = new AbortController();
|
| 557 |
const sequence = ++STATE.searchSequence;
|
| 558 |
STATE.searchController = controller;
|
|
@@ -577,6 +652,7 @@ async function doSearch() {
|
|
| 577 |
appendResults(newResults);
|
| 578 |
}
|
| 579 |
updateStatus();
|
|
|
|
| 580 |
} catch (error) {
|
| 581 |
if (error.name === "AbortError") return;
|
| 582 |
console.error(error);
|
|
@@ -643,7 +719,7 @@ function renderResults() {
|
|
| 643 |
async function openPreview(docId, keyword, options = {}) {
|
| 644 |
if (options.sync !== false) {
|
| 645 |
STATE.previewDocId = docId;
|
| 646 |
-
syncUrl(history.state
|
| 647 |
}
|
| 648 |
try {
|
| 649 |
const data = await API.preview(docId);
|
|
@@ -690,7 +766,7 @@ async function openPreview(docId, keyword, options = {}) {
|
|
| 690 |
}
|
| 691 |
|
| 692 |
function closePreview() {
|
| 693 |
-
if (history.state
|
| 694 |
history.back();
|
| 695 |
return;
|
| 696 |
}
|
|
@@ -1033,7 +1109,7 @@ function renderFolderNodes(container, nodes, depth) {
|
|
| 1033 |
}
|
| 1034 |
}
|
| 1035 |
|
| 1036 |
-
async function renderFolderTree(forceReload = false) {
|
| 1037 |
updateFilterVisibility();
|
| 1038 |
if (!STATE.source) {
|
| 1039 |
DOM.filterFolderTree.innerHTML = '<div style="font-size:12px;opacity:0.6">进入单仓库后可按路径过滤</div>';
|
|
@@ -1043,7 +1119,18 @@ async function renderFolderTree(forceReload = false) {
|
|
| 1043 |
return;
|
| 1044 |
}
|
| 1045 |
if (forceReload || STATE.folderTreeSource !== STATE.source || !STATE.folderTree.length) {
|
| 1046 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1047 |
STATE.folderTreeSource = STATE.source;
|
| 1048 |
STATE.folderNodeMap = buildFolderNodeMap(STATE.folderTree);
|
| 1049 |
STATE.folderCollapsed = {};
|
|
@@ -1053,22 +1140,60 @@ async function renderFolderTree(forceReload = false) {
|
|
| 1053 |
refreshFolderTreeSelectionState();
|
| 1054 |
}
|
| 1055 |
|
| 1056 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1057 |
if (!STATE.source) {
|
| 1058 |
DOM.sidebarTitle.textContent = "数据包";
|
| 1059 |
DOM.sidebarContent.innerHTML = STATE.sources.map((source) => `<div class="repo-list-item" data-source="${escapeHTML(source.slug)}">${ICON_HTML.source}<span class="repo-name">${escapeHTML(source.name)}</span><span class="repo-count">${(source.count || 0).toLocaleString()}</span></div>`).join("");
|
| 1060 |
return;
|
| 1061 |
}
|
| 1062 |
-
|
| 1063 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1064 |
let html = '<div class="back-to-global" id="back-home">返回全局搜索</div>';
|
| 1065 |
html += (data.folders || []).map((item) => `<div class="browser-item" data-folder-open="${escapeHTML(item.path)}">${ICON_HTML.folder}<span class="browser-name">${escapeHTML(item.name)}</span><span class="browser-count">${(item.count || 0).toLocaleString()}</span></div>`).join("");
|
| 1066 |
html += (data.files || []).map((item) => `<div class="browser-item" data-doc-id="${escapeHTML(item.doc_id)}">${ICON_HTML.file}<span class="browser-name">${escapeHTML(item.name)}.txt</span><span class="browser-size">${escapeHTML(formatSize(item.size))}</span></div>`).join("");
|
| 1067 |
DOM.sidebarContent.innerHTML = html;
|
| 1068 |
}
|
| 1069 |
|
| 1070 |
-
async function openFolder(path) {
|
| 1071 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1072 |
const parts = path ? path.split("/") : [];
|
| 1073 |
let html = '<div class="back-to-global" id="back-home">返回全局搜索</div>';
|
| 1074 |
html += `<div class="sidebar-breadcrumb"><span class="crumb-item" data-folder-nav="">根目录</span>${parts.map((part, index) => `<span class="crumb-sep">/</span><span class="crumb-item" data-folder-nav="${escapeHTML(parts.slice(0, index + 1).join("/"))}">${escapeHTML(part)}</span>`).join("")}</div>`;
|
|
@@ -1086,6 +1211,40 @@ function updateSidebarVisibility() {
|
|
| 1086 |
DOM.overlay.classList.toggle("open", STATE.isMobile && (STATE.leftSidebarOpen || STATE.rightSidebarOpen));
|
| 1087 |
}
|
| 1088 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1089 |
function clearFilters() {
|
| 1090 |
STATE.page = 1;
|
| 1091 |
STATE.folderSelections = [];
|
|
@@ -1405,12 +1564,12 @@ function attachEvents() {
|
|
| 1405 |
}
|
| 1406 |
const folderOpen = event.target.closest("[data-folder-open]");
|
| 1407 |
if (folderOpen) {
|
| 1408 |
-
openFolder(folderOpen.dataset.folderOpen);
|
| 1409 |
return;
|
| 1410 |
}
|
| 1411 |
const folderNav = event.target.closest("[data-folder-nav]");
|
| 1412 |
if (folderNav) {
|
| 1413 |
-
openFolder(folderNav.dataset.folderNav);
|
| 1414 |
return;
|
| 1415 |
}
|
| 1416 |
const docItem = event.target.closest("[data-doc-id]");
|
|
@@ -1426,6 +1585,12 @@ function attachEvents() {
|
|
| 1426 |
doSearch();
|
| 1427 |
}
|
| 1428 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1429 |
DOM.previewPanel.addEventListener("click", (event) => {
|
| 1430 |
const closeBtn = event.target.closest("[data-action='close-preview']");
|
| 1431 |
if (closeBtn) {
|
|
@@ -1463,8 +1628,10 @@ function attachEvents() {
|
|
| 1463 |
STATE.searchUrlWithoutPreview = nextSearchUrl;
|
| 1464 |
syncRoute();
|
| 1465 |
loadUrlState();
|
| 1466 |
-
|
| 1467 |
-
|
|
|
|
|
|
|
| 1468 |
updateSidebarVisibility();
|
| 1469 |
restorePreviewFromUrl({ scroll: false });
|
| 1470 |
doSearch();
|
|
@@ -1482,7 +1649,7 @@ async function init() {
|
|
| 1482 |
syncRoute();
|
| 1483 |
applyMobileMode();
|
| 1484 |
loadUrlState();
|
| 1485 |
-
if (STATE.previewDocId && history.state
|
| 1486 |
const previewDocId = STATE.previewDocId;
|
| 1487 |
STATE.previewDocId = null;
|
| 1488 |
syncUrl();
|
|
@@ -1500,8 +1667,9 @@ async function init() {
|
|
| 1500 |
STATE.sources = await API.getSources();
|
| 1501 |
STATE.sourceMap = Object.fromEntries(STATE.sources.map((source) => [source.slug, source]));
|
| 1502 |
await renderSourcesFilter();
|
| 1503 |
-
|
| 1504 |
-
await
|
|
|
|
| 1505 |
updateFilterVisibility();
|
| 1506 |
await initialSearch;
|
| 1507 |
}
|
|
|
|
| 41 |
const DOM = {};
|
| 42 |
const HISTORY_KEY = "vomebook_search_history";
|
| 43 |
const SEARCH_CACHE_LIMIT = 60;
|
| 44 |
+
const SEARCH_CACHE_TTL = 8 * 60 * 1000;
|
| 45 |
+
const REQUEST_TIMEOUT_MS = 12000;
|
| 46 |
+
const folderContentsPending = new Map();
|
| 47 |
+
const folderTreePending = new Map();
|
| 48 |
+
const sidebarRetryCounts = new Map();
|
| 49 |
+
let searchPrefetchController = null;
|
| 50 |
+
let routeRenderId = 0;
|
| 51 |
+
let sidebarCurrentPath = "";
|
| 52 |
+
let scrollRecoveryTimer = null;
|
| 53 |
|
| 54 |
function themeIcon(isDark) {
|
| 55 |
const shape = isDark
|
|
|
|
| 181 |
return resp.json();
|
| 182 |
},
|
| 183 |
async getFolders(sourceSlug) {
|
| 184 |
+
if (folderTreePending.has(sourceSlug)) return folderTreePending.get(sourceSlug);
|
| 185 |
+
const controller = new AbortController();
|
| 186 |
+
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
| 187 |
+
const pending = fetch(`/api/folders/${sourceSlug}`, { signal: controller.signal })
|
| 188 |
+
.then((resp) => resp.json())
|
| 189 |
+
.finally(() => {
|
| 190 |
+
clearTimeout(timeoutId);
|
| 191 |
+
folderTreePending.delete(sourceSlug);
|
| 192 |
+
});
|
| 193 |
+
folderTreePending.set(sourceSlug, pending);
|
| 194 |
+
return pending;
|
| 195 |
},
|
| 196 |
async getContents(sourceSlug, path) {
|
| 197 |
const cacheKey = `${sourceSlug}\n${path || ""}`;
|
| 198 |
if (STATE.folderContentsCache.has(cacheKey)) return STATE.folderContentsCache.get(cacheKey);
|
| 199 |
+
if (folderContentsPending.has(cacheKey)) return folderContentsPending.get(cacheKey);
|
| 200 |
const qs = path ? `?path=${encodeURIComponent(path)}` : "";
|
| 201 |
+
const controller = new AbortController();
|
| 202 |
+
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
| 203 |
+
const pending = fetch(`/api/folders/${sourceSlug}/contents${qs}`, { signal: controller.signal })
|
| 204 |
+
.then((resp) => resp.json())
|
| 205 |
+
.then((data) => {
|
| 206 |
+
if (data && (Array.isArray(data.folders) || Array.isArray(data.files))) {
|
| 207 |
+
STATE.folderContentsCache.set(cacheKey, data);
|
| 208 |
+
}
|
| 209 |
+
return data;
|
| 210 |
+
})
|
| 211 |
+
.finally(() => {
|
| 212 |
+
clearTimeout(timeoutId);
|
| 213 |
+
folderContentsPending.delete(cacheKey);
|
| 214 |
+
});
|
| 215 |
+
folderContentsPending.set(cacheKey, pending);
|
| 216 |
+
return pending;
|
| 217 |
},
|
| 218 |
async preview(docId) {
|
| 219 |
const resp = await fetch(`/api/preview/${encodeURIComponent(docId)}`);
|
|
|
|
| 236 |
|
| 237 |
function getCachedSearch(key) {
|
| 238 |
if (!STATE.searchCache.has(key)) return null;
|
| 239 |
+
const entry = STATE.searchCache.get(key);
|
| 240 |
+
if (!entry || Date.now() - entry.time > SEARCH_CACHE_TTL) {
|
| 241 |
+
STATE.searchCache.delete(key);
|
| 242 |
+
return null;
|
| 243 |
+
}
|
| 244 |
STATE.searchCache.delete(key);
|
| 245 |
+
STATE.searchCache.set(key, entry);
|
| 246 |
+
return entry.value;
|
| 247 |
}
|
| 248 |
|
| 249 |
function setCachedSearch(key, value) {
|
| 250 |
+
STATE.searchCache.set(key, { value, time: Date.now() });
|
| 251 |
while (STATE.searchCache.size > SEARCH_CACHE_LIMIT) {
|
| 252 |
STATE.searchCache.delete(STATE.searchCache.keys().next().value);
|
| 253 |
}
|
|
|
|
| 278 |
DOM.loadInfo = $("#load-info");
|
| 279 |
DOM.loadedCount = $("#loaded-count");
|
| 280 |
DOM.totalCount = $("#total-count");
|
|
|
|
| 281 |
DOM.clearFiltersBtn = $("#clear-filters-btn");
|
| 282 |
DOM.sortSelect = $("#sort-select");
|
| 283 |
DOM.previewPanel = $("#preview-panel");
|
|
|
|
| 335 |
STATE.page = 1;
|
| 336 |
STATE.folderSelections = [];
|
| 337 |
syncRoute();
|
| 338 |
+
const routeId = ++routeRenderId;
|
| 339 |
+
sidebarCurrentPath = "";
|
| 340 |
+
renderSidebar(routeId);
|
| 341 |
+
renderFolderTree(false, routeId);
|
| 342 |
doSearch();
|
| 343 |
}
|
| 344 |
|
|
|
|
| 347 |
STATE.page = 1;
|
| 348 |
STATE.folderSelections = folderPath ? [folderPath] : [];
|
| 349 |
syncRoute();
|
| 350 |
+
const routeId = ++routeRenderId;
|
| 351 |
+
sidebarCurrentPath = "";
|
| 352 |
+
await renderSidebar(routeId);
|
| 353 |
+
await renderFolderTree(false, routeId);
|
| 354 |
refreshFolderTreeSelectionState();
|
| 355 |
syncUrl();
|
| 356 |
doSearch();
|
|
|
|
| 361 |
STATE.page = 1;
|
| 362 |
STATE.folderSelections = [];
|
| 363 |
syncRoute();
|
| 364 |
+
const routeId = ++routeRenderId;
|
| 365 |
+
sidebarCurrentPath = "";
|
| 366 |
+
renderSidebar(routeId);
|
| 367 |
+
renderFolderTree(false, routeId);
|
| 368 |
doSearch();
|
| 369 |
}
|
| 370 |
|
|
|
|
| 453 |
DOM.themeBtn.innerHTML = themeIcon(STATE.isDark);
|
| 454 |
DOM.themeBtn.setAttribute("aria-label", STATE.isDark ? "切换到白天模式" : "切换到黑夜模式");
|
| 455 |
DOM.themeBtn.title = STATE.isDark ? "白天模式" : "黑夜模式";
|
| 456 |
+
const previewThemeBtn = DOM.previewPanel ? DOM.previewPanel.querySelector("[data-action='toggle-preview-theme']") : null;
|
| 457 |
if (previewThemeBtn) {
|
| 458 |
previewThemeBtn.setAttribute("aria-label", STATE.isDark ? "切换到白天模式" : "切换到黑夜模式");
|
| 459 |
previewThemeBtn.title = STATE.isDark ? "白天模式" : "黑夜模式";
|
|
|
|
| 516 |
const basePath = STATE.source ? `/${encodeURIComponent(STATE.source)}` : "/";
|
| 517 |
const nextUrl = params.toString() ? `${basePath}?${params.toString()}` : basePath;
|
| 518 |
STATE.searchUrlWithoutPreview = searchUrlKey(nextUrl);
|
| 519 |
+
const previewEntry = STATE.previewDocId && (!replace || (history.state && history.state.previewEntry === true));
|
| 520 |
const historyState = STATE.previewDocId ? { preview: true, previewEntry } : null;
|
| 521 |
if (replace) history.replaceState(historyState, "", nextUrl);
|
| 522 |
else history.pushState(historyState, "", nextUrl);
|
|
|
|
| 568 |
restoreSizeInput(DOM.filterMaxSize, DOM.filterMaxUnit, STATE.maxSize);
|
| 569 |
}
|
| 570 |
|
| 571 |
+
function buildSearchBody(page = STATE.page) {
|
| 572 |
+
return {
|
|
|
|
| 573 |
q: STATE.query,
|
| 574 |
sources: getSelectedSourcesForSearch(),
|
| 575 |
folders: STATE.folderSelections,
|
|
|
|
| 582 |
search_paths: STATE.searchPaths,
|
| 583 |
fulltext: STATE.fulltext,
|
| 584 |
};
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
function searchCacheKey(body, sourceSlug = STATE.source) {
|
| 588 |
+
return `${sourceSlug || ""}\n${stableStringify(body)}`;
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
async function pathSearch(signal, page = STATE.page) {
|
| 592 |
+
const body = buildSearchBody(page);
|
| 593 |
+
const cacheKey = searchCacheKey(body);
|
| 594 |
const cached = getCachedSearch(cacheKey);
|
| 595 |
if (cached) return { data: cached, page, cached: true };
|
| 596 |
const data = await API.search(body, STATE.source, signal);
|
|
|
|
| 598 |
return { data, page };
|
| 599 |
}
|
| 600 |
|
| 601 |
+
function abortSearchPrefetch() {
|
| 602 |
+
if (searchPrefetchController) searchPrefetchController.abort();
|
| 603 |
+
searchPrefetchController = null;
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
function prefetchNextPage() {
|
| 607 |
+
if (STATE.isLoading || STATE.results.length >= STATE.total) return;
|
| 608 |
+
abortSearchPrefetch();
|
| 609 |
+
const nextPage = Math.floor(STATE.results.length / STATE.pageSize) + 1;
|
| 610 |
+
if (nextPage <= STATE.page) return;
|
| 611 |
+
const body = buildSearchBody(nextPage);
|
| 612 |
+
const cacheKey = searchCacheKey(body);
|
| 613 |
+
if (getCachedSearch(cacheKey)) return;
|
| 614 |
+
const controller = new AbortController();
|
| 615 |
+
searchPrefetchController = controller;
|
| 616 |
+
API.search(body, STATE.source, controller.signal)
|
| 617 |
+
.then((data) => {
|
| 618 |
+
if (!controller.signal.aborted) setCachedSearch(cacheKey, data);
|
| 619 |
+
})
|
| 620 |
+
.catch((error) => {
|
| 621 |
+
if (error.name !== "AbortError") console.warn("搜索预取失败", error);
|
| 622 |
+
})
|
| 623 |
+
.finally(() => {
|
| 624 |
+
if (searchPrefetchController === controller) searchPrefetchController = null;
|
| 625 |
+
});
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
async function doSearch() {
|
| 629 |
if (STATE.searchController) STATE.searchController.abort();
|
| 630 |
+
abortSearchPrefetch();
|
| 631 |
const controller = new AbortController();
|
| 632 |
const sequence = ++STATE.searchSequence;
|
| 633 |
STATE.searchController = controller;
|
|
|
|
| 652 |
appendResults(newResults);
|
| 653 |
}
|
| 654 |
updateStatus();
|
| 655 |
+
prefetchNextPage();
|
| 656 |
} catch (error) {
|
| 657 |
if (error.name === "AbortError") return;
|
| 658 |
console.error(error);
|
|
|
|
| 719 |
async function openPreview(docId, keyword, options = {}) {
|
| 720 |
if (options.sync !== false) {
|
| 721 |
STATE.previewDocId = docId;
|
| 722 |
+
syncUrl(history.state && history.state.preview === true);
|
| 723 |
}
|
| 724 |
try {
|
| 725 |
const data = await API.preview(docId);
|
|
|
|
| 766 |
}
|
| 767 |
|
| 768 |
function closePreview() {
|
| 769 |
+
if (history.state && history.state.previewEntry === true) {
|
| 770 |
history.back();
|
| 771 |
return;
|
| 772 |
}
|
|
|
|
| 1109 |
}
|
| 1110 |
}
|
| 1111 |
|
| 1112 |
+
async function renderFolderTree(forceReload = false, routeId = routeRenderId) {
|
| 1113 |
updateFilterVisibility();
|
| 1114 |
if (!STATE.source) {
|
| 1115 |
DOM.filterFolderTree.innerHTML = '<div style="font-size:12px;opacity:0.6">进入单仓库后可按路径过滤</div>';
|
|
|
|
| 1119 |
return;
|
| 1120 |
}
|
| 1121 |
if (forceReload || STATE.folderTreeSource !== STATE.source || !STATE.folderTree.length) {
|
| 1122 |
+
const source = STATE.source;
|
| 1123 |
+
let data;
|
| 1124 |
+
try {
|
| 1125 |
+
data = await API.getFolders(source);
|
| 1126 |
+
} catch (error) {
|
| 1127 |
+
if (routeId === routeRenderId && STATE.source === source) {
|
| 1128 |
+
DOM.filterFolderTree.innerHTML = '<div class="sidebar-loading">路径加载失败</div>';
|
| 1129 |
+
}
|
| 1130 |
+
return;
|
| 1131 |
+
}
|
| 1132 |
+
if (routeId !== routeRenderId || STATE.source !== source) return;
|
| 1133 |
+
STATE.folderTree = normalizeFolderTree(data);
|
| 1134 |
STATE.folderTreeSource = STATE.source;
|
| 1135 |
STATE.folderNodeMap = buildFolderNodeMap(STATE.folderTree);
|
| 1136 |
STATE.folderCollapsed = {};
|
|
|
|
| 1140 |
refreshFolderTreeSelectionState();
|
| 1141 |
}
|
| 1142 |
|
| 1143 |
+
function retrySidebar(source, path, routeId) {
|
| 1144 |
+
const retryKey = `${source}\n${path || ""}`;
|
| 1145 |
+
const tries = sidebarRetryCounts.get(retryKey) || 0;
|
| 1146 |
+
if (tries >= 2) {
|
| 1147 |
+
DOM.sidebarContent.innerHTML = '<div class="sidebar-loading">加载失败</div>';
|
| 1148 |
+
return;
|
| 1149 |
+
}
|
| 1150 |
+
sidebarRetryCounts.set(retryKey, tries + 1);
|
| 1151 |
+
DOM.sidebarContent.innerHTML = '<div class="sidebar-loading">加载失败,正在重试...</div>';
|
| 1152 |
+
setTimeout(() => {
|
| 1153 |
+
if (routeId !== routeRenderId || STATE.source !== source) return;
|
| 1154 |
+
if (path) openFolder(path, routeId);
|
| 1155 |
+
else renderSidebar(routeId);
|
| 1156 |
+
}, 1200);
|
| 1157 |
+
}
|
| 1158 |
+
|
| 1159 |
+
async function renderSidebar(routeId = routeRenderId) {
|
| 1160 |
if (!STATE.source) {
|
| 1161 |
DOM.sidebarTitle.textContent = "数据包";
|
| 1162 |
DOM.sidebarContent.innerHTML = STATE.sources.map((source) => `<div class="repo-list-item" data-source="${escapeHTML(source.slug)}">${ICON_HTML.source}<span class="repo-name">${escapeHTML(source.name)}</span><span class="repo-count">${(source.count || 0).toLocaleString()}</span></div>`).join("");
|
| 1163 |
return;
|
| 1164 |
}
|
| 1165 |
+
const source = STATE.source;
|
| 1166 |
+
sidebarCurrentPath = "";
|
| 1167 |
+
DOM.sidebarTitle.textContent = (STATE.sourceMap[source] && STATE.sourceMap[source].name) || source;
|
| 1168 |
+
DOM.sidebarContent.innerHTML = '<div class="sidebar-loading">加载中...</div>';
|
| 1169 |
+
let data;
|
| 1170 |
+
try {
|
| 1171 |
+
data = await API.getContents(source, "");
|
| 1172 |
+
} catch (error) {
|
| 1173 |
+
if (routeId === routeRenderId && STATE.source === source) retrySidebar(source, "", routeId);
|
| 1174 |
+
return;
|
| 1175 |
+
}
|
| 1176 |
+
if (routeId !== routeRenderId || STATE.source !== source) return;
|
| 1177 |
+
sidebarRetryCounts.delete(`${source}\n`);
|
| 1178 |
let html = '<div class="back-to-global" id="back-home">返回全局搜索</div>';
|
| 1179 |
html += (data.folders || []).map((item) => `<div class="browser-item" data-folder-open="${escapeHTML(item.path)}">${ICON_HTML.folder}<span class="browser-name">${escapeHTML(item.name)}</span><span class="browser-count">${(item.count || 0).toLocaleString()}</span></div>`).join("");
|
| 1180 |
html += (data.files || []).map((item) => `<div class="browser-item" data-doc-id="${escapeHTML(item.doc_id)}">${ICON_HTML.file}<span class="browser-name">${escapeHTML(item.name)}.txt</span><span class="browser-size">${escapeHTML(formatSize(item.size))}</span></div>`).join("");
|
| 1181 |
DOM.sidebarContent.innerHTML = html;
|
| 1182 |
}
|
| 1183 |
|
| 1184 |
+
async function openFolder(path, routeId = routeRenderId) {
|
| 1185 |
+
const source = STATE.source;
|
| 1186 |
+
sidebarCurrentPath = path || "";
|
| 1187 |
+
DOM.sidebarContent.innerHTML = '<div class="sidebar-loading">加载中...</div>';
|
| 1188 |
+
let data;
|
| 1189 |
+
try {
|
| 1190 |
+
data = await API.getContents(source, path);
|
| 1191 |
+
} catch (error) {
|
| 1192 |
+
if (routeId === routeRenderId && STATE.source === source && sidebarCurrentPath === (path || "")) retrySidebar(source, path, routeId);
|
| 1193 |
+
return;
|
| 1194 |
+
}
|
| 1195 |
+
if (routeId !== routeRenderId || STATE.source !== source || sidebarCurrentPath !== (path || "")) return;
|
| 1196 |
+
sidebarRetryCounts.delete(`${source}\n${path || ""}`);
|
| 1197 |
const parts = path ? path.split("/") : [];
|
| 1198 |
let html = '<div class="back-to-global" id="back-home">返回全局搜索</div>';
|
| 1199 |
html += `<div class="sidebar-breadcrumb"><span class="crumb-item" data-folder-nav="">根目录</span>${parts.map((part, index) => `<span class="crumb-sep">/</span><span class="crumb-item" data-folder-nav="${escapeHTML(parts.slice(0, index + 1).join("/"))}">${escapeHTML(part)}</span>`).join("")}</div>`;
|
|
|
|
| 1211 |
DOM.overlay.classList.toggle("open", STATE.isMobile && (STATE.leftSidebarOpen || STATE.rightSidebarOpen));
|
| 1212 |
}
|
| 1213 |
|
| 1214 |
+
function recoverSidebarState() {
|
| 1215 |
+
if (!DOM.sidebarContent || !DOM.sidebarContent.querySelector(".sidebar-loading")) return;
|
| 1216 |
+
folderContentsPending.clear();
|
| 1217 |
+
folderTreePending.clear();
|
| 1218 |
+
sidebarRetryCounts.clear();
|
| 1219 |
+
const routeId = ++routeRenderId;
|
| 1220 |
+
if (STATE.source && sidebarCurrentPath) openFolder(sidebarCurrentPath, routeId);
|
| 1221 |
+
else renderSidebar(routeId);
|
| 1222 |
+
renderFolderTree(false, routeId);
|
| 1223 |
+
}
|
| 1224 |
+
|
| 1225 |
+
function recoverScrollState() {
|
| 1226 |
+
if (!DOM.resultsContainer) return;
|
| 1227 |
+
if (STATE.isLoading && !STATE.searchController) {
|
| 1228 |
+
STATE.isLoading = false;
|
| 1229 |
+
DOM.resultsLoading.style.display = "none";
|
| 1230 |
+
}
|
| 1231 |
+
const nearBottom = DOM.resultsContainer.scrollTop + DOM.resultsContainer.clientHeight >= DOM.resultsContainer.scrollHeight - 240;
|
| 1232 |
+
if (nearBottom && STATE.results.length < STATE.total && !STATE.isLoading) {
|
| 1233 |
+
STATE.page += 1;
|
| 1234 |
+
doSearch();
|
| 1235 |
+
} else {
|
| 1236 |
+
prefetchNextPage();
|
| 1237 |
+
}
|
| 1238 |
+
}
|
| 1239 |
+
|
| 1240 |
+
function schedulePageRecovery(delay = 0) {
|
| 1241 |
+
clearTimeout(scrollRecoveryTimer);
|
| 1242 |
+
scrollRecoveryTimer = setTimeout(() => {
|
| 1243 |
+
recoverSidebarState();
|
| 1244 |
+
recoverScrollState();
|
| 1245 |
+
}, delay);
|
| 1246 |
+
}
|
| 1247 |
+
|
| 1248 |
function clearFilters() {
|
| 1249 |
STATE.page = 1;
|
| 1250 |
STATE.folderSelections = [];
|
|
|
|
| 1564 |
}
|
| 1565 |
const folderOpen = event.target.closest("[data-folder-open]");
|
| 1566 |
if (folderOpen) {
|
| 1567 |
+
openFolder(folderOpen.dataset.folderOpen, routeRenderId);
|
| 1568 |
return;
|
| 1569 |
}
|
| 1570 |
const folderNav = event.target.closest("[data-folder-nav]");
|
| 1571 |
if (folderNav) {
|
| 1572 |
+
openFolder(folderNav.dataset.folderNav, routeRenderId);
|
| 1573 |
return;
|
| 1574 |
}
|
| 1575 |
const docItem = event.target.closest("[data-doc-id]");
|
|
|
|
| 1585 |
doSearch();
|
| 1586 |
}
|
| 1587 |
});
|
| 1588 |
+
window.addEventListener("resize", () => schedulePageRecovery(120));
|
| 1589 |
+
window.addEventListener("focus", () => schedulePageRecovery(0));
|
| 1590 |
+
window.addEventListener("pageshow", () => schedulePageRecovery(0));
|
| 1591 |
+
document.addEventListener("visibilitychange", () => {
|
| 1592 |
+
if (!document.hidden) schedulePageRecovery(0);
|
| 1593 |
+
});
|
| 1594 |
DOM.previewPanel.addEventListener("click", (event) => {
|
| 1595 |
const closeBtn = event.target.closest("[data-action='close-preview']");
|
| 1596 |
if (closeBtn) {
|
|
|
|
| 1628 |
STATE.searchUrlWithoutPreview = nextSearchUrl;
|
| 1629 |
syncRoute();
|
| 1630 |
loadUrlState();
|
| 1631 |
+
const routeId = ++routeRenderId;
|
| 1632 |
+
sidebarCurrentPath = "";
|
| 1633 |
+
await renderSidebar(routeId);
|
| 1634 |
+
await renderFolderTree(false, routeId);
|
| 1635 |
updateSidebarVisibility();
|
| 1636 |
restorePreviewFromUrl({ scroll: false });
|
| 1637 |
doSearch();
|
|
|
|
| 1649 |
syncRoute();
|
| 1650 |
applyMobileMode();
|
| 1651 |
loadUrlState();
|
| 1652 |
+
if (STATE.previewDocId && !(history.state && history.state.previewEntry === true)) {
|
| 1653 |
const previewDocId = STATE.previewDocId;
|
| 1654 |
STATE.previewDocId = null;
|
| 1655 |
syncUrl();
|
|
|
|
| 1667 |
STATE.sources = await API.getSources();
|
| 1668 |
STATE.sourceMap = Object.fromEntries(STATE.sources.map((source) => [source.slug, source]));
|
| 1669 |
await renderSourcesFilter();
|
| 1670 |
+
const routeId = ++routeRenderId;
|
| 1671 |
+
await renderSidebar(routeId);
|
| 1672 |
+
await renderFolderTree(false, routeId);
|
| 1673 |
updateFilterVisibility();
|
| 1674 |
await initialSearch;
|
| 1675 |
}
|
static/index.html
CHANGED
|
@@ -12,6 +12,7 @@
|
|
| 12 |
<link rel="stylesheet" href="/static/style.css">
|
| 13 |
</head>
|
| 14 |
<body class="dark">
|
|
|
|
| 15 |
<header id="header">
|
| 16 |
<div class="header-left">
|
| 17 |
<button id="hamburger-btn" class="icon-btn" aria-label="菜单"><span class="ui-icon ui-icon-menu" aria-hidden="true"></span></button>
|
|
@@ -32,12 +33,14 @@
|
|
| 32 |
<button id="mobile-toggle-btn" class="icon-btn" aria-label="切换手机或电脑模式" title="手机/电脑模式切换"><span id="mobile-toggle-icon" class="ui-icon ui-icon-devices" aria-hidden="true"></span></button>
|
| 33 |
<button id="theme-btn" class="icon-btn" aria-label="切换到白天模式" title="白天模式"><svg class="theme-icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M20 15.5A8 8 0 1 1 8.5 4 6.5 6.5 0 0 0 20 15.5Z"/></svg></button>
|
| 34 |
</div>
|
| 35 |
-
</header>
|
| 36 |
|
|
|
|
| 37 |
<div id="app-body" class="app-body">
|
|
|
|
| 38 |
<aside id="left-sidebar" class="sidebar left-sidebar">
|
| 39 |
<div class="sidebar-header"><span id="sidebar-title">数据包</span><button id="sidebar-expand-btn" class="icon-btn-sm" title="展开/收起侧边栏" style="display:none">↔</button></div>
|
| 40 |
<div id="sidebar-content" class="sidebar-content"><div class="sidebar-loading">加载中...</div></div>
|
|
|
|
| 41 |
</aside>
|
| 42 |
|
| 43 |
<main id="main-content" class="main-content">
|
|
@@ -45,7 +48,6 @@
|
|
| 45 |
<div class="status-left">
|
| 46 |
<span id="result-count" class="result-count"></span>
|
| 47 |
<label class="multi-toggle" id="multi-toggle-label" style="display:none"><input type="checkbox" id="multi-select-toggle"><span>多选</span></label>
|
| 48 |
-
<span id="did-you-mean" class="did-you-mean"></span>
|
| 49 |
</div>
|
| 50 |
<div class="status-right">
|
| 51 |
<button id="clear-filters-btn" class="text-btn" style="display:none">清空筛选</button>
|
|
@@ -59,7 +61,6 @@
|
|
| 59 |
</div>
|
| 60 |
</div>
|
| 61 |
</div>
|
| 62 |
-
|
| 63 |
<div id="multi-action-bar" class="multi-action-bar" style="display:none">
|
| 64 |
<button id="multi-select-all" class="multi-action-btn">全选</button>
|
| 65 |
<button id="multi-deselect" class="multi-action-btn">取消选择</button>
|
|
@@ -67,7 +68,6 @@
|
|
| 67 |
<button id="multi-zip-download" class="multi-action-btn primary">合并下载</button>
|
| 68 |
<span id="multi-selected-count" class="multi-selected-count"></span>
|
| 69 |
</div>
|
| 70 |
-
|
| 71 |
<div id="results-container" class="results-container">
|
| 72 |
<div id="results-list" class="results-list"></div>
|
| 73 |
<div id="results-loading" class="loading-spinner" style="display:none"><div class="spinner"></div></div>
|
|
@@ -77,9 +77,9 @@
|
|
| 77 |
<button id="empty-random-btn" class="random-big-btn">随机来一篇</button>
|
| 78 |
</div>
|
| 79 |
</div>
|
| 80 |
-
|
| 81 |
<div id="load-info" class="load-info" style="display:none">已加载 <span id="loaded-count">0</span> / <span id="total-count">0</span> 条</div>
|
| 82 |
<div id="preview-panel" class="preview-panel" style="display:none"></div>
|
|
|
|
| 83 |
</main>
|
| 84 |
|
| 85 |
<aside id="right-sidebar" class="sidebar right-sidebar">
|
|
@@ -132,6 +132,7 @@
|
|
| 132 |
</div>
|
| 133 |
</div>
|
| 134 |
</div>
|
|
|
|
| 135 |
</aside>
|
| 136 |
</div>
|
| 137 |
|
|
@@ -141,10 +142,11 @@
|
|
| 141 |
<span class="ui-icon ui-icon-book" aria-hidden="true"></span>
|
| 142 |
<span>随机来一篇</span>
|
| 143 |
</button>
|
| 144 |
-
</footer>
|
| 145 |
|
|
|
|
| 146 |
<div id="overlay" class="overlay" style="display:none"></div>
|
| 147 |
<div id="toast" class="toast" style="display:none"></div>
|
|
|
|
| 148 |
<script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { scope: "/" }); }</script>
|
| 149 |
<script src="/static/app.js"></script>
|
| 150 |
</body>
|
|
|
|
| 12 |
<link rel="stylesheet" href="/static/style.css">
|
| 13 |
</head>
|
| 14 |
<body class="dark">
|
| 15 |
+
|
| 16 |
<header id="header">
|
| 17 |
<div class="header-left">
|
| 18 |
<button id="hamburger-btn" class="icon-btn" aria-label="菜单"><span class="ui-icon ui-icon-menu" aria-hidden="true"></span></button>
|
|
|
|
| 33 |
<button id="mobile-toggle-btn" class="icon-btn" aria-label="切换手机或电脑模式" title="手机/电脑模式切换"><span id="mobile-toggle-icon" class="ui-icon ui-icon-devices" aria-hidden="true"></span></button>
|
| 34 |
<button id="theme-btn" class="icon-btn" aria-label="切换到白天模式" title="白天模式"><svg class="theme-icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M20 15.5A8 8 0 1 1 8.5 4 6.5 6.5 0 0 0 20 15.5Z"/></svg></button>
|
| 35 |
</div>
|
|
|
|
| 36 |
|
| 37 |
+
</header>
|
| 38 |
<div id="app-body" class="app-body">
|
| 39 |
+
|
| 40 |
<aside id="left-sidebar" class="sidebar left-sidebar">
|
| 41 |
<div class="sidebar-header"><span id="sidebar-title">数据包</span><button id="sidebar-expand-btn" class="icon-btn-sm" title="展开/收起侧边栏" style="display:none">↔</button></div>
|
| 42 |
<div id="sidebar-content" class="sidebar-content"><div class="sidebar-loading">加载中...</div></div>
|
| 43 |
+
|
| 44 |
</aside>
|
| 45 |
|
| 46 |
<main id="main-content" class="main-content">
|
|
|
|
| 48 |
<div class="status-left">
|
| 49 |
<span id="result-count" class="result-count"></span>
|
| 50 |
<label class="multi-toggle" id="multi-toggle-label" style="display:none"><input type="checkbox" id="multi-select-toggle"><span>多选</span></label>
|
|
|
|
| 51 |
</div>
|
| 52 |
<div class="status-right">
|
| 53 |
<button id="clear-filters-btn" class="text-btn" style="display:none">清空筛选</button>
|
|
|
|
| 61 |
</div>
|
| 62 |
</div>
|
| 63 |
</div>
|
|
|
|
| 64 |
<div id="multi-action-bar" class="multi-action-bar" style="display:none">
|
| 65 |
<button id="multi-select-all" class="multi-action-btn">全选</button>
|
| 66 |
<button id="multi-deselect" class="multi-action-btn">取消选择</button>
|
|
|
|
| 68 |
<button id="multi-zip-download" class="multi-action-btn primary">合并下载</button>
|
| 69 |
<span id="multi-selected-count" class="multi-selected-count"></span>
|
| 70 |
</div>
|
|
|
|
| 71 |
<div id="results-container" class="results-container">
|
| 72 |
<div id="results-list" class="results-list"></div>
|
| 73 |
<div id="results-loading" class="loading-spinner" style="display:none"><div class="spinner"></div></div>
|
|
|
|
| 77 |
<button id="empty-random-btn" class="random-big-btn">随机来一篇</button>
|
| 78 |
</div>
|
| 79 |
</div>
|
|
|
|
| 80 |
<div id="load-info" class="load-info" style="display:none">已加载 <span id="loaded-count">0</span> / <span id="total-count">0</span> 条</div>
|
| 81 |
<div id="preview-panel" class="preview-panel" style="display:none"></div>
|
| 82 |
+
|
| 83 |
</main>
|
| 84 |
|
| 85 |
<aside id="right-sidebar" class="sidebar right-sidebar">
|
|
|
|
| 132 |
</div>
|
| 133 |
</div>
|
| 134 |
</div>
|
| 135 |
+
|
| 136 |
</aside>
|
| 137 |
</div>
|
| 138 |
|
|
|
|
| 142 |
<span class="ui-icon ui-icon-book" aria-hidden="true"></span>
|
| 143 |
<span>随机来一篇</span>
|
| 144 |
</button>
|
|
|
|
| 145 |
|
| 146 |
+
</footer>
|
| 147 |
<div id="overlay" class="overlay" style="display:none"></div>
|
| 148 |
<div id="toast" class="toast" style="display:none"></div>
|
| 149 |
+
|
| 150 |
<script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { scope: "/" }); }</script>
|
| 151 |
<script src="/static/app.js"></script>
|
| 152 |
</body>
|
static/style.css
CHANGED
|
@@ -1128,16 +1128,6 @@ body.mobile .multi-toggle { padding: 1px 4px; font-size: 11px; }
|
|
| 1128 |
cursor: pointer;
|
| 1129 |
}
|
| 1130 |
|
| 1131 |
-
.did-you-mean {
|
| 1132 |
-
font-size: 12px;
|
| 1133 |
-
color: var(--primary);
|
| 1134 |
-
cursor: pointer;
|
| 1135 |
-
}
|
| 1136 |
-
|
| 1137 |
-
.did-you-mean:hover, .did-you-mean:focus-visible {
|
| 1138 |
-
text-decoration: underline;
|
| 1139 |
-
}
|
| 1140 |
-
|
| 1141 |
.results-container {
|
| 1142 |
flex: 1;
|
| 1143 |
overflow-y: auto;
|
|
|
|
| 1128 |
cursor: pointer;
|
| 1129 |
}
|
| 1130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1131 |
.results-container {
|
| 1132 |
flex: 1;
|
| 1133 |
overflow-y: auto;
|