vomebook commited on
Commit
86f96df
·
verified ·
1 Parent(s): a5d0e96

Upload 2 files

Browse files
Files changed (2) hide show
  1. static/app.js +126 -49
  2. static/style.css +117 -7
static/app.js CHANGED
@@ -34,6 +34,13 @@ let facetRetryTimer = null;
34
  let progressTimer = null;
35
  let searchRequestSeq = 0;
36
  let searchAbortController = null;
 
 
 
 
 
 
 
37
 
38
  function escapeHTML(value) {
39
  return String(value ?? "").replace(/[&<>"']/g, (ch) => ({
@@ -101,13 +108,20 @@ function syncUrl(replace = true) {
101
  if (STATE.filterPage.tag > 1) params.set("tag_page", String(STATE.filterPage.tag));
102
  if (STATE.preview) params.set("preview", STATE.preview);
103
  const url = params.toString() ? `/?${params.toString()}` : "/";
104
- history[replace ? "replaceState" : "pushState"](null, "", url);
 
 
 
 
 
 
105
  }
106
 
107
  function applyStateToDom() {
108
  document.body.classList.toggle("light", !STATE.isDark);
109
  document.body.classList.toggle("mobile", STATE.isMobile);
110
  document.body.classList.toggle("force-desktop", !STATE.isMobile);
 
111
  if (DOM.mobileToggleIcon) DOM.mobileToggleIcon.className = STATE.isMobile ? "ui-icon ui-icon-phone" : "ui-icon ui-icon-desktop";
112
  DOM.searchInput.value = STATE.q;
113
  DOM.exactToggle.checked = STATE.exact;
@@ -130,6 +144,17 @@ function applyStateToDom() {
130
  if (body) body.classList.toggle("collapsed", collapsed);
131
  if (toggle) toggle.classList.toggle("collapsed", collapsed);
132
  }
 
 
 
 
 
 
 
 
 
 
 
133
  }
134
 
135
  function hasActiveFilters() {
@@ -163,13 +188,36 @@ function highlightPreviewText(text) {
163
  return html.replace(pattern, (match) => `<mark>${match}</mark>`);
164
  }
165
 
166
- function closePreview() {
167
- STATE.preview = "";
 
 
 
 
 
 
 
 
 
 
 
 
168
  DOM.previewPanel.hidden = true;
169
  DOM.previewPanel.style.display = "none";
170
  DOM.previewPanel.innerHTML = "";
171
  }
172
 
 
 
 
 
 
 
 
 
 
 
 
173
  async function api(path, options) {
174
  const response = await fetch(path, options);
175
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -418,36 +466,49 @@ function scheduleSearch() {
418
 
419
  async function restorePreview(scroll = true) {
420
  if (!STATE.preview) {
421
- DOM.previewPanel.hidden = true;
422
- DOM.previewPanel.style.display = "none";
423
- DOM.previewPanel.innerHTML = "";
424
- syncUrl();
425
  return;
426
  }
 
 
 
 
 
427
  try {
428
- const item = await api(`/api/preview/${encodeURIComponent(STATE.preview)}`);
 
429
  DOM.previewPanel.innerHTML = previewHTML(item);
430
  DOM.previewPanel.hidden = false;
431
  DOM.previewPanel.style.display = "flex";
 
 
432
  DOM.emptyState.style.display = "none";
433
  if (scroll) DOM.previewPanel.querySelector(".preview-body")?.scrollTo({ top: 0, behavior: "smooth" });
434
- syncUrl();
435
  } catch (error) {
436
- STATE.preview = "";
437
- DOM.previewPanel.hidden = true;
438
- DOM.previewPanel.style.display = "none";
439
  showToast("预览失败");
440
- syncUrl();
441
  }
442
  }
443
 
 
 
 
 
 
 
 
 
 
444
  function previewHTML(item, compareItem = null) {
445
- const actions = [previewDownloadHTML(item), compareSelectHTML(item), '<button class="icon-btn-sm" data-close-preview>×</button>'].join("");
 
446
  if (compareItem) {
447
  const diff = diffHTML(item.content || "", compareItem.content || "");
448
- return `<div class="preview-header"><div>${previewTitleHTML(item)}<div class="preview-compare-note">正在对比:${escapeHTML(item.publication_name)} / ${escapeHTML(compareItem.publication_name)}</div></div><div class="preview-actions">${actions}</div></div><div class="preview-compare-grid"><div>${previewMetaHTML(item)}<pre class="preview-body compare-body">${diff.left}</pre></div><div>${previewMetaHTML(compareItem)}<pre class="preview-body compare-body">${diff.right}</pre></div></div>`;
449
  }
450
- return `<div class="preview-header"><div>${previewTitleHTML(item)}${previewMetaHTML(item)}</div><div class="preview-actions">${actions}</div></div><pre class="preview-body">${highlightPreviewText(item.content || "")}</pre>`;
451
  }
452
 
453
  function previewDownloadHTML(item) {
@@ -461,16 +522,17 @@ function previewDownloadHTML(item) {
461
  }
462
 
463
  async function openRandomPreview() {
 
 
 
 
464
  try {
465
- const item = await api("/api/random");
466
- STATE.preview = item.doc_id;
467
- DOM.previewPanel.innerHTML = previewHTML(item);
468
- DOM.previewPanel.hidden = false;
469
- DOM.previewPanel.style.display = "flex";
470
- DOM.emptyState.style.display = "none";
471
- DOM.previewPanel.querySelector(".preview-body")?.scrollTo({ top: 0, behavior: "smooth" });
472
- syncUrl(false);
473
  } catch (error) {
 
474
  showToast("随机文章加载失败");
475
  }
476
  }
@@ -558,11 +620,21 @@ function compareSelectHTML(item) {
558
 
559
  async function comparePreview(docId) {
560
  if (!STATE.preview || !docId) return;
 
 
 
 
 
561
  try {
562
- const current = await api(`/api/preview/${encodeURIComponent(STATE.preview)}`);
563
- const target = await api(`/api/preview/${encodeURIComponent(docId)}`);
 
 
 
 
564
  DOM.previewPanel.innerHTML = previewHTML(current, target);
565
  } catch (error) {
 
566
  showToast("对比加载失败");
567
  }
568
  }
@@ -577,7 +649,6 @@ function resetFilters() {
577
  STATE.yearTo = "";
578
  STATE.exact = true;
579
  STATE.page = 1;
580
- closePreview();
581
  doSearch();
582
  }
583
 
@@ -599,11 +670,7 @@ function attachEvents() {
599
  applyStateToDom();
600
  syncUrl();
601
  });
602
- DOM.themeBtn.addEventListener("click", () => {
603
- STATE.isDark = !STATE.isDark;
604
- localStorage.setItem("theme", STATE.isDark ? "dark" : "light");
605
- applyStateToDom();
606
- });
607
  DOM.mobileToggleBtn.addEventListener("click", () => {
608
  STATE.isMobile = !STATE.isMobile;
609
  localStorage.setItem("mobileMode", STATE.isMobile ? "mobile" : "desktop");
@@ -622,7 +689,6 @@ function attachEvents() {
622
  DOM.exactToggle.addEventListener("change", () => {
623
  STATE.exact = DOM.exactToggle.checked;
624
  STATE.page = 1;
625
- closePreview();
626
  doSearch();
627
  });
628
  DOM.previewHighlightToggle.addEventListener("change", () => {
@@ -633,16 +699,15 @@ function attachEvents() {
633
  DOM.pageSize.addEventListener("change", () => {
634
  STATE.pageSize = parseInt(DOM.pageSize.value, 10);
635
  STATE.page = 1;
636
- closePreview();
637
  doSearch();
638
  });
639
- DOM.sortFilter.addEventListener("change", () => { STATE.sort = DOM.sortFilter.value; STATE.page = 1; closePreview(); doSearch(); });
640
  DOM.sourceSearch.addEventListener("input", () => { STATE.filterSearch.source = DOM.sourceSearch.value; STATE.filterPage.source = 1; loadFacetPage("source"); });
641
  DOM.authorSearch.addEventListener("input", () => { STATE.filterSearch.author = DOM.authorSearch.value; STATE.filterPage.author = 1; loadFacetPage("author"); });
642
  DOM.tagSearch.addEventListener("input", () => { STATE.filterSearch.tag = DOM.tagSearch.value; STATE.filterPage.tag = 1; loadFacetPage("tag"); });
643
- DOM.sourceFilter.addEventListener("click", (event) => { const pageButton = event.target.closest("[data-source-page]"); if (pageButton) { if (!pageButton.disabled) STATE.filterPage.source += pageButton.dataset.sourcePage === "next" ? 1 : -1; loadFacetPage("source"); syncUrl(); return; } if (event.target.closest("[data-clear-source]")) { STATE.sources = []; STATE.page = 1; closePreview(); renderFilterOptions(); doSearch(); return; } const item = event.target.closest("[data-source]"); if (!item || event.target.closest("input")) return; STATE.sources = toggleValue(STATE.sources, item.dataset.source); STATE.page = 1; closePreview(); renderFilterOptions(); doSearch(); });
644
- DOM.authorFilter.addEventListener("click", (event) => { const pageButton = event.target.closest("[data-author-page]"); if (pageButton) { if (!pageButton.disabled) STATE.filterPage.author += pageButton.dataset.authorPage === "next" ? 1 : -1; loadFacetPage("author"); syncUrl(); return; } if (event.target.closest("[data-clear-author]")) { STATE.authors = []; STATE.page = 1; closePreview(); renderFilterOptions(); doSearch(); return; } const item = event.target.closest("[data-author]"); if (!item || event.target.closest("input")) return; STATE.authors = toggleValue(STATE.authors, item.dataset.author); STATE.page = 1; closePreview(); renderFilterOptions(); doSearch(); });
645
- DOM.tagFilter.addEventListener("click", (event) => { const pageButton = event.target.closest("[data-tag-page]"); if (pageButton) { if (!pageButton.disabled) STATE.filterPage.tag += pageButton.dataset.tagPage === "next" ? 1 : -1; loadFacetPage("tag"); syncUrl(); return; } if (event.target.closest("[data-clear-tag]")) { STATE.tags = []; STATE.page = 1; closePreview(); renderFilterOptions(); doSearch(); return; } const item = event.target.closest("[data-tag]"); if (!item || event.target.closest("input")) return; STATE.tags = toggleValue(STATE.tags, item.dataset.tag); STATE.page = 1; closePreview(); renderFilterOptions(); doSearch(); });
646
  for (const [kind, element] of Object.entries({ source: DOM.sourceFilter, author: DOM.authorFilter, tag: DOM.tagFilter })) {
647
  element.addEventListener("keydown", (event) => {
648
  const input = event.target.closest(`[data-${kind}-page-input]`);
@@ -656,10 +721,10 @@ function attachEvents() {
656
  if (input) commitFilterPage(kind, input);
657
  });
658
  }
659
- DOM.archiveFilter.addEventListener("change", () => { STATE.archiveId = DOM.archiveFilter.value; STATE.page = 1; closePreview(); doSearch(); });
660
- DOM.typeFilter.addEventListener("change", () => { STATE.publicationType = DOM.typeFilter.value; STATE.page = 1; closePreview(); doSearch(); });
661
- DOM.yearFrom.addEventListener("change", () => { STATE.yearFrom = DOM.yearFrom.value; STATE.page = 1; closePreview(); doSearch(); });
662
- DOM.yearTo.addEventListener("change", () => { STATE.yearTo = DOM.yearTo.value; STATE.page = 1; closePreview(); doSearch(); });
663
  DOM.clearFilters.addEventListener("click", resetFilters);
664
  DOM.randomBookBtn.addEventListener("click", openRandomPreview);
665
  DOM.emptyRandomBtn.addEventListener("click", openRandomPreview);
@@ -673,15 +738,13 @@ function attachEvents() {
673
  DOM.results.addEventListener("click", (event) => {
674
  const preview = event.target.closest("[data-preview]");
675
  if (preview) {
676
- STATE.preview = preview.dataset.preview;
677
- restorePreview();
678
  return;
679
  }
680
  const source = event.target.closest(".path-folder[data-source]");
681
  if (source) {
682
  STATE.sources = toggleValue(STATE.sources, source.dataset.source);
683
  STATE.page = 1;
684
- closePreview();
685
  doSearch();
686
  return;
687
  }
@@ -689,7 +752,6 @@ function attachEvents() {
689
  if (author) {
690
  STATE.authors = toggleValue(STATE.authors, author.dataset.author);
691
  STATE.page = 1;
692
- closePreview();
693
  doSearch();
694
  return;
695
  }
@@ -697,7 +759,6 @@ function attachEvents() {
697
  if (tag) {
698
  STATE.tags = toggleValue(STATE.tags, tag.dataset.tag);
699
  STATE.page = 1;
700
- closePreview();
701
  doSearch();
702
  }
703
  });
@@ -708,9 +769,10 @@ function attachEvents() {
708
  return;
709
  }
710
  if (event.target.closest("[data-close-preview]")) {
711
- STATE.preview = "";
712
- restorePreview(false);
713
  }
 
714
  });
715
  DOM.previewPanel.addEventListener("change", (event) => {
716
  const select = event.target.closest("[data-compare-select]");
@@ -737,9 +799,16 @@ function attachEvents() {
737
  });
738
  DOM.pageInput.addEventListener("change", commitResultPage);
739
  window.addEventListener("popstate", () => {
 
 
740
  parseUrl();
 
 
 
 
741
  applyStateToDom();
742
  doSearch();
 
743
  });
744
  document.addEventListener("keydown", (event) => {
745
  if (event.key === "/" && document.activeElement !== DOM.searchInput) {
@@ -799,10 +868,18 @@ function init() {
799
  const savedMobileMode = localStorage.getItem("mobileMode");
800
  STATE.isMobile = savedMobileMode === "mobile" || (!savedMobileMode && window.innerWidth <= 768);
801
  parseUrl();
 
 
 
 
 
 
 
802
  applyStateToDom();
803
  attachEvents();
804
  loadFacets();
805
  doSearch();
 
806
  updateProgress();
807
  }
808
 
 
34
  let progressTimer = null;
35
  let searchRequestSeq = 0;
36
  let searchAbortController = null;
37
+ let previewRequestSeq = 0;
38
+ let previewAbortController = null;
39
+ let compareRequestSeq = 0;
40
+ let compareAbortController = null;
41
+ let randomRequestSeq = 0;
42
+ let randomAbortController = null;
43
+ let previewCloseUrl = "/";
44
 
45
  function escapeHTML(value) {
46
  return String(value ?? "").replace(/[&<>"']/g, (ch) => ({
 
108
  if (STATE.filterPage.tag > 1) params.set("tag_page", String(STATE.filterPage.tag));
109
  if (STATE.preview) params.set("preview", STATE.preview);
110
  const url = params.toString() ? `/?${params.toString()}` : "/";
111
+ if (STATE.preview) {
112
+ const closeParams = new URLSearchParams(params);
113
+ closeParams.delete("preview");
114
+ previewCloseUrl = closeParams.toString() ? `/?${closeParams.toString()}` : "/";
115
+ }
116
+ const previewEntry = Boolean(STATE.preview && (!replace || history.state?.previewEntry === true));
117
+ history[replace ? "replaceState" : "pushState"]({ previewEntry }, "", url);
118
  }
119
 
120
  function applyStateToDom() {
121
  document.body.classList.toggle("light", !STATE.isDark);
122
  document.body.classList.toggle("mobile", STATE.isMobile);
123
  document.body.classList.toggle("force-desktop", !STATE.isMobile);
124
+ document.body.classList.toggle("preview-open", Boolean(STATE.preview));
125
  if (DOM.mobileToggleIcon) DOM.mobileToggleIcon.className = STATE.isMobile ? "ui-icon ui-icon-phone" : "ui-icon ui-icon-desktop";
126
  DOM.searchInput.value = STATE.q;
127
  DOM.exactToggle.checked = STATE.exact;
 
144
  if (body) body.classList.toggle("collapsed", collapsed);
145
  if (toggle) toggle.classList.toggle("collapsed", collapsed);
146
  }
147
+ const previewThemeButton = DOM.previewPanel?.querySelector("[data-preview-theme]");
148
+ if (previewThemeButton) {
149
+ previewThemeButton.setAttribute("aria-label", STATE.isDark ? "切换到白天模式" : "切换到黑夜模式");
150
+ previewThemeButton.title = STATE.isDark ? "白天模式" : "黑夜模式";
151
+ }
152
+ }
153
+
154
+ function toggleTheme() {
155
+ STATE.isDark = !STATE.isDark;
156
+ localStorage.setItem("theme", STATE.isDark ? "dark" : "light");
157
+ applyStateToDom();
158
  }
159
 
160
  function hasActiveFilters() {
 
188
  return html.replace(pattern, (match) => `<mark>${match}</mark>`);
189
  }
190
 
191
+ function cancelPreviewRequests() {
192
+ previewRequestSeq += 1;
193
+ compareRequestSeq += 1;
194
+ randomRequestSeq += 1;
195
+ if (previewAbortController) previewAbortController.abort();
196
+ if (compareAbortController) compareAbortController.abort();
197
+ if (randomAbortController) randomAbortController.abort();
198
+ previewAbortController = null;
199
+ compareAbortController = null;
200
+ randomAbortController = null;
201
+ }
202
+
203
+ function clearPreviewDom() {
204
+ document.body.classList.remove("preview-open");
205
  DOM.previewPanel.hidden = true;
206
  DOM.previewPanel.style.display = "none";
207
  DOM.previewPanel.innerHTML = "";
208
  }
209
 
210
+ function closePreview(useHistory = false) {
211
+ cancelPreviewRequests();
212
+ if (useHistory && history.state?.previewEntry === true) {
213
+ history.back();
214
+ return;
215
+ }
216
+ STATE.preview = "";
217
+ clearPreviewDom();
218
+ syncUrl();
219
+ }
220
+
221
  async function api(path, options) {
222
  const response = await fetch(path, options);
223
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
 
466
 
467
  async function restorePreview(scroll = true) {
468
  if (!STATE.preview) {
469
+ cancelPreviewRequests();
470
+ clearPreviewDom();
 
 
471
  return;
472
  }
473
+ const docId = STATE.preview;
474
+ const requestSeq = previewRequestSeq + 1;
475
+ previewRequestSeq = requestSeq;
476
+ if (previewAbortController) previewAbortController.abort();
477
+ previewAbortController = new AbortController();
478
  try {
479
+ const item = await api(`/api/preview/${encodeURIComponent(docId)}`, { signal: previewAbortController.signal });
480
+ if (requestSeq !== previewRequestSeq || STATE.preview !== docId) return;
481
  DOM.previewPanel.innerHTML = previewHTML(item);
482
  DOM.previewPanel.hidden = false;
483
  DOM.previewPanel.style.display = "flex";
484
+ document.body.classList.add("preview-open");
485
+ previewAbortController = null;
486
  DOM.emptyState.style.display = "none";
487
  if (scroll) DOM.previewPanel.querySelector(".preview-body")?.scrollTo({ top: 0, behavior: "smooth" });
 
488
  } catch (error) {
489
+ if (error.name === "AbortError" || requestSeq !== previewRequestSeq || STATE.preview !== docId) return;
 
 
490
  showToast("预览失败");
491
+ closePreview(true);
492
  }
493
  }
494
 
495
+ function openPreview(docId, scroll = true) {
496
+ if (!docId) return;
497
+ const replacingPreview = history.state?.previewEntry === true;
498
+ STATE.preview = docId;
499
+ applyStateToDom();
500
+ syncUrl(replacingPreview);
501
+ restorePreview(scroll);
502
+ }
503
+
504
  function previewHTML(item, compareItem = null) {
505
+ const themeAction = `<button type="button" class="icon-btn-sm preview-theme-btn" data-preview-theme aria-label="${STATE.isDark ? "切换到白天模式" : "切换到黑夜模式"}" title="${STATE.isDark ? "白天模式" : "黑夜模式"}"><span class="ui-icon ui-icon-theme" aria-hidden="true"></span></button>`;
506
+ const actions = [previewDownloadHTML(item), compareSelectHTML(item), `<div class="preview-tools"><button type="button" class="icon-btn-sm" data-close-preview aria-label="关闭预览">×</button>${themeAction}</div>`].join("");
507
  if (compareItem) {
508
  const diff = diffHTML(item.content || "", compareItem.content || "");
509
+ return `<div class="preview-header"><div class="preview-heading">${previewTitleHTML(item)}<div class="preview-compare-note">正在对比:${escapeHTML(item.publication_name)} / ${escapeHTML(compareItem.publication_name)}</div></div><div class="preview-actions">${actions}</div></div><div class="preview-compare-grid"><div>${previewMetaHTML(item)}<pre class="preview-body compare-body">${diff.left}</pre></div><div>${previewMetaHTML(compareItem)}<pre class="preview-body compare-body">${diff.right}</pre></div></div>`;
510
  }
511
+ return `<div class="preview-header"><div class="preview-heading">${previewTitleHTML(item)}${previewMetaHTML(item)}</div><div class="preview-actions">${actions}</div></div><pre class="preview-body">${highlightPreviewText(item.content || "")}</pre>`;
512
  }
513
 
514
  function previewDownloadHTML(item) {
 
522
  }
523
 
524
  async function openRandomPreview() {
525
+ const requestSeq = randomRequestSeq + 1;
526
+ randomRequestSeq = requestSeq;
527
+ if (randomAbortController) randomAbortController.abort();
528
+ randomAbortController = new AbortController();
529
  try {
530
+ const item = await api("/api/random", { signal: randomAbortController.signal });
531
+ if (requestSeq !== randomRequestSeq) return;
532
+ randomAbortController = null;
533
+ openPreview(item.doc_id);
 
 
 
 
534
  } catch (error) {
535
+ if (error.name === "AbortError" || requestSeq !== randomRequestSeq) return;
536
  showToast("随机文章加载失败");
537
  }
538
  }
 
620
 
621
  async function comparePreview(docId) {
622
  if (!STATE.preview || !docId) return;
623
+ const baseDocId = STATE.preview;
624
+ const requestSeq = compareRequestSeq + 1;
625
+ compareRequestSeq = requestSeq;
626
+ if (compareAbortController) compareAbortController.abort();
627
+ compareAbortController = new AbortController();
628
  try {
629
+ const [current, target] = await Promise.all([
630
+ api(`/api/preview/${encodeURIComponent(baseDocId)}`, { signal: compareAbortController.signal }),
631
+ api(`/api/preview/${encodeURIComponent(docId)}`, { signal: compareAbortController.signal }),
632
+ ]);
633
+ if (requestSeq !== compareRequestSeq || STATE.preview !== baseDocId) return;
634
+ compareAbortController = null;
635
  DOM.previewPanel.innerHTML = previewHTML(current, target);
636
  } catch (error) {
637
+ if (error.name === "AbortError" || requestSeq !== compareRequestSeq) return;
638
  showToast("对比加载失败");
639
  }
640
  }
 
649
  STATE.yearTo = "";
650
  STATE.exact = true;
651
  STATE.page = 1;
 
652
  doSearch();
653
  }
654
 
 
670
  applyStateToDom();
671
  syncUrl();
672
  });
673
+ DOM.themeBtn.addEventListener("click", toggleTheme);
 
 
 
 
674
  DOM.mobileToggleBtn.addEventListener("click", () => {
675
  STATE.isMobile = !STATE.isMobile;
676
  localStorage.setItem("mobileMode", STATE.isMobile ? "mobile" : "desktop");
 
689
  DOM.exactToggle.addEventListener("change", () => {
690
  STATE.exact = DOM.exactToggle.checked;
691
  STATE.page = 1;
 
692
  doSearch();
693
  });
694
  DOM.previewHighlightToggle.addEventListener("change", () => {
 
699
  DOM.pageSize.addEventListener("change", () => {
700
  STATE.pageSize = parseInt(DOM.pageSize.value, 10);
701
  STATE.page = 1;
 
702
  doSearch();
703
  });
704
+ DOM.sortFilter.addEventListener("change", () => { STATE.sort = DOM.sortFilter.value; STATE.page = 1; doSearch(); });
705
  DOM.sourceSearch.addEventListener("input", () => { STATE.filterSearch.source = DOM.sourceSearch.value; STATE.filterPage.source = 1; loadFacetPage("source"); });
706
  DOM.authorSearch.addEventListener("input", () => { STATE.filterSearch.author = DOM.authorSearch.value; STATE.filterPage.author = 1; loadFacetPage("author"); });
707
  DOM.tagSearch.addEventListener("input", () => { STATE.filterSearch.tag = DOM.tagSearch.value; STATE.filterPage.tag = 1; loadFacetPage("tag"); });
708
+ DOM.sourceFilter.addEventListener("click", (event) => { const pageButton = event.target.closest("[data-source-page]"); if (pageButton) { if (!pageButton.disabled) STATE.filterPage.source += pageButton.dataset.sourcePage === "next" ? 1 : -1; loadFacetPage("source"); syncUrl(); return; } if (event.target.closest("[data-clear-source]")) { STATE.sources = []; STATE.page = 1; renderFilterOptions(); doSearch(); return; } const item = event.target.closest("[data-source]"); if (!item || event.target.closest("input")) return; STATE.sources = toggleValue(STATE.sources, item.dataset.source); STATE.page = 1; renderFilterOptions(); doSearch(); });
709
+ DOM.authorFilter.addEventListener("click", (event) => { const pageButton = event.target.closest("[data-author-page]"); if (pageButton) { if (!pageButton.disabled) STATE.filterPage.author += pageButton.dataset.authorPage === "next" ? 1 : -1; loadFacetPage("author"); syncUrl(); return; } if (event.target.closest("[data-clear-author]")) { STATE.authors = []; STATE.page = 1; renderFilterOptions(); doSearch(); return; } const item = event.target.closest("[data-author]"); if (!item || event.target.closest("input")) return; STATE.authors = toggleValue(STATE.authors, item.dataset.author); STATE.page = 1; renderFilterOptions(); doSearch(); });
710
+ DOM.tagFilter.addEventListener("click", (event) => { const pageButton = event.target.closest("[data-tag-page]"); if (pageButton) { if (!pageButton.disabled) STATE.filterPage.tag += pageButton.dataset.tagPage === "next" ? 1 : -1; loadFacetPage("tag"); syncUrl(); return; } if (event.target.closest("[data-clear-tag]")) { STATE.tags = []; STATE.page = 1; renderFilterOptions(); doSearch(); return; } const item = event.target.closest("[data-tag]"); if (!item || event.target.closest("input")) return; STATE.tags = toggleValue(STATE.tags, item.dataset.tag); STATE.page = 1; renderFilterOptions(); doSearch(); });
711
  for (const [kind, element] of Object.entries({ source: DOM.sourceFilter, author: DOM.authorFilter, tag: DOM.tagFilter })) {
712
  element.addEventListener("keydown", (event) => {
713
  const input = event.target.closest(`[data-${kind}-page-input]`);
 
721
  if (input) commitFilterPage(kind, input);
722
  });
723
  }
724
+ DOM.archiveFilter.addEventListener("change", () => { STATE.archiveId = DOM.archiveFilter.value; STATE.page = 1; doSearch(); });
725
+ DOM.typeFilter.addEventListener("change", () => { STATE.publicationType = DOM.typeFilter.value; STATE.page = 1; doSearch(); });
726
+ DOM.yearFrom.addEventListener("change", () => { STATE.yearFrom = DOM.yearFrom.value; STATE.page = 1; doSearch(); });
727
+ DOM.yearTo.addEventListener("change", () => { STATE.yearTo = DOM.yearTo.value; STATE.page = 1; doSearch(); });
728
  DOM.clearFilters.addEventListener("click", resetFilters);
729
  DOM.randomBookBtn.addEventListener("click", openRandomPreview);
730
  DOM.emptyRandomBtn.addEventListener("click", openRandomPreview);
 
738
  DOM.results.addEventListener("click", (event) => {
739
  const preview = event.target.closest("[data-preview]");
740
  if (preview) {
741
+ openPreview(preview.dataset.preview);
 
742
  return;
743
  }
744
  const source = event.target.closest(".path-folder[data-source]");
745
  if (source) {
746
  STATE.sources = toggleValue(STATE.sources, source.dataset.source);
747
  STATE.page = 1;
 
748
  doSearch();
749
  return;
750
  }
 
752
  if (author) {
753
  STATE.authors = toggleValue(STATE.authors, author.dataset.author);
754
  STATE.page = 1;
 
755
  doSearch();
756
  return;
757
  }
 
759
  if (tag) {
760
  STATE.tags = toggleValue(STATE.tags, tag.dataset.tag);
761
  STATE.page = 1;
 
762
  doSearch();
763
  }
764
  });
 
769
  return;
770
  }
771
  if (event.target.closest("[data-close-preview]")) {
772
+ closePreview(true);
773
+ return;
774
  }
775
+ if (event.target.closest("[data-preview-theme]")) toggleTheme();
776
  });
777
  DOM.previewPanel.addEventListener("change", (event) => {
778
  const select = event.target.closest("[data-compare-select]");
 
799
  });
800
  DOM.pageInput.addEventListener("change", commitResultPage);
801
  window.addEventListener("popstate", () => {
802
+ cancelPreviewRequests();
803
+ const wasPreviewOpen = Boolean(STATE.preview);
804
  parseUrl();
805
+ if (wasPreviewOpen && !STATE.preview) {
806
+ history.replaceState({ previewEntry: false }, "", previewCloseUrl);
807
+ parseUrl();
808
+ }
809
  applyStateToDom();
810
  doSearch();
811
+ restorePreview(false);
812
  });
813
  document.addEventListener("keydown", (event) => {
814
  if (event.key === "/" && document.activeElement !== DOM.searchInput) {
 
868
  const savedMobileMode = localStorage.getItem("mobileMode");
869
  STATE.isMobile = savedMobileMode === "mobile" || (!savedMobileMode && window.innerWidth <= 768);
870
  parseUrl();
871
+ if (STATE.preview && history.state?.previewEntry !== true) {
872
+ const previewDocId = STATE.preview;
873
+ STATE.preview = "";
874
+ syncUrl();
875
+ STATE.preview = previewDocId;
876
+ syncUrl(false);
877
+ }
878
  applyStateToDom();
879
  attachEvents();
880
  loadFacets();
881
  doSearch();
882
+ restorePreview(false);
883
  updateProgress();
884
  }
885
 
static/style.css CHANGED
@@ -469,6 +469,11 @@ ul, li {
469
  border-bottom: 1px solid var(--outline-variant);
470
  }
471
 
 
 
 
 
 
472
  .preview-actions {
473
  display: flex;
474
  align-items: center;
@@ -476,6 +481,16 @@ ul, li {
476
  flex-shrink: 0;
477
  }
478
 
 
 
 
 
 
 
 
 
 
 
479
  .preview-compare-select {
480
  min-width: 128px;
481
  max-width: 220px;
@@ -565,8 +580,11 @@ ul, li {
565
  }
566
 
567
  .preview-body {
 
 
568
  margin: 0;
569
- padding: 14px;
 
570
  max-height: none;
571
  height: auto;
572
  flex: 0 1 auto;
@@ -574,8 +592,12 @@ ul, li {
574
  overflow: auto;
575
  white-space: pre-wrap;
576
  word-break: break-word;
577
- font-family: var(--font-sans);
578
- line-height: 1.75;
 
 
 
 
579
  }
580
 
581
  .preview-body mark {
@@ -609,13 +631,101 @@ body.mobile .preview-compare-grid .diff-gap {
609
  display: none;
610
  }
611
 
612
- body.mobile .preview-panel {
613
- flex: 1 1 auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614
  max-height: none;
 
 
615
  }
616
 
617
- body.mobile .preview-panel:not([hidden]) + .results-list {
618
- display: none;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
619
  }
620
 
621
  .result-snippet {
 
469
  border-bottom: 1px solid var(--outline-variant);
470
  }
471
 
472
+ .preview-heading {
473
+ min-width: 0;
474
+ flex: 1;
475
+ }
476
+
477
  .preview-actions {
478
  display: flex;
479
  align-items: center;
 
481
  flex-shrink: 0;
482
  }
483
 
484
+ .preview-tools {
485
+ display: flex;
486
+ flex-shrink: 0;
487
+ gap: 4px;
488
+ }
489
+
490
+ .preview-theme-btn {
491
+ display: none;
492
+ }
493
+
494
  .preview-compare-select {
495
  min-width: 128px;
496
  max-width: 220px;
 
580
  }
581
 
582
  .preview-body {
583
+ box-sizing: border-box;
584
+ width: 100%;
585
  margin: 0;
586
+ padding-block: 24px 36px;
587
+ padding-inline: max(20px, calc((100% - 72ch) / 2));
588
  max-height: none;
589
  height: auto;
590
  flex: 0 1 auto;
 
592
  overflow: auto;
593
  white-space: pre-wrap;
594
  word-break: break-word;
595
+ font-family: "Noto Serif SC", "Songti SC", "STSong", "SimSun", var(--font-sans);
596
+ font-size: 16px;
597
+ line-height: 1.9;
598
+ letter-spacing: 0.015em;
599
+ text-wrap: pretty;
600
+ tab-size: 4;
601
  }
602
 
603
  .preview-body mark {
 
631
  display: none;
632
  }
633
 
634
+ body.mobile.preview-open .preview-panel {
635
+ --reader-bg: #1d1e1b;
636
+ --reader-header: #22231f;
637
+ --reader-text: #d4d0c6;
638
+ --reader-title: #e3dfd5;
639
+ --reader-muted: #9f9d95;
640
+ --reader-divider: #393a35;
641
+ --reader-mark: #59491f;
642
+ --reader-on-mark: #f2d98f;
643
+ position: fixed;
644
+ inset: 0;
645
+ z-index: 150;
646
+ width: 100%;
647
+ height: 100vh;
648
+ height: 100dvh;
649
+ margin: 0;
650
+ border: 0;
651
+ border-radius: 0;
652
+ flex: none;
653
  max-height: none;
654
+ color: var(--reader-text);
655
+ background: var(--reader-bg);
656
  }
657
 
658
+ body.light.mobile.preview-open .preview-panel {
659
+ --reader-bg: #f7f3e8;
660
+ --reader-header: #f0ecdf;
661
+ --reader-text: #2d2b27;
662
+ --reader-title: #1e1d1a;
663
+ --reader-muted: #6b6860;
664
+ --reader-divider: #d7d0c2;
665
+ --reader-mark: #f1d58a;
666
+ --reader-on-mark: #3a2d0d;
667
+ }
668
+
669
+ body.mobile.preview-open .preview-header {
670
+ flex-shrink: 0;
671
+ padding-top: max(12px, env(safe-area-inset-top));
672
+ color: var(--reader-title);
673
+ background: var(--reader-header);
674
+ border-color: var(--reader-divider);
675
+ }
676
+
677
+ body.mobile.preview-open .preview-actions {
678
+ align-items: flex-start;
679
+ max-width: 48%;
680
+ flex-wrap: wrap;
681
+ justify-content: flex-end;
682
+ }
683
+
684
+ body.mobile.preview-open .preview-tools {
685
+ flex-direction: column;
686
+ }
687
+
688
+ body.mobile.preview-open .preview-theme-btn {
689
+ display: flex;
690
+ }
691
+
692
+ body.mobile.preview-open .preview-body {
693
+ flex: 1;
694
+ min-height: 0;
695
+ max-height: none;
696
+ padding: 22px 18px max(36px, env(safe-area-inset-bottom));
697
+ font-size: 17px;
698
+ line-height: 1.95;
699
+ letter-spacing: 0.02em;
700
+ color: var(--reader-text);
701
+ background: var(--reader-bg);
702
+ }
703
+
704
+ body.mobile.preview-open .preview-body mark {
705
+ color: var(--reader-on-mark);
706
+ background: var(--reader-mark);
707
+ }
708
+
709
+ body.mobile.preview-open .preview-path,
710
+ body.mobile.preview-open .preview-meta-row {
711
+ min-width: 0;
712
+ max-width: none;
713
+ overflow-x: auto;
714
+ overflow-y: hidden;
715
+ white-space: nowrap;
716
+ word-break: normal;
717
+ scrollbar-width: thin;
718
+ overscroll-behavior-x: contain;
719
+ -webkit-overflow-scrolling: touch;
720
+ }
721
+
722
+ body.mobile.preview-open .preview-path {
723
+ color: var(--reader-muted);
724
+ opacity: 1;
725
+ }
726
+
727
+ body.mobile.preview-open .preview-tools .icon-btn-sm {
728
+ color: var(--reader-muted);
729
  }
730
 
731
  .result-snippet {