| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| const ORDERED_EXTENSIONS = [ |
| "pdf", "txt", |
| "epub", "mobi", "azw3", "fb2", "djvu", "chm", "caj", |
| "doc", "docx", "odt", "rtf", |
| "ppt", "xlsx", |
| "jpg", "png", "gif", "tif", |
| "html", "htm", "aspx", "css", "js", "xml", |
| "mht", |
| "mp4", "flv", "swf", "rm", "rmvb", |
| "mp3", "wav", |
| "iso", "dat", "exe", |
| ]; |
|
|
| const FILE_ICON_MAP = { |
| pdf: "pdf", |
| txt: "text", mht: "text", |
| epub: "book", mobi: "book", azw3: "book", fb2: "book", djvu: "book", chm: "book", caj: "book", |
| doc: "doc", docx: "doc", odt: "doc", rtf: "doc", |
| ppt: "ppt", pptx: "ppt", pps: "ppt", |
| xls: "xls", xlsx: "xls", csv: "csv", |
| jpg: "image", jpeg: "image", png: "image", gif: "image", tif: "image", tiff: "image", |
| bmp: "image", webp: "image", svg: "image", |
| html: "code", htm: "code", aspx: "code", css: "code", js: "code", xml: "code", |
| json: "code", ini: "code", bat: "code", |
| mp4: "video", flv: "video", swf: "video", rm: "video", rmvb: "video", |
| wmv: "video", mpg: "video", mts: "video", f4v: "video", asx: "video", |
| mp3: "audio", wav: "audio", wma: "audio", ape: "audio", m4a: "audio", mpga: "audio", |
| iso: "archive", msi: "archive", dat: "archive", |
| exe: "file", |
| db: "database", itf: "database", |
| url: "text", vcf: "text", hhc: "text", |
| md: "markdown", markdown: "markdown", |
| }; |
|
|
| const ICONS = { |
| folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>', |
| pdf: '<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"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="9" y2="9"/></svg>', |
| doc: '<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"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>', |
| book: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>', |
| image: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>', |
| video: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>', |
| audio: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>', |
| archive: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>', |
| code: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>', |
| csv: '<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"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/></svg>', |
| markdown: '<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"/><line x1="8" y1="13" x2="11" y2="16"/><line x1="8" y1="13" x2="11" y2="10"/><line x1="13" y1="13" x2="16" y2="16"/><polyline points="13 16 16 13 19 16"/></svg>', |
| text: '<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"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>', |
| ppt: '<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"/><line x1="8" y1="13" x2="16" y2="13"/></svg>', |
| xls: '<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"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="16" y2="9"/></svg>', |
| 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>', |
| 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>', |
| }; |
|
|
| const MIRROR_HOST = "hf-mirror.com"; |
|
|
| |
| |
| |
|
|
| const STATE = { |
| mode: "global", |
| repo: null, |
| repoFull: null, |
| query: "", |
| page: 1, |
| pageSize: 100, |
| total: 0, |
| results: [], |
| didYouMean: null, |
| filterRepos: [], |
| filterExtensions: [], |
| filterFolders: [], |
| filterFolderSubtrees: [], |
| filterFolderSelfs: [], |
| filterMinSize: null, |
| filterMaxSize: null, |
| useMirrorLinks: true, |
| leftSidebarOpen: true, |
| rightSidebarOpen: false, |
| isMobile: false, |
| isDark: true, |
| isLoading: false, |
| hasMore: false, |
| searchFolders: true, |
| exact: true, |
| recordHistory: true, |
| resultsSkeletonActive: false, |
| browserPath: "", |
| repoList: [], |
| extensionList: [], |
| folderTree: null, |
| folderTreeCollapsed: {}, |
| folderNodeMap: {}, |
| _pendingPage: 0, |
| _loadedPage: 0, |
| _pageCache: {}, |
| }; |
|
|
| let searchAbortController = null; |
| let searchRequestId = 0; |
| let scrollTicking = false; |
|
|
| |
| |
| |
|
|
| function bytesToDisplay(bytes) { |
| if (bytes === null || bytes === undefined || bytes === 0) return { value: "", unit: "MB" }; |
| if (bytes >= 1073741824) return { value: (bytes / 1073741824).toFixed(2).replace(/\.?0+$/, ""), unit: "GB" }; |
| if (bytes >= 1048576) return { value: (bytes / 1048576).toFixed(1).replace(/\.0$/, ""), unit: "MB" }; |
| if (bytes >= 1024) return { value: (bytes / 1024).toFixed(1).replace(/\.0$/, ""), unit: "KB" }; |
| return { value: String(bytes), unit: "B" }; |
| } |
|
|
| function fmtSizeUrl(bytes) { |
| if (bytes === null || bytes === undefined) return null; |
| var d = bytesToDisplay(bytes); |
| return d.value + d.unit; |
| } |
|
|
| function parseSizeStr(str) { |
| if (!str) return null; |
| var m = String(str).match(/^([\d.]+)\s*(GB|MB|KB|B)?$/i); |
| if (!m) return parseInt(str) || null; |
| var val = parseFloat(m[1]); |
| var unit = (m[2] || "B").toUpperCase(); |
| if (unit === "GB") val *= 1073741824; |
| else if (unit === "MB") val *= 1048576; |
| else if (unit === "KB") val *= 1024; |
| return Math.round(val); |
| } |
|
|
| |
| |
| |
|
|
| var HISTORY_KEY = "voml_search_history"; |
| var HISTORY_MAX = 20; |
|
|
| function getHistory() { |
| try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; } |
| catch (e) { return []; } |
| } |
|
|
| function saveHistory(list) { |
| try { localStorage.setItem(HISTORY_KEY, JSON.stringify(list.slice(0, HISTORY_MAX))); } |
| catch (e) {} |
| } |
|
|
| function addHistoryItem(q) { |
| if (!q || !STATE.recordHistory) return; |
| var list = getHistory(); |
| var idx = list.indexOf(q); |
| if (idx >= 0) list.splice(idx, 1); |
| list.unshift(q); |
| saveHistory(list); |
| } |
|
|
| function renderDropdown() { |
| if (!DOM.historyDropdown) return; |
| var list = getHistory(); |
| if (list.length === 0) { DOM.historyDropdown.style.display = "none"; return; } |
| var html = ""; |
| for (var h = 0; h < list.length; h++) { |
| html += '<div class="history-item" data-query="' + escapeHTML(list[h]) + '">' + |
| '<svg class="history-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>' + |
| '<span class="history-text">' + escapeHTML(list[h]) + '</span>' + |
| '<button class="history-del" data-del="' + escapeHTML(list[h]) + '">×</button>' + |
| '</div>'; |
| } |
| html += '<div class="history-footer"><button class="history-clear-all">清空历史</button></div>'; |
| DOM.historyDropdown.innerHTML = html; |
| DOM.historyDropdown.style.display = ""; |
| } |
|
|
| function removeHistoryItem(q) { |
| var list = getHistory(); |
| var idx = list.indexOf(q); |
| if (idx >= 0) list.splice(idx, 1); |
| saveHistory(list); |
| renderDropdown(); |
| } |
|
|
| var selectedIndices = {}; |
| var lastSelectedIndex = -1; |
|
|
| function updateSelectionUI() { |
| if (!DOM.multiSelectToggle || !DOM.multiActionBar) return; |
| var count = Object.keys(selectedIndices).length; |
| DOM.multiSelectedCount.textContent = count > 0 ? (STATE.isMobile ? "" : ("已选" + count + "项")) : ""; |
| if (DOM.mobileSelectedCount) { |
| DOM.mobileSelectedCount.textContent = count > 0 ? ("已选" + count) : ""; |
| DOM.mobileSelectedCount.style.display = (STATE.isMobile && DOM.multiSelectToggle.checked && count > 0) ? "inline-block" : "none"; |
| } |
| DOM.multiActionBar.style.display = DOM.multiSelectToggle.checked ? "" : "none"; |
| if (DOM.multiCopyLinks) DOM.multiCopyLinks.textContent = STATE.isMobile ? "复制" : "复制链接"; |
| if (DOM.multiDeselect) DOM.multiDeselect.textContent = STATE.isMobile ? "取消" : "取消选择"; |
| if (DOM.multiSelectToggle.checked) { |
| document.body.classList.add("multiselect"); |
| } else { |
| document.body.classList.remove("multiselect"); |
| selectedIndices = {}; |
| lastSelectedIndex = -1; |
| } |
| var cbs = DOM.resultsList.querySelectorAll(".result-checkbox"); |
| for (var ci = 0; ci < cbs.length; ci++) { |
| var idx = parseInt(cbs[ci].dataset.index); |
| cbs[ci].checked = !!selectedIndices[idx]; |
| var item = cbs[ci].closest(".result-item"); |
| if (item) item.classList.toggle("selected", !!selectedIndices[idx]); |
| } |
| } |
|
|
| |
| |
| |
|
|
| const $ = (sel) => document.querySelector(sel); |
| const DOM = {}; |
|
|
| function cacheDOM() { |
| DOM.headerTitle = $("#header-title"); |
| DOM.headerLogo = $("#header-logo"); |
| DOM.searchInput = $("#search-input"); |
| DOM.hamburgerBtn = $("#hamburger-btn"); |
| DOM.settingsBtn = $("#settings-btn"); |
| DOM.themeBtn = $("#theme-btn"); |
| DOM.mobileToggleBtn = $("#mobile-toggle-btn"); |
| DOM.themeIconLight = $("#theme-icon-light"); |
| DOM.themeIconDark = $("#theme-icon-dark"); |
| DOM.mobileIconPhone = $("#mobile-icon-phone"); |
| DOM.mobileIconDesktop = $("#mobile-icon-desktop"); |
| DOM.leftSidebar = $("#left-sidebar"); |
| DOM.rightSidebar = $("#right-sidebar"); |
| DOM.sidebarContent = $("#sidebar-content"); |
| DOM.sidebarTitle = $("#sidebar-title"); |
| DOM.resultsList = $("#results-list"); |
| DOM.resultsContainer = $("#results-container"); |
| DOM.resultsLoading = $("#results-loading"); |
| DOM.emptyState = $("#empty-state"); |
| DOM.emptyDesc = $("#empty-desc"); |
| DOM.emptyRandomBtn = $("#empty-random-btn"); |
| DOM.resultCount = $("#result-count"); |
| DOM.didYouMean = $("#did-you-mean"); |
| DOM.clearFiltersBtn = $("#clear-filters-btn"); |
| DOM.sortSelect = $("#sort-select"); |
| DOM.loadInfo = $("#load-info"); |
| DOM.mobileSelectedCount = $("#mobile-selected-count"); |
| DOM.loadedCount = $("#loaded-count"); |
| DOM.totalCount = $("#total-count"); |
| DOM.scrollTrack = $("#scroll-track"); |
| DOM.scrollThumb = $("#scroll-thumb"); |
| DOM.hitokoto = $("#hitokoto"); |
| DOM.randomBookBtn = $("#random-book-btn"); |
| DOM.overlay = $("#overlay"); |
| DOM.toast = $("#toast"); |
| DOM.filterRepoSection = $("#filter-repo-section"); |
| DOM.filterRepoList = $("#filter-repo-list"); |
| DOM.filterFolderSection = $("#filter-folder-section"); |
| DOM.filterFolderTree = $("#filter-folder-tree"); |
| DOM.filterExtList = $("#filter-ext-list"); |
| DOM.filterMinSize = $("#filter-min-size"); |
| DOM.filterMaxSize = $("#filter-max-size"); |
| DOM.mirrorLinksToggle = $("#mirror-links-toggle"); |
| DOM.filterMinUnit = $("#filter-min-unit"); |
| DOM.filterMaxUnit = $("#filter-max-unit"); |
| DOM.closeFiltersBtn = $("#close-filters-btn"); |
| DOM.folderSelectAll = $("#folder-select-all"); |
| DOM.folderDeselectAll = $("#folder-deselect-all"); |
| DOM.extSelectAll = $("#ext-select-all"); |
| DOM.extDeselectAll = $("#ext-deselect-all"); |
| DOM.sidebarExpandBtn = $("#sidebar-expand-btn"); |
| DOM.searchFoldersToggle = $("#search-folders-toggle"); |
| DOM.exactSearchToggle = $("#exact-search-toggle"); |
| DOM.historyToggle = $("#history-toggle"); |
| DOM.historyDropdown = $("#search-history-dropdown"); |
| DOM.multiToggleLabel = $("#multi-toggle-label"); |
| DOM.multiSelectToggle = $("#multi-select-toggle"); |
| DOM.multiActionBar = $("#multi-action-bar"); |
| DOM.multiCopyLinks = $("#multi-copy-links"); |
| DOM.multiBatchDownload = $("#multi-batch-download"); |
| DOM.multiZipDownload = $("#multi-zip-download"); |
| DOM.multiSelectAll = $("#multi-select-all"); |
| DOM.multiSelectedCount = $("#multi-selected-count"); |
| DOM.multiDeselect = $("#multi-deselect"); |
| } |
|
|
| |
| |
| |
|
|
| const HTML_ESCAPE_MAP = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; |
| function escapeHTML(str) { |
| return String(str).replace(/[&<>"']/g, (ch) => HTML_ESCAPE_MAP[ch]); |
| } |
|
|
| const sizeCache = {}; |
| function formatSize(bytes) { |
| if (!bytes && bytes !== 0) return ""; |
| if (sizeCache[bytes] !== undefined) return sizeCache[bytes]; |
| if (typeof bytes === "string") bytes = parseInt(bytes); |
| if (isNaN(bytes) || bytes === 0) return (sizeCache[bytes] = ""); |
| let result; |
| if (bytes < 1024) result = bytes + " B"; |
| else if (bytes < 1048576) result = (bytes / 1024).toFixed(1) + " KB"; |
| else if (bytes < 1073741824) result = (bytes / 1048576).toFixed(1) + " MB"; |
| else result = (bytes / 1073741824).toFixed(2) + " GB"; |
| return (sizeCache[bytes] = result); |
| } |
|
|
| function getFileIconType(ext) { |
| const key = (ext || "").toLowerCase(); |
| return FILE_ICON_MAP[key] || "file"; |
| } |
|
|
| function highlightText(text, query) { |
| if (!query || !text) return escapeHTML(text); |
| const escaped = escapeHTML(text); |
| const tokens = query.split(/\s+/).filter((t) => t.length > 0); |
| if (tokens.length === 0) return escaped; |
| let result = escaped; |
| for (const tok of tokens) { |
| const escapedTok = escapeHTML(tok); |
| const regex = new RegExp( |
| `(${escapedTok.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, |
| "gi" |
| ); |
| result = result.replace(regex, "<mark>$1</mark>"); |
| } |
| return result; |
| } |
|
|
| function toMirrorURL(url) { |
| if (!url) return url; |
| try { |
| const parsed = new URL(url, window.location.origin); |
| if (parsed.hostname === "huggingface.co") parsed.hostname = MIRROR_HOST; |
| return parsed.toString(); |
| } catch (e) { |
| return url; |
| } |
| } |
|
|
| function getCopyableLink(link) { |
| return STATE.useMirrorLinks ? toMirrorURL(link) : link; |
| } |
|
|
| function getPreviewLink(path) { |
| return STATE.useMirrorLinks ? toMirrorURL(path) : path; |
| } |
|
|
| function buildDownloadUrl(filename, link) { |
| return `/api/download?file=${encodeURIComponent(filename || "file")}&link=${encodeURIComponent(link || "")}`; |
| } |
|
|
| function getBrowserFileName(file) { |
| const name = file && file.name ? String(file.name) : "file"; |
| const ext = file && file.ext ? String(file.ext) : ""; |
| if (!ext) return name; |
| if (name.toLowerCase().endsWith(`.${ext.toLowerCase()}`)) return name; |
| return `${name}.${ext}`; |
| } |
|
|
| function getBrowserFileLink(repo, folderPath, file) { |
| if (file && file.link) return file.link; |
| if (!repo) return ""; |
| const fullName = getBrowserFileName(file); |
| const relativePath = folderPath ? `${folderPath}/${fullName}` : fullName; |
| return `https://huggingface.co/datasets/VoiceOfML/${repo}/resolve/main/${encodeURI(relativePath)}`; |
| } |
|
|
| |
| |
| |
|
|
| const API = { |
| async search(params = {}) { |
| const q = params.q || ""; |
| const isRepo = !!STATE.repo; |
| const base = isRepo ? `/api/search/${STATE.repo}` : "/api/search"; |
| const body = {}; |
| if (q) body.q = q; |
| if (params.page) body.page = params.page; |
| body.page_size = params.pageSize || STATE.pageSize; |
| if (!isRepo && STATE.filterRepos.length > 0) |
| body.repos = STATE.filterRepos; |
| if (STATE.filterExtensions.length > 0) |
| body.extensions = STATE.filterExtensions; |
| if (STATE.filterFolderSelfs.length > 0 || STATE.filterFolderSubtrees.length > 0) { |
| body.folders = STATE.filterFolderSelfs.concat(STATE.filterFolderSubtrees.filter(path => !STATE.filterFolderSelfs.includes(path))); |
| body.folder_match_mode = "mixed"; |
| body.folder_selfs = STATE.filterFolderSelfs; |
| body.folder_subtrees = STATE.filterFolderSubtrees; |
| } else if (STATE.filterFolders.length > 0) { |
| body.folders = STATE.filterFolders; |
| } |
| if (STATE.filterMinSize !== null) body.min_size = STATE.filterMinSize; |
| if (STATE.filterMaxSize !== null) body.max_size = STATE.filterMaxSize; |
| body.sort = DOM.sortSelect.value; |
| if (!STATE.searchFolders) body.search_folders = false; |
| if (STATE.exact) body.exact = true; |
|
|
| const fetchOptions = { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify(body), |
| }; |
| if (params.signal) fetchOptions.signal = params.signal; |
|
|
| const resp = await fetch(base, fetchOptions); |
| if (!resp.ok) throw new Error(`HTTP ${resp.status}`); |
| return resp.json(); |
| }, |
| async getRepos() { const resp = await fetch("/api/repos"); return resp.json(); }, |
| async getExtensions(repo) { |
| const cacheKey = repo || "__global__"; |
| if (this._extCache && this._extCache.key === cacheKey) return this._extCache.data; |
| const url = repo ? `/api/extensions?repo=${repo}` : "/api/extensions"; |
| let resp = await fetch(url); |
| let data = await resp.json(); |
| if ((!data || data.length === 0) && repo) { |
| const fallbackResp = await fetch("/api/extensions"); |
| data = await fallbackResp.json(); |
| } |
| this._extCache = { key: cacheKey, data }; |
| return data; |
| }, |
| async getFolders(repo) { const resp = await fetch(`/api/folders/${repo}`); return resp.json(); }, |
| async getContents(repo, path) { |
| const cacheKey = repo + "|" + (path || ""); |
| if (!this._browserCache) this._browserCache = new Map(); |
| if (!this._browserPending) this._browserPending = new Map(); |
| if (this._browserCache.has(cacheKey)) return this._browserCache.get(cacheKey); |
| if (this._browserPending.has(cacheKey)) return this._browserPending.get(cacheKey); |
| const qs = path ? `?path=${encodeURIComponent(path)}` : ""; |
| const pending = fetch(`/api/folders/${repo}/contents${qs}`) |
| .then(resp => resp.json()) |
| .then(data => { |
| this._browserPending.delete(cacheKey); |
| this._browserCache.set(cacheKey, data); |
| if (this._browserCache.size > 200) { |
| const firstKey = this._browserCache.keys().next().value; |
| this._browserCache.delete(firstKey); |
| } |
| return data; |
| }) |
| .catch(err => { |
| this._browserPending.delete(cacheKey); |
| throw err; |
| }); |
| this._browserPending.set(cacheKey, pending); |
| return pending; |
| }, |
| async getRandom(repo) { |
| const url = repo ? `/api/random?repo=${repo}` : "/api/random"; |
| const resp = await fetch(url); |
| return resp.json(); |
| }, |
| }; |
|
|
| function scheduleBrowserRootPrefetch(repos) { |
| const run = () => { |
| for (const repo of repos) { |
| const shortName = repo && repo.name ? repo.name.split("/").pop() : ""; |
| if (shortName) API.getContents(shortName, "").catch(() => {}); |
| } |
| }; |
| if (window.requestIdleCallback) window.requestIdleCallback(run, { timeout: 1500 }); |
| else setTimeout(run, 200); |
| } |
|
|
| |
| |
| |
|
|
| const ROUTER = { |
| parse() { |
| const path = window.location.pathname.replace(/\/+$/, ""); |
| if (!path || path === "/") return { mode: "global", repo: null }; |
| const parts = path.split("/").filter(Boolean); |
| if (parts.length === 1) return { mode: "repo", repo: parts[0] }; |
| return { mode: "global", repo: null }; |
| }, |
| navigate(mode, repo) { |
| var params = new URLSearchParams(); |
| if (STATE.query) params.set("q", STATE.query); |
| if (STATE.filterExtensions.length) params.set("ext", STATE.filterExtensions.join(",")); |
| if (STATE.filterMinSize !== null) params.set("min_size", fmtSizeUrl(STATE.filterMinSize)); |
| if (STATE.filterMaxSize !== null) params.set("max_size", fmtSizeUrl(STATE.filterMaxSize)); |
| if (!STATE.recordHistory) params.set("history", "0"); |
| if (!STATE.useMirrorLinks) params.set("mirror", "0"); |
| if (DOM.sortSelect.value !== "relevance") params.set("sort", DOM.sortSelect.value); |
| if (!STATE.searchFolders) params.set("search_folders", "false"); |
| if (!STATE.exact) params.set("exact", "0"); |
| if (!STATE.leftSidebarOpen) params.set("sidebar", "0"); |
| if (STATE.rightSidebarOpen) params.set("filters", "1"); |
| if (DOM.leftSidebar.classList.contains("expanded-wide")) params.set("wide", "1"); |
| var qs = params.toString(); |
| var url = mode === "global" ? "/" : "/" + repo; |
| if (qs) url += "?" + qs; |
| history.pushState(null, "", url); |
| this.apply(); |
| }, |
| apply() { |
| const route = this.parse(); |
| const prevMode = STATE.mode; |
| const prevRepo = STATE.repo; |
| STATE.mode = route.mode; |
| STATE.repo = route.repo; |
| STATE.repoFull = route.repo ? `VoiceOfML/${route.repo}` : null; |
|
|
| if (prevMode !== STATE.mode || prevRepo !== STATE.repo) { |
| STATE.page = 1; |
| STATE.results = []; |
| STATE.total = 0; |
| STATE.filterFolders = []; |
| STATE.filterFolderSubtrees = []; |
| STATE.filterFolderSelfs = []; |
| STATE.folderTree = null; |
| STATE.folderTreeCollapsed = {}; |
| STATE.browserPath = ""; |
| API._extCache = null; |
| syncURLToState(); |
| } |
|
|
| this.updateUI(); |
|
|
| if (prevMode !== STATE.mode || prevRepo !== STATE.repo) { |
| const wasWide = new URLSearchParams(window.location.search).get("wide") === "1"; |
| this.onModeChanged(); |
| if (wasWide) { |
| DOM.leftSidebar.classList.add("expanded-wide"); |
| DOM.sidebarExpandBtn.textContent = "→"; |
| syncStateToURL(true); |
| } |
| } else { |
| |
| syncURLToState(); |
| renderSidebar(); |
| renderFilters(); |
| doSearch(); |
| } |
| }, |
| updateUI() { |
| if (STATE.mode === "global") { |
| DOM.headerTitle.textContent = "VoiceOfML"; |
| DOM.headerLogo.href = "https://huggingface.co/VoiceOfML"; |
| DOM.searchInput.placeholder = "搜索 VoiceOfML 数据仓库..."; |
| DOM.sidebarTitle.textContent = "仓库列表"; |
| } else { |
| DOM.headerTitle.textContent = STATE.repo; |
| DOM.headerLogo.href = "/"; |
| DOM.searchInput.placeholder = `搜索 ${STATE.repo}...`; |
| DOM.sidebarTitle.textContent = STATE.repo; |
| } |
| }, |
| onModeChanged() { |
| |
| DOM.leftSidebar.classList.remove("expanded-wide"); |
| DOM.sidebarExpandBtn.textContent = "↔"; |
| if (!STATE.isMobile) { |
| DOM.resultsList.innerHTML = ""; |
| DOM.emptyState.style.display = "none"; |
| DOM.resultsLoading.style.display = "flex"; |
| } |
| renderSidebar(); |
| renderFilters(); |
| doSearch(); |
| DOM.sidebarExpandBtn.style.display = (STATE.mode === "repo" && !STATE.isMobile) ? "" : "none"; |
| }, |
| }; |
|
|
| |
|
|
| function syncStateToURL(replace = true) { |
| const params = new URLSearchParams(); |
|
|
| if (STATE.query) params.set("q", STATE.query); |
| if (STATE.mode === "global") { |
| STATE.filterRepos.forEach(r => { |
| const short = r.split("/").pop(); |
| params.append("repo", short); |
| }); |
| } |
| if (STATE.filterExtensions.length > 0) { |
| params.set("ext", STATE.filterExtensions.join(",")); |
| } |
| if (STATE.mode !== "global" && STATE.browserPath) { |
| params.set("path", STATE.browserPath); |
| } |
| if (DOM.leftSidebar.classList.contains("expanded-wide")) { |
| params.set("wide", "1"); |
| } |
| if (!STATE.leftSidebarOpen) params.set("sidebar", "0"); |
| if (STATE.rightSidebarOpen) params.set("filters", "1"); |
| if (DOM.sortSelect.value !== "relevance") { |
| params.set("sort", DOM.sortSelect.value); |
| } |
| if (STATE.filterMinSize !== null) params.set("min_size", fmtSizeUrl(STATE.filterMinSize)); |
| if (STATE.filterMaxSize !== null) params.set("max_size", fmtSizeUrl(STATE.filterMaxSize)); |
| if (!STATE.searchFolders) params.set("search_folders", "false"); |
| if (!STATE.exact) params.set("exact", "0"); |
| if (!STATE.recordHistory) params.set("history", "0"); |
|
|
| const qs = params.toString(); |
| const path = STATE.mode === "global" ? "/" : `/${STATE.repo}`; |
| const url = qs ? `${path}?${qs}` : path; |
|
|
| if (replace) { |
| history.replaceState(null, "", url); |
| } else { |
| history.pushState(null, "", url); |
| } |
| } |
|
|
| function syncURLToState() { |
| const params = new URLSearchParams(window.location.search); |
|
|
| STATE.query = params.get("q") || ""; |
| DOM.searchInput.value = STATE.query; |
|
|
| STATE.filterRepos = params.getAll("repo").map(r => `VoiceOfML/${r}`); |
|
|
| const extStr = params.get("ext") || ""; |
| STATE.filterExtensions = extStr ? extStr.split(",").filter(Boolean) : []; |
|
|
| STATE.browserPath = params.get("path") || ""; |
|
|
| const sort = params.get("sort") || "relevance"; |
| DOM.sortSelect.value = sort; |
|
|
| const minSize = params.get("min_size"); |
| if (minSize) { |
| var parsed = parseSizeStr(minSize); |
| STATE.filterMinSize = parsed; |
| var disp = bytesToDisplay(parsed); |
| DOM.filterMinSize.value = disp.value; |
| DOM.filterMinUnit.value = disp.unit; |
| } else { |
| STATE.filterMinSize = null; |
| DOM.filterMinSize.value = ""; |
| DOM.filterMinUnit.value = "MB"; |
| } |
|
|
| const maxSize = params.get("max_size"); |
| if (maxSize) { |
| var parsedMx = parseSizeStr(maxSize); |
| STATE.filterMaxSize = parsedMx; |
| var dispMx = bytesToDisplay(parsedMx); |
| DOM.filterMaxSize.value = dispMx.value; |
| DOM.filterMaxUnit.value = dispMx.unit; |
| } else { |
| STATE.filterMaxSize = null; |
| DOM.filterMaxSize.value = ""; |
| DOM.filterMaxUnit.value = "MB"; |
| } |
|
|
| const searchFolders = params.get("search_folders"); |
| STATE.searchFolders = searchFolders !== "false"; |
| DOM.searchFoldersToggle.checked = STATE.searchFolders; |
|
|
| STATE.exact = params.get("exact") !== "0"; |
| DOM.exactSearchToggle.checked = STATE.exact; |
|
|
| STATE.recordHistory = params.get("history") !== "0"; |
| if (DOM.historyToggle) DOM.historyToggle.checked = STATE.recordHistory; |
| STATE.useMirrorLinks = params.get("mirror") !== "0"; |
| if (DOM.mirrorLinksToggle) DOM.mirrorLinksToggle.checked = STATE.useMirrorLinks; |
|
|
| if (params.get("wide") === "1") { |
| DOM.leftSidebar.classList.add("expanded-wide"); |
| DOM.sidebarExpandBtn.textContent = "→"; |
| } |
|
|
| if (params.get("sidebar") === "0") { |
| STATE.leftSidebarOpen = false; |
| updateSidebarVisibility(); |
| } |
| STATE.rightSidebarOpen = params.get("filters") === "1"; |
| updateSidebarVisibility(); |
|
|
| STATE.page = 1; |
| STATE.results = []; |
| } |
|
|
| |
| |
| |
|
|
| let searchTimer = null; |
|
|
| async function renderSidebar() { |
| if (STATE.mode === "global") { |
| await renderRepoList(); |
| } else { |
| await renderBrowser(STATE.browserPath || ""); |
| } |
| } |
|
|
| async function renderRepoList() { |
| if (!STATE.repoList || STATE.repoList.length === 0) { |
| try { STATE.repoList = await API.getRepos(); } catch (e) { DOM.sidebarContent.innerHTML = '<div class="sidebar-loading">加载失败</div>'; return; } |
| } |
| let html = ""; |
| for (const repo of STATE.repoList) { |
| const shortName = repo.name.split("/").pop(); |
| html += `<div class="repo-list-item" data-repo="${escapeHTML(shortName)}"><svg class="repo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg><span class="repo-name">${escapeHTML(shortName)}</span><span class="repo-count">${(repo.count || 0).toLocaleString()}</span></div>`; |
| } |
| DOM.sidebarContent.innerHTML = html; |
| scheduleBrowserRootPrefetch(STATE.repoList || []); |
| } |
|
|
| async function renderBrowser(path) { |
| STATE.browserPath = path; |
| syncStateToURL(true); |
| DOM.sidebarContent.innerHTML = ""; |
| const currentRepo = STATE.repo; |
| const backBtn = document.createElement("div"); |
| backBtn.className = "back-to-global"; |
| backBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>返回全局搜索'; |
| backBtn.addEventListener("click", () => ROUTER.navigate("global")); |
| DOM.sidebarContent.appendChild(backBtn); |
| if (path) { |
| const bc = document.createElement("div"); |
| bc.className = "sidebar-breadcrumb"; |
| const parts = path.split("/"); |
| bc.innerHTML = `<span class="crumb-item" data-path="">根目录</span>` + |
| parts.map((p, i) => { |
| const pp = parts.slice(0, i + 1).join("/"); |
| return `<span class="crumb-sep">/</span><span class="crumb-item${i === parts.length - 1 ? ' current' : ''}" data-path="${escapeHTML(pp)}">${escapeHTML(p)}</span>`; |
| }).join(""); |
| bc.querySelectorAll(".crumb-item").forEach(el => { |
| el.addEventListener("click", () => { |
| if (!el.classList.contains("current")) renderBrowser(el.dataset.path); |
| }); |
| }); |
| DOM.sidebarContent.appendChild(bc); |
| } |
| const list = document.createElement("div"); |
| list.className = "browser-list"; |
| list.innerHTML = '<div class="sidebar-loading">加载中...</div>'; |
| DOM.sidebarContent.appendChild(list); |
| try { |
| const data = await API.getContents(STATE.repo, path); |
| list.innerHTML = ""; |
| for (const f of (data.folders || [])) { |
| const div = document.createElement("div"); |
| div.className = "browser-item"; |
| div.innerHTML = `${ICONS.folder}<span class="browser-name">${escapeHTML(f.name)}</span><span class="browser-count">${(f.count || 0).toLocaleString()}</span>`; |
| div.addEventListener("click", () => renderBrowser(f.path)); |
| list.appendChild(div); |
| } |
| for (const f of (data.files || [])) { |
| const div = document.createElement("div"); |
| div.className = "browser-item"; |
| const iconType = getFileIconType(f.ext); |
| const sizeStr = formatSize(f.size); |
| div.innerHTML = `${ICONS[iconType] || ICONS.file}<span class="browser-name">${escapeHTML(f.name)}${f.ext ? '.' + escapeHTML(f.ext) : ''}</span>${sizeStr ? '<span class="browser-size">' + sizeStr + '</span>' : ''}${f.hasTxt ? '<span class="browser-action" data-read="1">📖 阅读</span>' : ''}`; |
| div.addEventListener("click", (e) => { |
| if (e.target.closest(".browser-action")) { |
| e.stopPropagation(); |
| const stem = f.ext ? (f.name) : f.name; |
| const txtUrl = `/txt/${encodeURIComponent(path ? path + '/' + stem : stem)}.txt`; |
| window.open(txtUrl, "_blank"); |
| return; |
| } |
| const fileLink = getBrowserFileLink(currentRepo, path, f); |
| if (fileLink) { |
| const filename = getBrowserFileName(f); |
| window.open(buildDownloadUrl(filename, fileLink), "_blank"); |
| } |
| }); |
| list.appendChild(div); |
| } |
| } catch (e) { |
| list.innerHTML = '<div class="sidebar-loading">加载失败</div>'; |
| } |
| } |
|
|
| function debouncedSearch() { |
| clearTimeout(searchTimer); |
| searchTimer = setTimeout(() => { |
| STATE.query = DOM.searchInput.value.trim(); |
| STATE.page = 1; |
| STATE.results = []; |
| addHistoryItem(STATE.query); |
| renderDropdown(); |
| doSearch(); |
| }, 300); |
| } |
|
|
| function shouldShowResultsSkeleton(append = false) { |
| if (append) return false; |
| if (STATE.results.length > 0) return false; |
| if (STATE.mode === "global") return true; |
| return !STATE.isMobile && STATE.mode === "repo"; |
| } |
|
|
| function renderResultsSkeleton(count = 8) { |
| let html = ""; |
| for (let i = 0; i < count; i++) { |
| html += `<div class="result-skeleton-item" aria-hidden="true"> |
| <div class="result-skeleton-icon skeleton-shimmer"></div> |
| <div class="result-skeleton-info"> |
| <div class="result-skeleton-line title skeleton-shimmer"></div> |
| <div class="result-skeleton-line path skeleton-shimmer"></div> |
| <div class="result-skeleton-line meta skeleton-shimmer"></div> |
| </div> |
| <div class="result-skeleton-actions"> |
| <div class="result-skeleton-btn skeleton-shimmer"></div> |
| <div class="result-skeleton-btn skeleton-shimmer"></div> |
| <div class="result-skeleton-btn skeleton-shimmer short"></div> |
| </div> |
| </div>`; |
| } |
| DOM.resultsList.innerHTML = html; |
| DOM.resultsList.classList.add("showing-skeleton"); |
| } |
|
|
| function clearResultsSkeleton() { |
| DOM.resultsList.classList.remove("showing-skeleton"); |
| } |
|
|
| function animateResultsReveal() { |
| DOM.resultsList.classList.remove("results-reveal"); |
| void DOM.resultsList.offsetWidth; |
| DOM.resultsList.classList.add("results-reveal"); |
| requestAnimationFrame(() => { |
| const items = DOM.resultsList.querySelectorAll(".result-item"); |
| for (let i = 0; i < items.length; i++) { |
| items[i].style.setProperty("--reveal-delay", `${Math.min(i * 28, 220)}ms`); |
| } |
| }); |
| setTimeout(() => { |
| DOM.resultsList.classList.remove("results-reveal"); |
| }, 560); |
| } |
|
|
| |
| |
| |
|
|
| function renderResults(newItems) { |
| const items = newItems || STATE.results; |
| if (!newItems) DOM.resultsList.innerHTML = ""; |
| clearResultsSkeleton(); |
|
|
| if (items.length === 0 && !newItems) { |
| DOM.emptyState.style.display = "flex"; |
| DOM.emptyDesc.textContent = STATE.query ? `没有找到与 "${STATE.query}" 相关的结果` : "暂无数据"; |
| DOM.multiToggleLabel.style.display = "none"; |
| DOM.multiActionBar.style.display = "none"; |
| return; |
| } |
| DOM.emptyState.style.display = "none"; |
| DOM.multiToggleLabel.style.display = ""; |
| if (DOM.multiSelectToggle && DOM.multiSelectToggle.checked) updateSelectionUI(); |
| if (!newItems && STATE.resultsSkeletonActive) animateResultsReveal(); |
|
|
| var offset = newItems ? STATE.results.length - items.length : 0; |
| const fragment = document.createDocumentFragment(); |
| for (const rec of items) { |
| const item = document.createElement("div"); |
| item.className = "result-item"; |
| const iconType = getFileIconType(rec.Extension); |
| const titleHTML = highlightText(rec.File, STATE.query); |
| const repoShort = (rec.Repo || "").split("/").pop(); |
| const sizeStr = formatSize(rec.Size); |
| const breadcrumb = (rec.Folder || []).map((f, i) => { |
| const accum = (rec.Folder || []).slice(0, i + 1).join("/"); |
| const folderDisplay = STATE.searchFolders ? highlightText(f, STATE.query) : escapeHTML(f); |
| return `<span class="path-sep">/</span><span class="path-folder" data-folder="${escapeHTML(accum)}" data-repo="${repoShort}">${folderDisplay}</span>`; |
| }).join(""); |
| var currentIdx = offset + fragment.children.length; |
| item.innerHTML = ` |
| <input type="checkbox" class="result-checkbox" data-index="${currentIdx}"> |
| <div class="result-file-icon">${ICONS[iconType] || ICONS.file}</div> |
| <div class="result-info"> |
| <div class="result-title">${titleHTML}${rec.Extension ? `<span style="opacity:0.5;font-size:12px">.${escapeHTML(rec.Extension)}</span>` : ""}</div> |
| <div class="result-path"><span class="path-folder" data-folder="" data-repo="${repoShort}">${repoShort}</span>${breadcrumb}</div> |
| <div class="result-meta"> |
| ${STATE.mode === "global" ? `<span class="result-repo-tag" data-repo="${repoShort}">${repoShort}</span>` : ""} |
| ${sizeStr ? `<span class="result-size">${sizeStr}</span>` : ""} |
| </div> |
| </div> |
| <div class="result-actions"> |
| <button class="result-action-btn" data-action="copy" data-link="${escapeHTML(getCopyableLink(rec.Link))}">复制链接</button> |
| <a href="${buildDownloadUrl(rec.File + (rec.Extension ? '.' + rec.Extension : ''), rec.Link)}" class="result-action-btn primary">下载</a> |
| <a href="${escapeHTML(getPreviewLink(rec.Path))}" class="result-action-btn" target="_blank">仓库查看</a> |
| ${rec.HasTxt ? `<button class="result-action-btn" data-action="read" data-link="${escapeHTML(rec.Link)}" data-repo="${repoShort}">在线阅读</button>` : ""} |
| </div>`; |
| fragment.appendChild(item); |
| } |
| DOM.resultsList.appendChild(fragment); |
| } |
|
|
| function updateStatusBar() { |
| DOM.resultCount.textContent = STATE.total > 0 ? `共 ${STATE.total.toLocaleString()} 条结果` : ""; |
| const has = STATE.filterRepos.length || STATE.filterExtensions.length || STATE.filterFolderSelfs.length || STATE.filterFolderSubtrees.length || STATE.filterMinSize !== null || STATE.filterMaxSize !== null; |
| DOM.clearFiltersBtn.style.display = has ? "" : "none"; |
| if (DOM.multiToggleLabel) DOM.multiToggleLabel.style.display = STATE.total > 0 ? "" : "none"; |
| } |
|
|
| function updateLoadInfo() { |
| if (STATE.total === 0 && STATE.results.length === 0) { DOM.loadInfo.style.display = "none"; return; } |
| DOM.loadInfo.style.display = ""; |
| DOM.loadedCount.textContent = STATE.results.length.toLocaleString(); |
| DOM.totalCount.textContent = STATE.total.toLocaleString(); |
| } |
|
|
| |
| |
| |
|
|
| function prefetchNextPage() { |
| var nextPage = STATE._loadedPage + 1; |
| var totalPages = Math.ceil(STATE.total / STATE.pageSize); |
| if (nextPage > totalPages) return; |
| if (STATE._pageCache[nextPage]) return; |
|
|
| var reqId = searchRequestId; |
| var q = STATE.query || ""; |
| var isRepo = !!STATE.repo; |
| var base = isRepo ? "/api/search/" + STATE.repo : "/api/search"; |
| var body = {}; |
| if (q) body.q = q; |
| body.page = nextPage; |
| body.page_size = STATE.pageSize; |
| if (!isRepo && STATE.filterRepos.length > 0) body.repos = STATE.filterRepos; |
| if (STATE.filterExtensions.length > 0) body.extensions = STATE.filterExtensions; |
| if (STATE.filterFolderSelfs.length > 0 || STATE.filterFolderSubtrees.length > 0) { |
| body.folders = STATE.filterFolderSelfs.concat(STATE.filterFolderSubtrees.filter(path => !STATE.filterFolderSelfs.includes(path))); |
| body.folder_match_mode = "mixed"; |
| body.folder_selfs = STATE.filterFolderSelfs; |
| body.folder_subtrees = STATE.filterFolderSubtrees; |
| } else if (STATE.filterFolders.length > 0) { |
| body.folders = STATE.filterFolders; |
| } |
| if (STATE.filterMinSize !== null) body.min_size = STATE.filterMinSize; |
| if (STATE.filterMaxSize !== null) body.max_size = STATE.filterMaxSize; |
| body.sort = DOM.sortSelect.value; |
| if (!STATE.searchFolders) body.search_folders = false; |
| if (STATE.exact) body.exact = true; |
|
|
| fetch(base, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify(body), |
| }).then(function(resp) { |
| if (!resp.ok) return; |
| return resp.json(); |
| }).then(function(data) { |
| if (reqId !== searchRequestId) return; |
| if (data && data.results) { |
| STATE._pageCache[nextPage] = data.results; |
| } |
| }).catch(function() {}); |
| } |
|
|
| |
| |
| |
|
|
| async function doSearch(append = false) { |
| |
| |
| if (!append) { |
| selectedIndices = {}; |
| lastSelectedIndex = -1; |
| if (searchAbortController) searchAbortController.abort(); |
| searchAbortController = new AbortController(); |
| STATE._pageCache = {}; |
| STATE._loadedPage = 0; |
| STATE._pendingPage = 0; |
| if (scrollTicking) { |
| scrollTicking = false; |
| } |
| } |
|
|
| |
| if (append && STATE._pendingPage === STATE.page) return; |
|
|
| |
| if (append && STATE._pageCache[STATE.page]) { |
| var cp = STATE.page; |
| STATE.results = STATE.results.concat(STATE._pageCache[cp]); |
| delete STATE._pageCache[cp]; |
| STATE._loadedPage = cp; |
| var np = cp + 1; |
| while (STATE._pageCache[np]) { |
| STATE.results = STATE.results.concat(STATE._pageCache[np]); |
| delete STATE._pageCache[np]; |
| np++; |
| } |
| STATE._loadedPage = np - 1; |
| STATE.hasMore = STATE.results.length < STATE.total; |
| STATE._pendingPage = 0; |
| STATE.isLoading = false; |
| var cachedItems = STATE.results.slice(-STATE.pageSize * (np - cp)); |
| renderResults(cachedItems); |
| updateStatusBar(); |
| updateLoadInfo(); |
| syncStateToURL(true); |
| prefetchNextPage(); |
| return; |
| } |
|
|
| STATE.isLoading = true; |
| STATE._pendingPage = STATE.page; |
| const currentRequestId = ++searchRequestId; |
| const requestBody = { |
| q: STATE.query, |
| page: STATE.page, |
| page_size: STATE.pageSize, |
| sort: DOM.sortSelect.value, |
| signal: searchAbortController.signal, |
| }; |
| if (STATE.mode === "global" && STATE.filterRepos.length > 0) requestBody.repos = STATE.filterRepos; |
| if (STATE.filterExtensions.length > 0) requestBody.extensions = STATE.filterExtensions; |
| if (STATE.filterFolderSelfs.length > 0 || STATE.filterFolderSubtrees.length > 0) { |
| requestBody.folders = STATE.filterFolderSelfs.concat(STATE.filterFolderSubtrees.filter(path => !STATE.filterFolderSelfs.includes(path))); |
| requestBody.folder_match_mode = "mixed"; |
| requestBody.folder_selfs = STATE.filterFolderSelfs; |
| requestBody.folder_subtrees = STATE.filterFolderSubtrees; |
| } else if (STATE.filterFolders.length > 0) { |
| requestBody.folders = STATE.filterFolders; |
| } |
| if (STATE.filterMinSize !== null) requestBody.min_size = STATE.filterMinSize; |
| if (STATE.filterMaxSize !== null) requestBody.max_size = STATE.filterMaxSize; |
| if (!STATE.searchFolders) requestBody.search_folders = false; |
| if (STATE.exact) requestBody.exact = true; |
|
|
| STATE.resultsSkeletonActive = shouldShowResultsSkeleton(append); |
| DOM.resultsLoading.style.display = STATE.resultsSkeletonActive ? 'none' : 'flex'; |
| if (!append) { |
| DOM.emptyState.style.display = 'none'; |
| DOM.didYouMean.style.display = 'none'; |
| if (STATE.resultsSkeletonActive) { |
| DOM.resultsContainer.scrollTop = 0; |
| renderResultsSkeleton(); |
| } else { |
| clearResultsSkeleton(); |
| } |
| } |
|
|
| try { |
| const data = await API.search(requestBody); |
| |
| if (currentRequestId !== searchRequestId) return; |
|
|
| STATE.total = data.total; |
| STATE.didYouMean = data.did_you_mean || null; |
| const newItems = data.results; |
|
|
| if (append) { |
| |
| STATE._pageCache[data.page] = newItems; |
| let nextPage = STATE._loadedPage + 1; |
| let combinedNewItems = []; |
|
|
| while (STATE._pageCache[nextPage]) { |
| const pageItems = STATE._pageCache[nextPage]; |
| STATE.results = STATE.results.concat(pageItems); |
| combinedNewItems = combinedNewItems.concat(pageItems); |
| delete STATE._pageCache[nextPage]; |
| nextPage++; |
| } |
| STATE._loadedPage = nextPage - 1; |
| renderResults(combinedNewItems); |
| } else { |
| |
| STATE.results = newItems; |
| STATE._loadedPage = 1; |
| STATE._pageCache = {}; |
| renderResults(null); |
| } |
|
|
| STATE.hasMore = STATE.results.length < STATE.total; |
| updateStatusBar(); |
| updateLoadInfo(); |
|
|
| if (STATE.didYouMean) { |
| DOM.didYouMean.textContent = `你是不是想找: ${STATE.didYouMean}`; |
| DOM.didYouMean.style.display = 'inline'; |
| } |
|
|
| syncStateToURL(true); |
| prefetchNextPage(); |
|
|
| } catch (err) { |
| if (err.name === 'AbortError') return; |
| console.error('搜索失败:', err); |
| showToast('搜索失败'); |
| } finally { |
| if (currentRequestId === searchRequestId) { |
| STATE.isLoading = false; |
| STATE.resultsSkeletonActive = false; |
| DOM.resultsLoading.style.display = 'none'; |
| } |
| } |
| } |
|
|
| |
| |
| |
|
|
| async function renderFilters() { |
| |
| if (STATE.mode === "global") { |
| DOM.filterRepoSection.style.display = ""; |
| if (!STATE.repoList || STATE.repoList.length === 0) try { STATE.repoList = await API.getRepos(); } catch (e) { } |
| renderCheckboxList(DOM.filterRepoList, (STATE.repoList || []).map(r => ({ key: r.name, label: r.name.split("/").pop(), count: r.count })), STATE.filterRepos, (vals) => { STATE.filterRepos = vals; STATE.page = 1; STATE.results = []; doSearch(); }); |
| } else { |
| DOM.filterRepoSection.style.display = "none"; |
| } |
| |
| if (STATE.mode === "repo") { |
| DOM.filterFolderSection.style.display = ""; |
| if (!STATE.folderTree) try { |
| STATE.folderTree = normalizeFolderTreeRoots(await API.getFolders(STATE.repo)); |
| STATE.folderTreeCollapsed = {}; |
| STATE.folderNodeMap = buildFolderNodeMap(STATE.folderTree); |
| initializeFolderTreeCollapsed(STATE.folderTree); |
| } catch (e) { } |
| renderFilterFolderTree(); |
| } else { |
| DOM.filterFolderSection.style.display = "none"; |
| } |
| |
| try { |
| STATE.extensionList = await API.getExtensions(STATE.repo); |
| } catch (e) { |
| STATE.extensionList = []; |
| } |
| renderExtensionFilter(); |
| } |
|
|
| function renderExtensionFilter() { |
| const allExt = STATE.extensionList || []; |
|
|
| |
| if (STATE.filterExtensions.length > 0 && allExt.length > 0) { |
| var availNames = {}; |
| for (var axe = 0; axe < allExt.length; axe++) { availNames[allExt[axe].name] = true; } |
| var cleaned = STATE.filterExtensions.filter(function(ext) { return availNames[ext]; }); |
| if (cleaned.length !== STATE.filterExtensions.length) { |
| STATE.filterExtensions = cleaned; |
| STATE.page = 1; |
| STATE.results = []; |
| doSearch(); |
| } |
| } |
|
|
| if (!Array.isArray(allExt) || allExt.length === 0) { |
| DOM.filterExtList.innerHTML = '<div style="font-size:12px;color:var(--on-surface-variant);opacity:0.6;padding:4px 0">暂无</div>'; |
| return; |
| } |
|
|
| const ordered = []; |
| const rest = []; |
| for (const e of allExt) { |
| if (!e || typeof e.name !== 'string') continue; |
| const idx = ORDERED_EXTENSIONS.indexOf(e.name); |
| if (idx >= 0) { |
| ordered.push({ key: e.name, label: '.' + e.name, count: e.count || 0, _idx: idx }); |
| } else { |
| rest.push(e); |
| } |
| } |
| ordered.sort((a, b) => a._idx - b._idx); |
|
|
| const items = ordered.map(e => ({ key: e.key, label: e.label, count: e.count })); |
| if (rest.length > 0) { |
| const total = rest.reduce((s, e) => s + (e.count || 0), 0); |
| items.push({ key: "__OTHER__", label: `其他 (${rest.length}种)`, count: total }); |
| } |
|
|
| renderCheckboxList(DOM.filterExtList, items, STATE.filterExtensions, (vals) => { |
| STATE.filterExtensions = vals.filter(v => v !== "__OTHER__"); |
| if (vals.includes("__OTHER__")) { |
| for (const r of rest) STATE.filterExtensions.push(r.name); |
| } |
| STATE.page = 1; |
| STATE.results = []; |
| doSearch(); |
| }); |
| } |
|
|
| function renderCheckboxList(container, items, selected, onChange) { |
| if (items.length === 0) { container.innerHTML = '<div style="font-size:12px;color:var(--on-surface-variant);opacity:0.6;padding:4px 0">暂无</div>'; container._itemsKey = ''; return; } |
| const itemsKey = items.map(i => i.key).join(',') + '|' + items.map(i => i.count||0).join(','); |
| if (container._itemsKey !== itemsKey) { |
| container.innerHTML = items.map(item => `<label class="filter-checkbox-item"><input type="checkbox" value="${escapeHTML(item.key)}" ${selected.includes(item.key) ? 'checked' : ''}><span>${escapeHTML(item.label)}</span>${item.count !== undefined ? `<span class="checkbox-count">${item.count.toLocaleString()}</span>` : ''}</label>`).join(""); |
| container._itemsKey = itemsKey; |
| container._onChange = onChange; |
| if (!container._hasDelegate) { |
| container.addEventListener("change", () => { |
| if (container._updating) return; |
| container._onChange(Array.from(container.querySelectorAll("input:checked")).map(c => c.value)); |
| }); |
| container._hasDelegate = true; |
| } |
| } else { |
| container._updating = true; |
| container.querySelectorAll("input").forEach(cb => { |
| cb.checked = selected.includes(cb.value); |
| }); |
| container._updating = false; |
| } |
| container._onChange = onChange; |
| } |
|
|
| function renderFilterFolderTree() { |
| DOM.filterFolderTree.innerHTML = ""; |
| if (!STATE.folderTree || STATE.folderTree.length === 0) { |
| DOM.filterFolderTree.innerHTML = '<div style="font-size:12px;color:var(--on-surface-variant);opacity:0.6">暂无目录</div>'; |
| return; |
| } |
| renderFilterTreeNodes(DOM.filterFolderTree, STATE.folderTree, 0); |
| } |
|
|
| function buildFolderNodeMap(nodes, map = {}) { |
| for (const node of nodes || []) { |
| if (node.path) map[node.path] = node; |
| if (node.children && node.children.length > 0) buildFolderNodeMap(node.children, map); |
| } |
| return map; |
| } |
|
|
| function collectVisibleDescendantPaths(node, into = []) { |
| for (const child of node.children || []) { |
| if (!child.path) continue; |
| into.push(child.path); |
| if (child.children && child.children.length > 0 && !STATE.folderTreeCollapsed[child.path]) { |
| collectVisibleDescendantPaths(child, into); |
| } |
| } |
| return into; |
| } |
|
|
| function toggleFolderChildrenAnimated(childContainer, toggle, expanding) { |
| if (!childContainer || !toggle) return; |
| childContainer.getAnimations().forEach((animation) => animation.cancel()); |
| toggle.getAnimations().forEach((animation) => animation.cancel()); |
| const glyph = toggle.querySelector(".tree-toggle-glyph"); |
| if (glyph) glyph.getAnimations().forEach((animation) => animation.cancel()); |
|
|
| const resetChildStyles = () => { |
| childContainer.style.height = ""; |
| childContainer.style.opacity = ""; |
| childContainer.style.transform = ""; |
| childContainer.style.overflow = ""; |
| childContainer.style.transition = ""; |
| }; |
|
|
| const stopTransition = () => { |
| if (childContainer._transitionCleanup) { |
| childContainer.removeEventListener("transitionend", childContainer._transitionCleanup); |
| childContainer._transitionCleanup = null; |
| } |
| if (childContainer._transitionTimer) { |
| clearTimeout(childContainer._transitionTimer); |
| childContainer._transitionTimer = null; |
| } |
| }; |
|
|
| stopTransition(); |
| resetChildStyles(); |
|
|
| if (expanding) { |
| childContainer.style.display = "block"; |
| const targetHeight = childContainer.scrollHeight; |
| childContainer.style.height = "0px"; |
| childContainer.style.opacity = "0"; |
| childContainer.style.transform = "translateY(-6px)"; |
| childContainer.style.overflow = "hidden"; |
| void childContainer.offsetHeight; |
| childContainer.style.transition = "height 220ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms cubic-bezier(0.22, 1, 0.36, 1), transform 220ms cubic-bezier(0.22, 1, 0.36, 1)"; |
| childContainer.style.height = `${targetHeight}px`; |
| childContainer.style.opacity = "1"; |
| childContainer.style.transform = "translateY(0)"; |
| childContainer._transitionCleanup = (event) => { |
| if (event.target !== childContainer || event.propertyName !== "height") return; |
| stopTransition(); |
| resetChildStyles(); |
| childContainer.style.display = "block"; |
| }; |
| childContainer.addEventListener("transitionend", childContainer._transitionCleanup); |
| childContainer._transitionTimer = setTimeout(() => { |
| if (childContainer._transitionCleanup) childContainer._transitionCleanup({ target: childContainer, propertyName: "height" }); |
| }, 260); |
| toggle.classList.add("expanded"); |
| if (glyph) { |
| glyph.animate([ |
| { transform: "rotate(-45deg)" }, |
| { transform: "rotate(45deg)" }, |
| ], { |
| duration: 220, |
| easing: "cubic-bezier(0.22, 1, 0.36, 1)", |
| fill: "forwards", |
| }); |
| } |
| return; |
| } |
|
|
| childContainer.style.display = "block"; |
| const startHeight = childContainer.scrollHeight; |
| childContainer.style.height = `${startHeight}px`; |
| childContainer.style.opacity = "1"; |
| childContainer.style.transform = "translateY(0)"; |
| childContainer.style.overflow = "hidden"; |
| void childContainer.offsetHeight; |
| childContainer.style.transition = "height 190ms cubic-bezier(0.22, 1, 0.36, 1), opacity 190ms cubic-bezier(0.22, 1, 0.36, 1), transform 190ms cubic-bezier(0.22, 1, 0.36, 1)"; |
| childContainer.style.height = "0px"; |
| childContainer.style.opacity = "0"; |
| childContainer.style.transform = "translateY(-6px)"; |
| childContainer._transitionCleanup = (event) => { |
| if (event.target !== childContainer || event.propertyName !== "height") return; |
| stopTransition(); |
| childContainer.style.display = "none"; |
| resetChildStyles(); |
| }; |
| childContainer.addEventListener("transitionend", childContainer._transitionCleanup); |
| childContainer._transitionTimer = setTimeout(() => { |
| if (childContainer._transitionCleanup) childContainer._transitionCleanup({ target: childContainer, propertyName: "height" }); |
| }, 230); |
| toggle.classList.remove("expanded"); |
| if (glyph) { |
| glyph.animate([ |
| { transform: "rotate(45deg)" }, |
| { transform: "rotate(-45deg)" }, |
| ], { |
| duration: 190, |
| easing: "cubic-bezier(0.22, 1, 0.36, 1)", |
| fill: "forwards", |
| }); |
| } |
| } |
|
|
| function normalizeFolderTreeRoots(nodes) { |
| const roots = Array.isArray(nodes) ? nodes : []; |
| if (roots.length === 1) { |
| const only = roots[0]; |
| const hasChildren = Array.isArray(only.children) && only.children.length > 0; |
| if ((only.isRoot || !only.path || only.path === STATE.repo || only.name === STATE.repo) && hasChildren) { |
| return only.children; |
| } |
| } |
| return roots; |
| } |
|
|
| function initializeFolderTreeCollapsed(nodes, depth = 0) { |
| for (const node of nodes || []) { |
| if (node.path && !(node.path in STATE.folderTreeCollapsed)) STATE.folderTreeCollapsed[node.path] = false; |
| if (node.children && node.children.length > 0) initializeFolderTreeCollapsed(node.children, depth + 1); |
| } |
| } |
|
|
| function getFolderSubtreeSet() { |
| return new Set(STATE.filterFolderSubtrees || []); |
| } |
|
|
| function getFolderSelfSet() { |
| return new Set(STATE.filterFolderSelfs || []); |
| } |
|
|
| function isNodeFullySelected(node, subtreeSet, selfSet) { |
| if (!node) return false; |
| if (node.isRoot) { |
| const childNodes = node.children || []; |
| if (childNodes.length === 0) return false; |
| for (let i = 0; i < childNodes.length; i++) { |
| if (!isNodeFullySelected(childNodes[i], subtreeSet, selfSet)) return false; |
| } |
| return true; |
| } |
| if (node.showSelfToggle && !selfSet.has(node.path)) return false; |
| if (!node.hasChildren) { |
| if (node.hasDirectFiles) return selfSet.has(node.path) || subtreeSet.has(node.path); |
| return subtreeSet.has(node.path); |
| } |
| const childNodes = node.children || []; |
| for (let i = 0; i < childNodes.length; i++) { |
| if (!isNodeFullySelected(childNodes[i], subtreeSet, selfSet)) return false; |
| } |
| return !node.hasDirectFiles || selfSet.has(node.path); |
| } |
|
|
| function isNodePartiallySelected(node, subtreeSet, selfSet) { |
| if (!node) return false; |
| if (isNodeFullySelected(node, subtreeSet, selfSet)) return false; |
| if (selfSet.has(node.path) || subtreeSet.has(node.path)) return true; |
| const childNodes = node.children || []; |
| for (let i = 0; i < childNodes.length; i++) { |
| if (isNodeFullySelected(childNodes[i], subtreeSet, selfSet) || isNodePartiallySelected(childNodes[i], subtreeSet, selfSet)) return true; |
| } |
| return false; |
| } |
|
|
| function setNodeSubtreeSelection(node, enabled, subtreeSet, selfSet) { |
| if (!node) return; |
| if (!node.isRoot && enabled) subtreeSet.add(node.path); |
| if (node.isRoot && !enabled) { |
| subtreeSet.clear(); |
| selfSet.clear(); |
| } |
| if (enabled) { |
| if (node.hasDirectFiles && !node.isRoot) selfSet.add(node.path); |
| const childNodes = node.children || []; |
| for (let i = 0; i < childNodes.length; i++) { |
| setNodeSubtreeSelection(childNodes[i], true, subtreeSet, selfSet); |
| } |
| return; |
| } |
| subtreeSet.delete(node.path); |
| if (node.hasDirectFiles) selfSet.delete(node.path); |
| const childNodes = node.children || []; |
| for (let i = 0; i < childNodes.length; i++) { |
| setNodeSubtreeSelection(childNodes[i], false, subtreeSet, selfSet); |
| } |
| } |
|
|
| function persistFolderSelection(subtreeSet, selfSet) { |
| STATE.filterFolderSubtrees = Array.from(subtreeSet); |
| STATE.filterFolderSelfs = Array.from(selfSet); |
|
|
| const merged = []; |
| selfSet.forEach(path => { if (path) merged.push(path); }); |
| subtreeSet.forEach(path => { if (path && !merged.includes(path)) merged.push(path); }); |
| STATE.filterFolders = merged; |
|
|
| STATE.page = 1; |
| STATE.results = []; |
| doSearch(); |
| } |
|
|
| function collectFolderNodePaths(nodes, subtreePaths, selfPaths) { |
| for (let i = 0; i < nodes.length; i++) { |
| const node = nodes[i]; |
| if (!node.isRoot && node.path) subtreePaths.push(node.path); |
| if (!node.isRoot && node.hasDirectFiles) selfPaths.push(node.path); |
| if (node.children && node.children.length > 0) { |
| collectFolderNodePaths(node.children, subtreePaths, selfPaths); |
| } |
| } |
| } |
|
|
| function applyFolderSelectionToNode(node, row, subtreeSet, selfSet) { |
| const cb = row.querySelector("input[type='checkbox']"); |
| if (!cb) return; |
| const full = isNodeFullySelected(node, subtreeSet, selfSet); |
| const partial = isNodePartiallySelected(node, subtreeSet, selfSet); |
| cb.checked = full; |
| cb.indeterminate = !full && partial; |
|
|
| const selfBtn = row.querySelector(".folder-self-toggle"); |
| if (selfBtn) { |
| const selfOn = selfSet.has(node.path); |
| selfBtn.classList.toggle("active", selfOn); |
| selfBtn.setAttribute("aria-pressed", selfOn ? "true" : "false"); |
| } |
| } |
|
|
| function renderFilterTreeNodes(container, nodes, depth) { |
| const subtreeSet = getFolderSubtreeSet(); |
| const selfSet = getFolderSelfSet(); |
| for (const node of nodes) { |
| const has = node.children && node.children.length > 0; |
| const row = document.createElement("div"); |
| row.className = "filter-folder-item"; |
| row.style.setProperty("--fdepth", depth); |
| row.dataset.path = node.path; |
| const collapsed = !!STATE.folderTreeCollapsed[node.path]; |
| row.innerHTML = `${has ? `<button type="button" class="tree-toggle ${collapsed ? '' : 'expanded'}" aria-label="${collapsed ? '展开子文件夹' : '收起子文件夹'}" title="${collapsed ? '展开' : '收起'}"><span class="tree-toggle-glyph" aria-hidden="true"></span></button>` : '<span class="tree-toggle-placeholder"></span>'}<input type="checkbox" value="${escapeHTML(node.path)}"><span class="folder-name" title="${escapeHTML(node.name)}">${escapeHTML(node.name)}</span>${node.showSelfToggle ? `<button type="button" class="folder-self-toggle" data-path="${escapeHTML(node.path)}">本层文件</button>` : ''}<span class="folder-count">${(node.count || 0).toLocaleString()}</span>`; |
| const toggle = row.querySelector(".tree-toggle"); |
| const cb = row.querySelector("input"); |
| const selfBtn = row.querySelector(".folder-self-toggle"); |
| applyFolderSelectionToNode(node, row, subtreeSet, selfSet); |
| cb.addEventListener("click", function(e) { |
| e.preventDefault(); |
| handleFolderCheckboxChange(node); |
| }); |
| if (selfBtn) { |
| selfBtn.addEventListener("click", function(e) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| handleFolderSelfToggle(node); |
| }); |
| } |
| if (has) { |
| const childDiv = document.createElement("div"); |
| childDiv.className = "tree-children"; |
| if (collapsed) childDiv.style.display = "none"; |
| renderFilterTreeNodes(childDiv, node.children, depth + 1); |
| toggle.addEventListener("click", (e) => { |
| e.stopPropagation(); |
| const expanding = !!STATE.folderTreeCollapsed[node.path]; |
| STATE.folderTreeCollapsed[node.path] = !expanding; |
| toggleFolderChildrenAnimated(childDiv, toggle, expanding); |
| }); |
| container.appendChild(row); |
| container.appendChild(childDiv); |
| } else { |
| container.appendChild(row); |
| } |
| } |
| } |
|
|
| function handleFolderCheckboxChange(node) { |
| const subtreeSet = getFolderSubtreeSet(); |
| const selfSet = getFolderSelfSet(); |
| const full = isNodeFullySelected(node, subtreeSet, selfSet); |
| setNodeSubtreeSelection(node, !full, subtreeSet, selfSet); |
| persistFolderSelection(subtreeSet, selfSet); |
| renderFilterFolderTree(); |
| } |
|
|
| function handleFolderSelfToggle(node) { |
| const subtreeSet = getFolderSubtreeSet(); |
| const selfSet = getFolderSelfSet(); |
| if (selfSet.has(node.path)) selfSet.delete(node.path); |
| else selfSet.add(node.path); |
| persistFolderSelection(subtreeSet, selfSet); |
| renderFilterFolderTree(); |
| } |
|
|
| |
| |
| |
|
|
| async function fetchHitokoto() { |
| try { |
| const resp = await fetch("https://vomebook-hitokoto.hf.space/"); |
| const data = await resp.json(); |
| const text = data.hitokoto || data.text || data.content || data.sentence || ""; |
| if (text) typewriter(DOM.hitokoto, text); |
| } catch (e) { DOM.hitokoto.textContent = ""; } |
| } |
|
|
| function typewriter(el, text, speed = 60) { |
| el.style.opacity = "0"; |
| el.textContent = ""; |
| setTimeout(() => { el.style.transition = "opacity 0.5s ease"; el.style.opacity = "0.55"; }, 100); |
| let i = 0; |
| const t = setInterval(() => { |
| el.textContent = text.slice(0, i + 1); |
| if (++i >= text.length) clearInterval(t); |
| }, speed); |
| } |
|
|
| |
| |
| |
|
|
| async function randomBook() { |
| try { |
| const rec = await API.getRandom(STATE.repo); |
| const link = rec?.Link || rec?.link || ""; |
| if (link) { |
| const filename = (rec.File || "file") + (rec.Extension ? '.' + rec.Extension : ''); |
| window.open(buildDownloadUrl(filename, link), "_blank"); |
| } else { |
| showToast("暂无可用记录"); |
| } |
| } catch (e) { |
| console.error(e); |
| showToast("获取失败"); |
| } |
| } |
|
|
| |
| |
| |
|
|
| let toastTimer; |
| function showToast(msg, dur = 2000) { |
| DOM.toast.textContent = msg; |
| DOM.toast.style.display = ""; |
| DOM.toast.style.animation = "none"; |
| void DOM.toast.offsetWidth; |
| DOM.toast.style.animation = "toast-in 0.2s ease"; |
| clearTimeout(toastTimer); |
| toastTimer = setTimeout(() => { DOM.toast.style.display = "none"; }, dur); |
| } |
|
|
| |
| |
| |
|
|
| scrollTicking = false; |
| function setupInfiniteScroll() { |
| DOM.resultsContainer.addEventListener("scroll", () => { |
| if (!scrollTicking) { |
| requestAnimationFrame(() => { |
| updateScrollThumb(); |
| if (!STATE.isLoading && STATE.hasMore) { |
| const { scrollTop, scrollHeight, clientHeight } = DOM.resultsContainer; |
| const loadedHeight = DOM.resultsList.scrollHeight; |
| const triggerPoint = loadedHeight * 0.05; |
| if (scrollTop >= triggerPoint) { |
| STATE.page++; |
| doSearch(true); |
| } |
| } |
| scrollTicking = false; |
| }); |
| scrollTicking = true; |
| } |
| }); |
| } |
|
|
| function updateScrollThumb() { |
| const { scrollTop, scrollHeight, clientHeight } = DOM.resultsContainer; |
| if (scrollHeight <= clientHeight) { DOM.scrollTrack.classList.remove("visible"); return; } |
| DOM.scrollTrack.classList.add("visible"); |
| const th = Math.max(30, (clientHeight / scrollHeight) * DOM.scrollTrack.clientHeight); |
| const tt = (scrollTop / (scrollHeight - clientHeight)) * (DOM.scrollTrack.clientHeight - th); |
| DOM.scrollThumb.style.height = th + "px"; |
| DOM.scrollThumb.style.top = tt + "px"; |
| } |
|
|
| function setupQuickScroll() { |
| let dragging = false, startY, startST; |
| DOM.scrollThumb.addEventListener("mousedown", (e) => { |
| dragging = true; startY = e.clientY; startST = DOM.resultsContainer.scrollTop; e.preventDefault(); |
| }); |
| document.addEventListener("mousemove", (e) => { |
| if (!dragging) return; |
| const delta = e.clientY - startY; |
| const ratio = delta / (DOM.scrollTrack.clientHeight - DOM.scrollThumb.clientHeight); |
| DOM.resultsContainer.scrollTop = startST + ratio * (DOM.resultsContainer.scrollHeight - DOM.resultsContainer.clientHeight); |
| }); |
| document.addEventListener("mouseup", () => { dragging = false; }); |
| DOM.scrollThumb.addEventListener("touchstart", (e) => { |
| dragging = true; startY = e.touches[0].clientY; startST = DOM.resultsContainer.scrollTop; |
| }); |
| document.addEventListener("touchmove", (e) => { |
| if (!dragging) return; |
| const delta = e.touches[0].clientY - startY; |
| const ratio = delta / (DOM.scrollTrack.clientHeight - DOM.scrollThumb.clientHeight); |
| DOM.resultsContainer.scrollTop = startST + ratio * (DOM.resultsContainer.scrollHeight - DOM.resultsContainer.clientHeight); |
| }); |
| document.addEventListener("touchend", () => { dragging = false; }); |
| } |
|
|
| |
| |
| |
|
|
| function toggleTheme() { |
| const btn = DOM.themeBtn; |
| const rect = btn.getBoundingClientRect(); |
| const ripple = document.createElement("div"); |
| ripple.className = "theme-ripple"; |
| ripple.style.left = (rect.left + rect.width / 2) + "px"; |
| ripple.style.top = (rect.top + rect.height / 2) + "px"; |
| ripple.style.background = STATE.isDark ? "#fff" : "#1a1c1e"; |
| ripple.style.marginLeft = "-0px"; |
| ripple.style.marginTop = "-0px"; |
| document.body.appendChild(ripple); |
| document.body.classList.add("theme-transitioning"); |
|
|
| STATE.isDark = !STATE.isDark; |
| applyTheme(); |
| localStorage.setItem("theme", STATE.isDark ? "dark" : "light"); |
|
|
| ripple.addEventListener("animationend", () => { |
| ripple.remove(); |
| document.body.classList.remove("theme-transitioning"); |
| }); |
| } |
| function applyTheme() { |
| if (STATE.isDark) { document.body.classList.remove("light"); DOM.themeIconLight.style.display = "none"; DOM.themeIconDark.style.display = ""; } |
| else { document.body.classList.add("light"); DOM.themeIconLight.style.display = ""; DOM.themeIconDark.style.display = "none"; } |
| } |
| function toggleMobile() { STATE.isMobile = !STATE.isMobile; applyMobileMode(); localStorage.setItem("mobileMode", STATE.isMobile ? "mobile" : "desktop"); } |
| function applyMobileMode() { |
| if (STATE.isMobile) { |
| document.body.classList.add("mobile"); document.body.classList.remove("force-desktop"); |
| DOM.mobileIconPhone.style.display = ""; DOM.mobileIconDesktop.style.display = "none"; |
| STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; |
| } else { |
| document.body.classList.remove("mobile"); document.body.classList.add("force-desktop"); |
| DOM.mobileIconPhone.style.display = "none"; DOM.mobileIconDesktop.style.display = ""; |
| STATE.leftSidebarOpen = true; STATE.rightSidebarOpen = false; |
| } |
| updateSidebarVisibility(); |
| if (DOM.sidebarExpandBtn) DOM.sidebarExpandBtn.style.display = (STATE.mode === "repo" && !STATE.isMobile) ? "" : "none"; |
| updateSelectionUI(); |
| } |
| function autoDetectMobile() { return window.innerWidth <= 768; } |
|
|
| |
| |
| |
|
|
| function toggleLeftSidebar() { |
| STATE.leftSidebarOpen = !STATE.leftSidebarOpen; |
| if (!STATE.leftSidebarOpen) { |
| DOM.leftSidebar.classList.remove("expanded-wide"); |
| DOM.sidebarExpandBtn.textContent = "↔"; |
| } |
| syncStateToURL(true); |
| if (STATE.isMobile && STATE.leftSidebarOpen && STATE.rightSidebarOpen) STATE.rightSidebarOpen = false; |
| updateSidebarVisibility(); |
| } |
| function toggleRightSidebar() { |
| STATE.rightSidebarOpen = !STATE.rightSidebarOpen; |
| if (STATE.rightSidebarOpen && STATE.leftSidebarOpen && STATE.isMobile) STATE.leftSidebarOpen = false; |
| updateSidebarVisibility(); |
| syncStateToURL(true); |
| } |
| function updateSidebarVisibility() { |
| DOM.leftSidebar.classList.toggle("collapsed", !STATE.leftSidebarOpen); |
| DOM.leftSidebar.classList.toggle("open", STATE.leftSidebarOpen); |
| DOM.rightSidebar.classList.toggle("collapsed", !STATE.rightSidebarOpen); |
| DOM.rightSidebar.classList.toggle("open", STATE.rightSidebarOpen); |
| DOM.overlay.style.display = (STATE.isMobile && (STATE.leftSidebarOpen || STATE.rightSidebarOpen)) ? "" : "none"; |
| } |
|
|
| |
| |
| |
|
|
| let keyboardResultIndex = -1; |
| function setupKeyboard() { |
| document.addEventListener("keydown", (e) => { |
| const tag = document.activeElement.tagName; |
| const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; |
| if (e.key === "/" && !isInput) { e.preventDefault(); DOM.searchInput.focus(); DOM.searchInput.select(); return; } |
| if (e.key === "Escape") { |
| if (STATE.rightSidebarOpen || STATE.leftSidebarOpen) { STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; updateSidebarVisibility(); return; } |
| if (DOM.searchInput.value) { DOM.searchInput.value = ""; STATE.query = ""; STATE.page = 1; STATE.results = []; doSearch(); return; } |
| DOM.searchInput.blur(); return; |
| } |
| if (e.key === "b" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); toggleLeftSidebar(); return; } |
| if (e.key === "ArrowDown" || e.key === "ArrowUp") { |
| if (STATE.results.length === 0) return; |
| e.preventDefault(); |
| const items = DOM.resultsList.querySelectorAll(".result-item"); |
| if (items.length === 0) return; |
| items.forEach(it => it.style.background = ""); |
| if (e.key === "ArrowDown") keyboardResultIndex = Math.min(keyboardResultIndex + 1, items.length - 1); |
| else keyboardResultIndex = Math.max(keyboardResultIndex - 1, 0); |
| items[keyboardResultIndex].style.background = "var(--surface-variant)"; |
| items[keyboardResultIndex].scrollIntoView({ block: "nearest" }); |
| return; |
| } |
| if (e.key === "Enter") { |
| if (isInput && document.activeElement === DOM.searchInput) { |
| e.preventDefault(); STATE.query = DOM.searchInput.value.trim(); STATE.page = 1; STATE.results = []; keyboardResultIndex = -1; |
| addHistoryItem(STATE.query); |
| doSearch(); DOM.searchInput.blur(); return; |
| } |
| if (keyboardResultIndex >= 0 && keyboardResultIndex < STATE.results.length) { |
| const rec = STATE.results[keyboardResultIndex]; |
| if (rec && rec.Link) window.open(rec.Link, "_blank"); |
| return; |
| } |
| } |
| }); |
| DOM.searchInput.addEventListener("keydown", (e) => { |
| if (e.key === "Enter") { |
| e.preventDefault(); STATE.query = DOM.searchInput.value.trim(); STATE.page = 1; STATE.results = []; keyboardResultIndex = -1; |
| doSearch(); DOM.searchInput.blur(); |
| } |
| }); |
| } |
|
|
| |
| |
| |
|
|
| function clearAllFilters() { |
| STATE.filterRepos = []; STATE.filterExtensions = []; STATE.filterFolders = []; STATE.filterFolderSubtrees = []; STATE.filterFolderSelfs = []; |
| STATE.filterMinSize = null; STATE.filterMaxSize = null; |
| STATE.page = 1; STATE.results = []; |
| DOM.filterMinSize.value = ""; DOM.filterMaxSize.value = ""; |
| DOM.filterMinUnit.value = "MB"; DOM.filterMaxUnit.value = "MB"; |
| renderFilters(); doSearch(); showToast("已清空所有筛选条件"); |
| syncStateToURL(true); |
| } |
|
|
| |
| |
| |
|
|
| function setupResultDelegation() { |
| DOM.resultsList.addEventListener("click", async (e) => { |
| const actionBtn = e.target.closest("[data-action]"); |
| if (actionBtn) { |
| e.preventDefault(); |
| const action = actionBtn.dataset.action; |
| if (action === "copy") { |
| try { await navigator.clipboard.writeText(actionBtn.dataset.link); showToast("链接已复制"); } catch { showToast("复制失败"); } |
| return; |
| } |
| if (action === "read") { |
| const link = actionBtn.dataset.link; |
| const repoShort = actionBtn.dataset.repo || STATE.repo; |
| const prefix = `https://huggingface.co/datasets/VoiceOfML/${repoShort}/resolve/main/`; |
| let relPath = ""; |
| if (link.startsWith(prefix)) relPath = decodeURIComponent(link.slice(prefix.length)); |
| let stem = relPath; |
| if (stem.includes(".")) { |
| const lastDot = stem.lastIndexOf("."); |
| const slashAfterDot = stem.indexOf("/", lastDot); |
| if (slashAfterDot === -1) stem = stem.substring(0, lastDot); |
| } |
| window.open(`/txt/${encodeURI(stem)}.txt`, "_blank"); |
| return; |
| } |
| } |
| const repoTag = e.target.closest(".result-repo-tag"); |
| if (repoTag) { ROUTER.navigate("repo", repoTag.dataset.repo); return; } |
| const folderLink = e.target.closest(".path-folder"); |
| if (folderLink) { |
| const folder = folderLink.dataset.folder; |
| const repo = folderLink.dataset.repo; |
| if (repo && STATE.mode === "global") { |
| ROUTER.navigate("repo", repo); |
| } |
| if (folder !== undefined) { |
| STATE.filterFolders = folder ? [folder] : []; |
| STATE.filterFolderSubtrees = []; |
| STATE.filterFolderSelfs = folder ? [folder] : []; |
| STATE.page = 1; STATE.results = []; |
| renderFilters(); doSearch(); |
| } |
| return; |
| } |
| }); |
| DOM.sidebarContent.addEventListener("click", (e) => { |
| const repoItem = e.target.closest(".repo-list-item"); |
| if (repoItem) { ROUTER.navigate("repo", repoItem.dataset.repo); } |
| }); |
| } |
|
|
| |
| |
| |
|
|
| async function init() { |
| cacheDOM(); |
| STATE.isDark = localStorage.getItem("theme") !== "light"; |
| applyTheme(); |
| const savedMobile = localStorage.getItem("mobileMode"); |
| if (savedMobile === "mobile") STATE.isMobile = true; |
| else if (savedMobile === "desktop") STATE.isMobile = false; |
| else STATE.isMobile = autoDetectMobile(); |
| applyMobileMode(); |
|
|
| DOM.searchInput.addEventListener("input", debouncedSearch); |
|
|
| const renderDropdown = () => { |
| var list = getHistory(); |
| if (list.length === 0) { DOM.historyDropdown.style.display = "none"; return; } |
| var html = ""; |
| for (var h = 0; h < list.length; h++) { |
| html += '<div class="history-item" data-query="' + escapeHTML(list[h]) + '">' + |
| '<svg class="history-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>' + |
| '<span class="history-text">' + escapeHTML(list[h]) + '</span>' + |
| '<button class="history-del" data-del="' + escapeHTML(list[h]) + '">×</button>' + |
| '</div>'; |
| } |
| html += '<div class="history-footer"><button class="history-clear-all">清空历史</button></div>'; |
| DOM.historyDropdown.innerHTML = html; |
| DOM.historyDropdown.style.display = ""; |
| }; |
| const hideDropdown = () => { |
| setTimeout(() => { if (!dropdownActive) DOM.historyDropdown.style.display = "none"; }, 150); |
| }; |
| var dropdownActive = false; |
| DOM.searchInput.addEventListener("focus", () => { renderDropdown(); }); |
| DOM.searchInput.addEventListener("blur", hideDropdown); |
|
|
| var longPressTimer = null; |
| DOM.historyDropdown.addEventListener("mouseenter", () => { dropdownActive = true; }); |
| DOM.historyDropdown.addEventListener("mouseleave", () => { dropdownActive = false; }); |
| DOM.historyDropdown.addEventListener("mousedown", (e) => { |
| if (e.target.closest(".history-del")) return; |
| if (e.target.closest(".history-clear-all")) { saveHistory([]); DOM.historyDropdown.style.display = "none"; return; } |
| var item = e.target.closest(".history-item"); |
| if (item) longPressTimer = setTimeout(() => { removeHistoryItem(item.dataset.query); }, 600); |
| }); |
| DOM.historyDropdown.addEventListener("mouseup", () => { clearTimeout(longPressTimer); }); |
| DOM.historyDropdown.addEventListener("mouseleave", () => { clearTimeout(longPressTimer); }); |
| DOM.historyDropdown.addEventListener("touchstart", (e) => { |
| if (e.target.closest(".history-del")) return; |
| var item = e.target.closest(".history-item"); |
| if (item) longPressTimer = setTimeout(() => { removeHistoryItem(item.dataset.query); }, 600); |
| }, { passive: true }); |
| DOM.historyDropdown.addEventListener("touchend", () => { clearTimeout(longPressTimer); }); |
| DOM.historyDropdown.addEventListener("touchmove", () => { clearTimeout(longPressTimer); }); |
| DOM.historyDropdown.addEventListener("click", (e) => { |
| var delBtn = e.target.closest(".history-del"); |
| if (delBtn) { removeHistoryItem(delBtn.dataset.del); return; } |
| var item = e.target.closest(".history-item"); |
| if (item) { |
| DOM.searchInput.value = item.dataset.query; |
| STATE.query = item.dataset.query; |
| STATE.page = 1; |
| STATE.results = []; |
| doSearch(); |
| hideDropdown(); |
| DOM.searchInput.blur(); |
| return; |
| } |
| }); |
| DOM.historyToggle.addEventListener("change", () => { |
| STATE.recordHistory = DOM.historyToggle.checked; |
| if (!STATE.recordHistory) saveHistory([]); |
| }); |
| if (DOM.mirrorLinksToggle) DOM.mirrorLinksToggle.addEventListener("change", () => { |
| STATE.useMirrorLinks = DOM.mirrorLinksToggle.checked; |
| syncStateToURL(true); |
| if (STATE.results.length > 0) renderResults(); |
| }); |
|
|
| |
| if (DOM.multiSelectToggle) DOM.multiSelectToggle.addEventListener("change", updateSelectionUI); |
|
|
| DOM.resultsList.addEventListener("click", (e) => { |
| if (!DOM.multiSelectToggle || !DOM.multiSelectToggle.checked) return; |
| var cb = e.target.closest(".result-checkbox"); |
| if (!cb) return; |
| e.stopPropagation(); |
| var idx = parseInt(cb.dataset.index); |
| if (e.shiftKey && lastSelectedIndex >= 0) { |
| var lo = Math.min(lastSelectedIndex, idx); |
| var hi = Math.max(lastSelectedIndex, idx); |
| for (var si = lo; si <= hi; si++) selectedIndices[si] = true; |
| } else if (cb.checked) { |
| selectedIndices[idx] = true; |
| } else { |
| delete selectedIndices[idx]; |
| } |
| lastSelectedIndex = idx; |
| updateSelectionUI(); |
| }); |
|
|
| if (DOM.multiCopyLinks) DOM.multiCopyLinks.addEventListener("click", () => { |
| var indices = Object.keys(selectedIndices).map(Number); |
| var links = []; |
| for (var li = 0; li < indices.length; li++) { |
| var rec = STATE.results[indices[li]]; |
| if (rec && rec.Link) links.push(getCopyableLink(rec.Link)); |
| } |
| if (links.length === 0) { showToast("未选中任何文件"); return; } |
| navigator.clipboard.writeText(links.join("\n")).then(() => showToast("已复制 " + links.length + " 条链接")).catch(() => showToast("复制失败")); |
| }); |
|
|
| if (DOM.multiBatchDownload) DOM.multiBatchDownload.addEventListener("click", () => { |
| var indices = Object.keys(selectedIndices).map(Number); |
| if (indices.length === 0) { showToast("未选中任何文件"); return; } |
| for (var bi = 0; bi < indices.length; bi++) { |
| var rec = STATE.results[indices[bi]]; |
| var filename = (rec.File || "file") + (rec.Extension ? "." + rec.Extension : ""); |
| var proxyUrl = buildDownloadUrl(filename, rec.Link); |
| setTimeout((u) => window.open(u, "_blank"), bi * 300, proxyUrl); |
| } |
| showToast("正在打开 " + indices.length + " 个下载"); |
| }); |
|
|
| if (DOM.multiZipDownload) DOM.multiZipDownload.addEventListener("click", () => { |
| var indices = Object.keys(selectedIndices).map(Number); |
| if (indices.length === 0) { showToast("未选中任何文件"); return; } |
| showToast("正在打包 " + indices.length + " 个文件..."); |
| var files = []; |
| for (var zi = 0; zi < indices.length; zi++) { |
| var rec = STATE.results[indices[zi]]; |
| files.push({ name: rec.File + (rec.Extension ? "." + rec.Extension : ""), link: rec.Link }); |
| } |
| console.log("[zip] requesting", files.length, "files"); |
| fetch("/api/zip", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ files: files }), |
| }).then(resp => { |
| if (!resp.ok) throw new Error("HTTP " + resp.status); |
| return resp.blob(); |
| }).then(blob => { |
| var a = document.createElement("a"); |
| a.href = URL.createObjectURL(blob); |
| a.download = "voml_batch_" + Date.now() + ".zip"; |
| a.click(); |
| URL.revokeObjectURL(a.href); |
| }).catch(e => { console.error(e); showToast("合并下载失败"); }); |
| }); |
|
|
| if (DOM.multiDeselect) DOM.multiDeselect.addEventListener("click", () => { |
| selectedIndices = {}; |
| lastSelectedIndex = -1; |
| updateSelectionUI(); |
| }); |
|
|
| if (DOM.multiSelectAll) DOM.multiSelectAll.addEventListener("click", () => { |
| for (var si = 0; si < STATE.results.length; si++) selectedIndices[si] = true; |
| lastSelectedIndex = STATE.results.length > 0 ? STATE.results.length - 1 : -1; |
| updateSelectionUI(); |
| }); |
|
|
| DOM.hamburgerBtn.addEventListener("click", toggleLeftSidebar); |
| DOM.settingsBtn.addEventListener("click", toggleRightSidebar); |
| DOM.closeFiltersBtn.addEventListener("click", () => { STATE.rightSidebarOpen = false; updateSidebarVisibility(); syncStateToURL(true); }); |
| |
| DOM.sidebarExpandBtn.addEventListener("click", () => { |
| DOM.leftSidebar.classList.toggle("expanded-wide"); |
| DOM.sidebarExpandBtn.textContent = DOM.leftSidebar.classList.contains("expanded-wide") ? "→" : "↔"; |
| syncStateToURL(true); |
| }); |
|
|
| DOM.themeBtn.addEventListener("click", toggleTheme); |
| DOM.mobileToggleBtn.addEventListener("click", toggleMobile); |
| DOM.clearFiltersBtn.addEventListener("click", clearAllFilters); |
| DOM.searchFoldersToggle.addEventListener("change", () => { |
| STATE.searchFolders = DOM.searchFoldersToggle.checked; |
| STATE.page = 1; |
| STATE.results = []; |
| doSearch(); |
| }); |
| DOM.exactSearchToggle.addEventListener("change", () => { |
| STATE.exact = DOM.exactSearchToggle.checked; |
| STATE.page = 1; |
| STATE.results = []; |
| doSearch(); |
| }); |
| DOM.sortSelect.addEventListener("change", () => { |
| STATE.page = 1; |
| STATE.results = []; |
| doSearch(); |
| syncStateToURL(true); |
| }); |
| DOM.overlay.addEventListener("click", () => { STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; updateSidebarVisibility(); syncStateToURL(true); }); |
| DOM.randomBookBtn.addEventListener('click', randomBook); |
| DOM.emptyRandomBtn.addEventListener('click', randomBook); |
|
|
| let sizeTimer; |
| const sizeInputToBytes = (input, unitSelect) => { |
| var val = parseFloat(input.value); |
| if (isNaN(val) || val < 0) return null; |
| var unit = unitSelect.value; |
| if (unit === "KB") val *= 1024; |
| else if (unit === "MB") val *= 1048576; |
| else if (unit === "GB") val *= 1073741824; |
| return Math.round(val); |
| }; |
| const applySizeFilter = () => { clearTimeout(sizeTimer); sizeTimer = setTimeout(() => { STATE.filterMinSize = sizeInputToBytes(DOM.filterMinSize, DOM.filterMinUnit); STATE.filterMaxSize = sizeInputToBytes(DOM.filterMaxSize, DOM.filterMaxUnit); STATE.page = 1; STATE.results = []; doSearch(); }, 500); }; |
| DOM.filterMinSize.addEventListener("input", applySizeFilter); |
| DOM.filterMaxSize.addEventListener("input", applySizeFilter); |
| DOM.filterMinUnit.addEventListener("change", applySizeFilter); |
| DOM.filterMaxUnit.addEventListener("change", applySizeFilter); |
|
|
| DOM.extSelectAll.addEventListener("click", () => { STATE.filterExtensions = (STATE.extensionList || []).map(e => e.name); STATE.page = 1; STATE.results = []; doSearch(); renderExtensionFilter(); }); |
| DOM.extDeselectAll.addEventListener("click", () => { |
| const allExtNames = (STATE.extensionList || []).map(e => e.name); |
| const currentSet = new Set(STATE.filterExtensions); |
| |
| STATE.filterExtensions = allExtNames.filter(e => !currentSet.has(e)); |
| STATE.page = 1; |
| STATE.results = []; |
| doSearch(); |
| renderExtensionFilter(); |
| }); |
| DOM.folderSelectAll.addEventListener("click", () => { |
| if (!STATE.folderTree || STATE.folderTree.length === 0) return; |
| const subtreeSet = new Set(); |
| const selfSet = new Set(); |
| for (let i = 0; i < STATE.folderTree.length; i++) { |
| setNodeSubtreeSelection(STATE.folderTree[i], true, subtreeSet, selfSet); |
| } |
| persistFolderSelection(subtreeSet, selfSet); |
| renderFilterFolderTree(); |
| }); |
| DOM.folderDeselectAll.addEventListener("click", () => { |
| if (!STATE.folderTree || STATE.folderTree.length === 0) return; |
| const subtreeSet = getFolderSubtreeSet(); |
| const selfSet = getFolderSelfSet(); |
| const allSubtreePaths = []; |
| const allSelfPaths = []; |
| collectFolderNodePaths(STATE.folderTree, allSubtreePaths, allSelfPaths); |
|
|
| const nextSubtreeSet = new Set(); |
| const nextSelfSet = new Set(); |
| for (let i = 0; i < allSubtreePaths.length; i++) { |
| if (!subtreeSet.has(allSubtreePaths[i])) nextSubtreeSet.add(allSubtreePaths[i]); |
| } |
| for (let i = 0; i < allSelfPaths.length; i++) { |
| if (!selfSet.has(allSelfPaths[i])) nextSelfSet.add(allSelfPaths[i]); |
| } |
|
|
| persistFolderSelection(nextSubtreeSet, nextSelfSet); |
| renderFilterFolderTree(); |
| }); |
|
|
| 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(); } }); |
|
|
| setupInfiniteScroll(); |
| setupQuickScroll(); |
| setupKeyboard(); |
| setupResultDelegation(); |
| |
| window.addEventListener("popstate", () => ROUTER.apply()); |
| window.addEventListener("resize", () => { if (!localStorage.getItem("mobileMode")) { const wm = STATE.isMobile; STATE.isMobile = autoDetectMobile(); if (wm !== STATE.isMobile) applyMobileMode(); } }); |
|
|
| ROUTER.apply(); |
| fetchHitokoto(); |
| setInterval(fetchHitokoto, 30000); |
| await renderSidebar(); |
| await renderFilters(); |
| doSearch(); |
| } |
|
|
| document.addEventListener("DOMContentLoaded", init); |
|
|