vomebook commited on
Commit
41abe6f
·
1 Parent(s): 5dd19af

Upload 12 files

Browse files
Files changed (11) hide show
  1. .gitattributes +36 -0
  2. Dockerfile +14 -0
  3. README.md +12 -0
  4. app.py +337 -0
  5. requirements.txt +3 -0
  6. static/app.js +798 -0
  7. static/icons/icon-192.png +0 -0
  8. static/index.html +135 -0
  9. static/manifest.json +27 -0
  10. static/style.css +1650 -0
  11. static/sw.js +66 -0
.gitattributes ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ static/icons/icon-512.png filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY app.py .
9
+ COPY static/ static/
10
+ COPY data/ data/
11
+
12
+ EXPOSE 7860
13
+
14
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--log-level", "info"]
README.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: VOMEBOOK Search
3
+ emoji: 📚
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ pinned: false
8
+ license: gpl-3.0
9
+ short_description: TXT search and full-text preview
10
+ ---
11
+
12
+ VOMEBOOK Search is a txt-focused search Space for CCRD and CW bundles.
app.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VOMEBOOK Search Space backend for txt bundles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ import json
7
+ import posixpath
8
+ import random
9
+ import re
10
+ import time
11
+ from contextlib import asynccontextmanager
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Optional
15
+ from urllib.parse import quote
16
+
17
+ from fastapi import FastAPI, Query
18
+ from fastapi.middleware.cors import CORSMiddleware
19
+ from fastapi.middleware.gzip import GZipMiddleware
20
+ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, StreamingResponse
21
+ from fastapi.staticfiles import StaticFiles
22
+ from pydantic import BaseModel
23
+ from stream_zip import ZIP_32, stream_zip
24
+
25
+
26
+ BASE_DIR = Path(__file__).resolve().parent
27
+ DATA_PATH = BASE_DIR / "data/search_data.json.gz"
28
+ FOLDER_TREE_PATH = BASE_DIR / "data/folder_tree.json.gz"
29
+ FOLDER_BROWSER_PATH = BASE_DIR / "data/folder_browser.json.gz"
30
+ FULLTEXT_MANIFEST_PATH = BASE_DIR / "data/fulltext_manifest.json.gz"
31
+
32
+ records: list[dict] = []
33
+ record_map: dict[str, dict] = {}
34
+ sources: list[dict] = []
35
+ source_counts: dict[str, int] = {}
36
+ folder_tree_data: dict[str, list[dict]] = {}
37
+ folder_browser_data: dict[str, dict[str, dict]] = {}
38
+ source_records_map: dict[str, list[int]] = {}
39
+ extension_counts: dict[str, int] = {}
40
+ vocab_by_len: dict[int, dict[str, int]] = {}
41
+ word_index: dict[str, set[int]] = {}
42
+ did_you_mean_vocab: dict[str, int] = {}
43
+
44
+
45
+ def tokenize(text: str) -> list[str]:
46
+ text_lower = (text or "").lower()
47
+ return list(set(re.findall(r"[a-z0-9]+|[\u4e00-\u9fff\u3400-\u4dbf]+", text_lower)))
48
+
49
+
50
+ def edit_distance(s1: str, s2: str, max_dist: int = 2) -> int:
51
+ if abs(len(s1) - len(s2)) > max_dist:
52
+ return 999
53
+ prev = list(range(len(s2) + 1))
54
+ for i, c1 in enumerate(s1):
55
+ curr = [i + 1]
56
+ for j, c2 in enumerate(s2):
57
+ cost = 0 if c1 == c2 else 1
58
+ curr.append(min(prev[j + 1] + 1, curr[j] + 1, prev[j] + cost))
59
+ if min(curr) > max_dist:
60
+ return 999
61
+ prev = curr
62
+ return prev[-1]
63
+
64
+
65
+ def load_json_gz(path: Path):
66
+ return json.loads(gzip.decompress(path.read_bytes()).decode("utf-8"))
67
+
68
+
69
+ def build_indexes() -> None:
70
+ global word_index, did_you_mean_vocab, vocab_by_len, source_records_map, extension_counts
71
+ word_index = {}
72
+ did_you_mean_vocab = {}
73
+ vocab_by_len = {}
74
+ source_records_map = {}
75
+ extension_counts = {}
76
+ for idx, rec in enumerate(records):
77
+ source_records_map.setdefault(rec["source"], []).append(idx)
78
+ extension_counts[rec["extension"]] = extension_counts.get(rec["extension"], 0) + 1
79
+ search_text = " ".join([rec["display_name"], rec["display_rel_path"], rec["source_name"]])
80
+ tokens = tokenize(search_text)
81
+ rec["_search_text"] = search_text.lower()
82
+ for token in tokens:
83
+ word_index.setdefault(token, set()).add(idx)
84
+ did_you_mean_vocab[token] = did_you_mean_vocab.get(token, 0) + 1
85
+ for token, freq in did_you_mean_vocab.items():
86
+ vocab_by_len.setdefault(len(token), {})[token] = freq
87
+
88
+
89
+ def load_data() -> None:
90
+ global records, record_map, sources, source_counts, folder_tree_data, folder_browser_data
91
+ start = time.time()
92
+ payload = load_json_gz(DATA_PATH)
93
+ records = payload.get("records", [])
94
+ sources = payload.get("sources", [])
95
+ source_counts = {item["slug"]: item.get("count", 0) for item in sources}
96
+ record_map = {rec["doc_id"]: rec for rec in records}
97
+ folder_tree_data = load_json_gz(FOLDER_TREE_PATH)
98
+ folder_browser_data = load_json_gz(FOLDER_BROWSER_PATH)
99
+ build_indexes()
100
+ print(f"loaded {len(records)} txt records in {time.time() - start:.2f}s")
101
+
102
+
103
+ def score_record(idx: int, query_tokens: list[str]) -> int:
104
+ score = 0
105
+ text = records[idx]["_search_text"]
106
+ for token in query_tokens:
107
+ if token in text:
108
+ score += 3
109
+ return score
110
+
111
+
112
+ def apply_filters(indices: list[int], sources_filter=None, folders=None, min_size=None, max_size=None):
113
+ result = []
114
+ for idx in indices:
115
+ rec = records[idx]
116
+ if sources_filter and rec["source"] not in sources_filter:
117
+ continue
118
+ if folders:
119
+ rel_dir = "/".join(rec.get("display_dirs", []))
120
+ matched = any(rel_dir == folder or rel_dir.startswith(folder + "/") for folder in folders)
121
+ if not matched:
122
+ continue
123
+ size = rec.get("size") or 0
124
+ if min_size is not None and size < min_size:
125
+ continue
126
+ if max_size is not None and size > max_size:
127
+ continue
128
+ result.append(idx)
129
+ return result
130
+
131
+
132
+ def trim_record(rec: dict) -> dict:
133
+ return {
134
+ "doc_id": rec["doc_id"],
135
+ "Source": rec["source"],
136
+ "SourceName": rec["source_name"],
137
+ "File": rec["display_name"],
138
+ "Extension": rec["extension"],
139
+ "Folder": rec["display_dirs"],
140
+ "DisplayPath": rec["display_rel_path"],
141
+ "Size": rec["size"],
142
+ "HasTxt": True,
143
+ }
144
+
145
+
146
+ def search(q="", sources_filter=None, folders=None, min_size=None, max_size=None, page=1, page_size=100, sort="relevance", exact=False):
147
+ q = q.strip()
148
+ if not q:
149
+ indices = list(range(len(records)))
150
+ filtered = apply_filters(indices, sources_filter, folders, min_size, max_size)
151
+ else:
152
+ tokens = tokenize(q)
153
+ if exact:
154
+ indices = [idx for idx, rec in enumerate(records) if q.lower() in rec["_search_text"]]
155
+ else:
156
+ matched = set()
157
+ for token in tokens:
158
+ if token in word_index:
159
+ matched.update(word_index[token])
160
+ continue
161
+ tok_len = len(token)
162
+ for delta in (-2, -1, 0, 1, 2):
163
+ for vocab_word in vocab_by_len.get(tok_len + delta, {}):
164
+ if edit_distance(token, vocab_word) <= 2:
165
+ matched.update(word_index.get(vocab_word, set()))
166
+ if len(matched) > 5000:
167
+ break
168
+ indices = list(matched)
169
+ filtered = apply_filters(indices, sources_filter, folders, min_size, max_size)
170
+ if sort == "relevance":
171
+ filtered.sort(key=lambda idx: (-score_record(idx, tokens), records[idx]["display_rel_path"].lower()))
172
+
173
+ if sort == "name":
174
+ filtered.sort(key=lambda idx: records[idx]["display_rel_path"].lower())
175
+ elif sort == "size":
176
+ filtered.sort(key=lambda idx: (-records[idx].get("size", 0), records[idx]["display_rel_path"].lower()))
177
+
178
+ total = len(filtered)
179
+ start = (page - 1) * page_size
180
+ result_items = [trim_record(records[idx]) for idx in filtered[start:start + page_size]]
181
+ return {"results": result_items, "total": total, "page": page, "page_size": page_size, "did_you_mean": None}
182
+
183
+
184
+ def get_doc_storage_path(doc: dict) -> Path:
185
+ return BASE_DIR / doc["storage_root"] / doc["storage_rel_path"]
186
+
187
+
188
+ def get_folder_contents(source_slug: str, path: str) -> dict:
189
+ source_browser = folder_browser_data.get(source_slug, {})
190
+ if path in source_browser:
191
+ entry = dict(source_browser[path])
192
+ entry["current_path"] = path
193
+ return entry
194
+ return {"folders": [], "files": [], "current_path": path}
195
+
196
+
197
+ @asynccontextmanager
198
+ async def lifespan(app: FastAPI):
199
+ load_data()
200
+ yield
201
+
202
+
203
+ app = FastAPI(title="VOMEBOOK Search", version="1.0", lifespan=lifespan)
204
+ app.add_middleware(GZipMiddleware, minimum_size=500)
205
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
206
+
207
+
208
+ class SearchRequest(BaseModel):
209
+ q: str = ""
210
+ sources: Optional[list[str]] = None
211
+ folders: Optional[list[str]] = None
212
+ min_size: Optional[int] = None
213
+ max_size: Optional[int] = None
214
+ page: int = 1
215
+ page_size: int = 100
216
+ sort: str = "relevance"
217
+ exact: bool = False
218
+
219
+
220
+ class ZipRequest(BaseModel):
221
+ doc_ids: list[str] = []
222
+
223
+
224
+ @app.post("/api/search")
225
+ def api_search(body: SearchRequest):
226
+ return JSONResponse(search(body.q, body.sources, body.folders, body.min_size, body.max_size, body.page, body.page_size, body.sort, body.exact))
227
+
228
+
229
+ @app.post("/api/search/{source_slug}")
230
+ def api_search_source(source_slug: str, body: SearchRequest):
231
+ if source_slug not in source_counts:
232
+ return JSONResponse({"error": "source not found", "results": [], "total": 0}, status_code=404)
233
+ return JSONResponse(search(body.q, [source_slug], body.folders, body.min_size, body.max_size, body.page, body.page_size, body.sort, body.exact))
234
+
235
+
236
+ @app.get("/api/sources")
237
+ def api_sources():
238
+ return JSONResponse(sources)
239
+
240
+
241
+ @app.get("/api/extensions")
242
+ def api_extensions(source: Optional[str] = Query(default=None)):
243
+ pool = [records[idx] for idx in source_records_map.get(source, [])] if source else records
244
+ counts = {}
245
+ for rec in pool:
246
+ counts[rec["extension"]] = counts.get(rec["extension"], 0) + 1
247
+ return JSONResponse([{"name": ext, "count": count} for ext, count in sorted(counts.items())])
248
+
249
+
250
+ @app.get("/api/folders/{source_slug}")
251
+ def api_folders(source_slug: str):
252
+ return JSONResponse(folder_tree_data.get(source_slug, []))
253
+
254
+
255
+ @app.get("/api/folders/{source_slug}/contents")
256
+ def api_folder_contents(source_slug: str, path: str = Query(default="")):
257
+ return JSONResponse(get_folder_contents(source_slug, path))
258
+
259
+
260
+ @app.get("/api/random")
261
+ def api_random(source: Optional[str] = Query(default=None)):
262
+ pool = [records[idx] for idx in source_records_map.get(source, [])] if source else records
263
+ if not pool:
264
+ return JSONResponse({"error": "no record"}, status_code=404)
265
+ return JSONResponse(trim_record(random.choice(pool)))
266
+
267
+
268
+ @app.get("/api/preview/{doc_id}")
269
+ def api_preview(doc_id: str):
270
+ rec = record_map.get(doc_id)
271
+ if not rec:
272
+ return JSONResponse({"error": "not found"}, status_code=404)
273
+ text = get_doc_storage_path(rec).read_text(encoding="utf-8", errors="ignore")
274
+ return JSONResponse({"doc_id": doc_id, "title": rec["display_name"], "path": rec["display_rel_path"], "source": rec["source_name"], "text": text})
275
+
276
+
277
+ @app.get("/api/download/{doc_id}")
278
+ def api_download(doc_id: str):
279
+ rec = record_map.get(doc_id)
280
+ if not rec:
281
+ return JSONResponse({"error": "not found"}, status_code=404)
282
+ file_path = get_doc_storage_path(rec)
283
+ encoded_filename = quote(f"{rec['display_name']}.txt", safe="")
284
+ return StreamingResponse(
285
+ iter([file_path.read_bytes()]),
286
+ media_type="text/plain; charset=utf-8",
287
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
288
+ )
289
+
290
+
291
+ @app.post("/api/zip")
292
+ def api_zip(req: ZipRequest):
293
+ valid = []
294
+ for doc_id in req.doc_ids[:500]:
295
+ rec = record_map.get(doc_id)
296
+ if not rec:
297
+ continue
298
+ valid.append((rec["display_rel_path"], get_doc_storage_path(rec).read_bytes()))
299
+ if not valid:
300
+ return JSONResponse({"error": "no files"}, status_code=400)
301
+
302
+ modified_at = datetime.now()
303
+
304
+ def members():
305
+ for display_path, data in valid:
306
+ yield (display_path.encode("utf-8"), modified_at, ZIP_32, data)
307
+
308
+ return StreamingResponse(
309
+ stream_zip(members()),
310
+ media_type="application/zip",
311
+ headers={"Content-Disposition": "attachment; filename=vomebook_batch.zip"},
312
+ )
313
+
314
+
315
+ @app.get("/api/fulltext-manifest")
316
+ def api_fulltext_manifest():
317
+ return JSONResponse(load_json_gz(FULLTEXT_MANIFEST_PATH))
318
+
319
+
320
+ app.mount("/data", StaticFiles(directory=str(BASE_DIR / "data")), name="data")
321
+ app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static"), html=True), name="static")
322
+ app.mount("/icons", StaticFiles(directory=str(BASE_DIR / "static/icons")), name="icons")
323
+
324
+
325
+ @app.get("/manifest.json")
326
+ def serve_manifest():
327
+ return JSONResponse(json.loads((BASE_DIR / "static/manifest.json").read_text(encoding="utf-8")))
328
+
329
+
330
+ @app.get("/sw.js")
331
+ def serve_sw():
332
+ return PlainTextResponse((BASE_DIR / "static/sw.js").read_text(encoding="utf-8"), media_type="application/javascript")
333
+
334
+
335
+ @app.get("/{rest_of_path:path}")
336
+ async def serve_spa(rest_of_path: str):
337
+ return HTMLResponse((BASE_DIR / "static/index.html").read_text(encoding="utf-8"))
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn==0.30.6
3
+ stream-zip==0.0.83
static/app.js ADDED
@@ -0,0 +1,798 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const STATE = {
2
+ mode: "global",
3
+ source: null,
4
+ query: "",
5
+ page: 1,
6
+ pageSize: 100,
7
+ total: 0,
8
+ results: [],
9
+ sources: [],
10
+ sourceMap: {},
11
+ folderTree: [],
12
+ folderSelections: [],
13
+ selectedSources: [],
14
+ minSize: null,
15
+ maxSize: null,
16
+ exact: false,
17
+ fulltext: false,
18
+ fulltextManifest: null,
19
+ fulltextLoaded: {},
20
+ historyEnabled: true,
21
+ leftSidebarOpen: true,
22
+ rightSidebarOpen: false,
23
+ isDark: true,
24
+ isLoading: false,
25
+ multiSelect: false,
26
+ selectedIds: new Set(),
27
+ };
28
+
29
+ const DOM = {};
30
+ const HISTORY_KEY = "vomebook_search_history";
31
+
32
+ function $(selector) {
33
+ return document.querySelector(selector);
34
+ }
35
+
36
+ function escapeHTML(value) {
37
+ return String(value).replace(/[&<>"']/g, function(ch) {
38
+ return ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch];
39
+ });
40
+ }
41
+
42
+ function tokenize(text) {
43
+ return Array.from(new Set(String(text || "").toLowerCase().match(/[a-z0-9]+|[\u4e00-\u9fff\u3400-\u4dbf]+/g) || []));
44
+ }
45
+
46
+ function formatSize(bytes) {
47
+ if (!bytes) return "";
48
+ if (bytes < 1024) return bytes + " B";
49
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1).replace(/\.0$/, "") + " KB";
50
+ if (bytes < 1073741824) return (bytes / 1048576).toFixed(1).replace(/\.0$/, "") + " MB";
51
+ return (bytes / 1073741824).toFixed(2).replace(/\.00$/, "") + " GB";
52
+ }
53
+
54
+ function bytesFromInput(value, unit) {
55
+ const parsed = parseFloat(value);
56
+ if (Number.isNaN(parsed) || parsed < 0) return null;
57
+ if (unit === "KB") return Math.round(parsed * 1024);
58
+ if (unit === "MB") return Math.round(parsed * 1048576);
59
+ if (unit === "GB") return Math.round(parsed * 1073741824);
60
+ return Math.round(parsed);
61
+ }
62
+
63
+ function buildPath(folder, file) {
64
+ return folder.length ? folder.join("/") + "/" + file : file;
65
+ }
66
+
67
+ const API = {
68
+ async getSources() {
69
+ const resp = await fetch("/api/sources");
70
+ return resp.json();
71
+ },
72
+ async search(body, sourceSlug) {
73
+ const url = sourceSlug ? `/api/search/${sourceSlug}` : "/api/search";
74
+ const resp = await fetch(url, {
75
+ method: "POST",
76
+ headers: { "Content-Type": "application/json" },
77
+ body: JSON.stringify(body),
78
+ });
79
+ return resp.json();
80
+ },
81
+ async getFolders(sourceSlug) {
82
+ const resp = await fetch(`/api/folders/${sourceSlug}`);
83
+ return resp.json();
84
+ },
85
+ async getContents(sourceSlug, path) {
86
+ const qs = path ? `?path=${encodeURIComponent(path)}` : "";
87
+ const resp = await fetch(`/api/folders/${sourceSlug}/contents${qs}`);
88
+ return resp.json();
89
+ },
90
+ async preview(docId) {
91
+ const resp = await fetch(`/api/preview/${encodeURIComponent(docId)}`);
92
+ return resp.json();
93
+ },
94
+ async random(sourceSlug) {
95
+ const url = sourceSlug ? `/api/random?source=${encodeURIComponent(sourceSlug)}` : "/api/random";
96
+ const resp = await fetch(url);
97
+ return resp.json();
98
+ },
99
+ async fulltextManifest() {
100
+ const resp = await fetch("/api/fulltext-manifest");
101
+ return resp.json();
102
+ },
103
+ async fulltextIndex(path) {
104
+ const resp = await fetch(`/data/${path}`);
105
+ const blob = await resp.blob();
106
+ const ds = new DecompressionStream("gzip");
107
+ const stream = blob.stream().pipeThrough(ds);
108
+ const text = await new Response(stream).text();
109
+ return JSON.parse(text);
110
+ },
111
+ };
112
+
113
+ function cacheDom() {
114
+ DOM.headerTitle = $("#header-title");
115
+ DOM.headerLogo = $("#header-logo");
116
+ DOM.searchInput = $("#search-input");
117
+ DOM.searchHistoryDropdown = $("#search-history-dropdown");
118
+ DOM.hamburgerBtn = $("#hamburger-btn");
119
+ DOM.settingsBtn = $("#settings-btn");
120
+ DOM.themeBtn = $("#theme-btn");
121
+ DOM.leftSidebar = $("#left-sidebar");
122
+ DOM.rightSidebar = $("#right-sidebar");
123
+ DOM.sidebarTitle = $("#sidebar-title");
124
+ DOM.sidebarContent = $("#sidebar-content");
125
+ DOM.resultCount = $("#result-count");
126
+ DOM.resultsContainer = $("#results-container");
127
+ DOM.resultsList = $("#results-list");
128
+ DOM.resultsLoading = $("#results-loading");
129
+ DOM.emptyState = $("#empty-state");
130
+ DOM.emptyDesc = $("#empty-desc");
131
+ DOM.emptyRandomBtn = $("#empty-random-btn");
132
+ DOM.loadInfo = $("#load-info");
133
+ DOM.loadedCount = $("#loaded-count");
134
+ DOM.totalCount = $("#total-count");
135
+ DOM.didYouMean = $("#did-you-mean");
136
+ DOM.clearFiltersBtn = $("#clear-filters-btn");
137
+ DOM.sortSelect = $("#sort-select");
138
+ DOM.previewPanel = $("#preview-panel");
139
+ DOM.closeFiltersBtn = $("#close-filters-btn");
140
+ DOM.fulltextToggle = $("#fulltext-toggle");
141
+ DOM.historyToggle = $("#history-toggle");
142
+ DOM.exactToggle = $("#exact-search-toggle");
143
+ DOM.filterSourceList = $("#filter-source-list");
144
+ DOM.filterFolderTree = $("#filter-folder-tree");
145
+ DOM.filterMinSize = $("#filter-min-size");
146
+ DOM.filterMaxSize = $("#filter-max-size");
147
+ DOM.filterMinUnit = $("#filter-min-unit");
148
+ DOM.filterMaxUnit = $("#filter-max-unit");
149
+ DOM.folderSelectAll = $("#folder-select-all");
150
+ DOM.folderDeselectAll = $("#folder-deselect-all");
151
+ DOM.multiToggleLabel = $("#multi-toggle-label");
152
+ DOM.multiSelectToggle = $("#multi-select-toggle");
153
+ DOM.multiActionBar = $("#multi-action-bar");
154
+ DOM.multiSelectAll = $("#multi-select-all");
155
+ DOM.multiDeselect = $("#multi-deselect");
156
+ DOM.multiBatchDownload = $("#multi-batch-download");
157
+ DOM.multiZipDownload = $("#multi-zip-download");
158
+ DOM.multiSelectedCount = $("#multi-selected-count");
159
+ DOM.overlay = $("#overlay");
160
+ DOM.toast = $("#toast");
161
+ }
162
+
163
+ function parseRoute() {
164
+ const path = window.location.pathname.replace(/^\/+|\/+$/g, "");
165
+ if (!path) return { mode: "global", source: null };
166
+ return { mode: "source", source: decodeURIComponent(path) };
167
+ }
168
+
169
+ function syncRoute() {
170
+ const route = parseRoute();
171
+ STATE.mode = route.mode;
172
+ STATE.source = route.source;
173
+ if (STATE.source) {
174
+ DOM.sidebarTitle.textContent = STATE.source;
175
+ } else {
176
+ DOM.sidebarTitle.textContent = "数据包";
177
+ }
178
+ }
179
+
180
+ function navigateToSource(sourceSlug) {
181
+ history.pushState(null, "", `/${encodeURIComponent(sourceSlug)}`);
182
+ STATE.page = 1;
183
+ STATE.folderSelections = [];
184
+ syncRoute();
185
+ renderSidebar();
186
+ renderFolderTree();
187
+ doSearch();
188
+ }
189
+
190
+ function navigateHome() {
191
+ history.pushState(null, "", "/");
192
+ STATE.page = 1;
193
+ STATE.folderSelections = [];
194
+ syncRoute();
195
+ renderSidebar();
196
+ renderFolderTree();
197
+ doSearch();
198
+ }
199
+
200
+ function getSelectedSourcesForSearch() {
201
+ if (STATE.source) return [STATE.source];
202
+ if (STATE.selectedSources.length) return STATE.selectedSources.slice();
203
+ return [];
204
+ }
205
+
206
+ function updateStatus() {
207
+ DOM.resultCount.textContent = STATE.total ? `共 ${STATE.total.toLocaleString()} 条结果` : "";
208
+ DOM.loadedCount.textContent = STATE.results.length.toLocaleString();
209
+ DOM.totalCount.textContent = STATE.total.toLocaleString();
210
+ DOM.loadInfo.style.display = STATE.total ? "" : "none";
211
+ DOM.multiToggleLabel.style.display = STATE.total ? "" : "none";
212
+ const hasFilter = STATE.folderSelections.length || STATE.selectedSources.length || STATE.minSize !== null || STATE.maxSize !== null;
213
+ DOM.clearFiltersBtn.style.display = hasFilter ? "" : "none";
214
+ }
215
+
216
+ function showToast(message, duration = 2000) {
217
+ DOM.toast.textContent = message;
218
+ DOM.toast.style.display = "";
219
+ clearTimeout(showToast._timer);
220
+ showToast._timer = setTimeout(() => {
221
+ DOM.toast.style.display = "none";
222
+ }, duration);
223
+ }
224
+
225
+ function getHistory() {
226
+ try {
227
+ return JSON.parse(localStorage.getItem(HISTORY_KEY)) || [];
228
+ } catch (_error) {
229
+ return [];
230
+ }
231
+ }
232
+
233
+ function saveHistory(items) {
234
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(items.slice(0, 20)));
235
+ }
236
+
237
+ function addHistory(query) {
238
+ if (!STATE.historyEnabled || !query) return;
239
+ const items = getHistory().filter((item) => item !== query);
240
+ items.unshift(query);
241
+ saveHistory(items);
242
+ }
243
+
244
+ function renderHistoryDropdown() {
245
+ const items = getHistory();
246
+ if (!items.length) {
247
+ DOM.searchHistoryDropdown.style.display = "none";
248
+ return;
249
+ }
250
+ DOM.searchHistoryDropdown.innerHTML = items.map((item) => `<div class="history-item" data-query="${escapeHTML(item)}"><span class="history-text">${escapeHTML(item)}</span></div>`).join("");
251
+ DOM.searchHistoryDropdown.style.display = "";
252
+ }
253
+
254
+ function applyTheme() {
255
+ document.body.classList.toggle("light", !STATE.isDark);
256
+ }
257
+
258
+ async function ensureFulltextLoaded() {
259
+ if (!STATE.fulltextManifest) {
260
+ STATE.fulltextManifest = await API.fulltextManifest();
261
+ }
262
+ const sourceSlugs = STATE.source ? [STATE.source] : (STATE.selectedSources.length ? STATE.selectedSources : STATE.sources.map((source) => source.slug));
263
+ for (const slug of sourceSlugs) {
264
+ if (STATE.fulltextLoaded[slug]) continue;
265
+ const item = (STATE.fulltextManifest.sources || []).find((source) => source.slug === slug);
266
+ if (!item) continue;
267
+ showToast(`加载全文索引: ${slug}`);
268
+ STATE.fulltextLoaded[slug] = await API.fulltextIndex(item.path);
269
+ }
270
+ }
271
+
272
+ function scoreFulltextDoc(docId, payload, queryTokens, exactQuery) {
273
+ if (exactQuery) {
274
+ const snippet = (payload.snippets[docId] || "").toLowerCase();
275
+ return snippet.includes(exactQuery.toLowerCase()) ? 100 : 1;
276
+ }
277
+ let score = 0;
278
+ for (const token of queryTokens) {
279
+ if ((payload.snippets[docId] || "").toLowerCase().includes(token)) score += 4;
280
+ if ((payload.docs[docId]?.display_name || "").toLowerCase().includes(token)) score += 1;
281
+ }
282
+ return score;
283
+ }
284
+
285
+ async function fulltextSearch() {
286
+ await ensureFulltextLoaded();
287
+ const query = STATE.query.trim();
288
+ const queryTokens = tokenize(query);
289
+ const sourceSlugs = STATE.source ? [STATE.source] : (STATE.selectedSources.length ? STATE.selectedSources : STATE.sources.map((source) => source.slug));
290
+ let results = [];
291
+ for (const slug of sourceSlugs) {
292
+ const payload = STATE.fulltextLoaded[slug];
293
+ if (!payload) continue;
294
+ let matchedIds = null;
295
+ if (STATE.exact) {
296
+ matchedIds = Object.keys(payload.docs).filter((docId) => {
297
+ const snippet = (payload.snippets[docId] || "") + " " + (payload.docs[docId]?.display_name || "");
298
+ return snippet.toLowerCase().includes(query.toLowerCase());
299
+ });
300
+ } else {
301
+ for (const token of queryTokens) {
302
+ const docIds = payload.index[token] || [];
303
+ const set = new Set(docIds);
304
+ if (matchedIds === null) matchedIds = set;
305
+ else matchedIds = new Set(Array.from(matchedIds).filter((docId) => set.has(docId)));
306
+ }
307
+ matchedIds = Array.from(matchedIds || []);
308
+ }
309
+ for (const docId of matchedIds) {
310
+ const meta = payload.docs[docId];
311
+ const folderParts = meta.display_rel_path.split("/");
312
+ const fileName = folderParts.pop();
313
+ const displayName = fileName.replace(/\.txt$/i, "");
314
+ results.push({
315
+ doc_id: docId,
316
+ Source: slug,
317
+ SourceName: STATE.sourceMap[slug]?.name || slug,
318
+ File: displayName,
319
+ Extension: "txt",
320
+ Folder: folderParts,
321
+ DisplayPath: meta.display_rel_path,
322
+ Size: meta.size,
323
+ HasTxt: true,
324
+ snippet: payload.snippets[docId] || "",
325
+ _score: scoreFulltextDoc(docId, payload, queryTokens, query),
326
+ });
327
+ }
328
+ }
329
+ if (STATE.folderSelections.length) {
330
+ results = results.filter((item) => {
331
+ const folder = item.Folder.join("/");
332
+ return STATE.folderSelections.some((selected) => folder === selected || folder.startsWith(selected + "/"));
333
+ });
334
+ }
335
+ if (STATE.minSize !== null) results = results.filter((item) => item.Size >= STATE.minSize);
336
+ if (STATE.maxSize !== null) results = results.filter((item) => item.Size <= STATE.maxSize);
337
+ if (DOM.sortSelect.value === "name") results.sort((a, b) => a.DisplayPath.localeCompare(b.DisplayPath, "zh"));
338
+ else if (DOM.sortSelect.value === "size") results.sort((a, b) => (b.Size - a.Size) || a.DisplayPath.localeCompare(b.DisplayPath, "zh"));
339
+ else results.sort((a, b) => (b._score - a._score) || a.DisplayPath.localeCompare(b.DisplayPath, "zh"));
340
+ STATE.total = results.length;
341
+ STATE.results = results.slice(0, STATE.page * STATE.pageSize);
342
+ }
343
+
344
+ async function pathSearch() {
345
+ const body = {
346
+ q: STATE.query,
347
+ sources: getSelectedSourcesForSearch(),
348
+ folders: STATE.folderSelections,
349
+ min_size: STATE.minSize,
350
+ max_size: STATE.maxSize,
351
+ page: 1,
352
+ page_size: STATE.page * STATE.pageSize,
353
+ sort: DOM.sortSelect.value,
354
+ exact: STATE.exact,
355
+ };
356
+ const data = await API.search(body, STATE.source);
357
+ STATE.total = data.total || 0;
358
+ STATE.results = data.results || [];
359
+ }
360
+
361
+ async function doSearch() {
362
+ STATE.isLoading = true;
363
+ DOM.resultsLoading.style.display = "flex";
364
+ DOM.previewPanel.style.display = "none";
365
+ try {
366
+ if (STATE.fulltext && STATE.query.trim()) {
367
+ await fulltextSearch();
368
+ } else {
369
+ await pathSearch();
370
+ }
371
+ renderResults();
372
+ updateStatus();
373
+ } catch (error) {
374
+ console.error(error);
375
+ showToast("搜索失败");
376
+ } finally {
377
+ STATE.isLoading = false;
378
+ DOM.resultsLoading.style.display = "none";
379
+ }
380
+ }
381
+
382
+ function resultPathHtml(item) {
383
+ const sourcePrefix = `<span class="path-folder" data-source="${escapeHTML(item.Source)}" data-folder="">${escapeHTML(item.SourceName)}</span>`;
384
+ const rest = (item.Folder || []).map((part, index) => {
385
+ const path = item.Folder.slice(0, index + 1).join("/");
386
+ return `<span class="path-sep">/</span><span class="path-folder" data-source="${escapeHTML(item.Source)}" data-folder="${escapeHTML(path)}">${escapeHTML(part)}</span>`;
387
+ }).join("");
388
+ return sourcePrefix + rest;
389
+ }
390
+
391
+ function renderResults() {
392
+ if (!STATE.results.length) {
393
+ DOM.resultsList.innerHTML = "";
394
+ DOM.emptyState.style.display = "flex";
395
+ DOM.emptyDesc.textContent = STATE.query ? `没有找到与“${STATE.query}”相关的结果` : "暂无数据";
396
+ DOM.multiActionBar.style.display = "none";
397
+ return;
398
+ }
399
+ DOM.emptyState.style.display = "none";
400
+ DOM.resultsList.innerHTML = STATE.results.map((item) => {
401
+ const fullName = `${item.File}.txt`;
402
+ const snippetHtml = item.snippet ? `<div class="result-snippet">${escapeHTML(item.snippet)}</div>` : "";
403
+ return `
404
+ <div class="result-item" data-doc-id="${escapeHTML(item.doc_id)}">
405
+ <input type="checkbox" class="result-checkbox" data-doc-id="${escapeHTML(item.doc_id)}" ${STATE.selectedIds.has(item.doc_id) ? "checked" : ""}>
406
+ <div class="result-file-icon">TXT</div>
407
+ <div class="result-info">
408
+ <div class="result-title">${escapeHTML(item.File)}<span style="opacity:0.5;font-size:12px">.txt</span></div>
409
+ <div class="result-path">${resultPathHtml(item)}</div>
410
+ <div class="result-meta"><span class="result-size">${escapeHTML(formatSize(item.Size))}</span></div>
411
+ ${snippetHtml}
412
+ </div>
413
+ <div class="result-actions">
414
+ <button class="result-action-btn" data-action="preview" data-doc-id="${escapeHTML(item.doc_id)}">在线预览</button>
415
+ <a class="result-action-btn primary" href="/api/download/${encodeURIComponent(item.doc_id)}">下载</a>
416
+ </div>
417
+ </div>`;
418
+ }).join("");
419
+ DOM.multiActionBar.style.display = STATE.multiSelect ? "" : "none";
420
+ }
421
+
422
+ async function openPreview(docId, keyword) {
423
+ try {
424
+ const data = await API.preview(docId);
425
+ if (data.error) {
426
+ showToast("预览失败");
427
+ return;
428
+ }
429
+ let text = escapeHTML(data.text || "");
430
+ if (keyword) {
431
+ const tokens = tokenize(keyword).filter(Boolean);
432
+ for (const token of tokens) {
433
+ text = text.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), (m) => `<mark>${m}</mark>`);
434
+ }
435
+ }
436
+ DOM.previewPanel.innerHTML = `
437
+ <div class="preview-header">
438
+ <div class="preview-title">${escapeHTML(data.title)}</div>
439
+ <div class="preview-path">${escapeHTML(data.source)} / ${escapeHTML(data.path)}</div>
440
+ </div>
441
+ <pre class="preview-body">${text}</pre>`;
442
+ DOM.previewPanel.style.display = "block";
443
+ DOM.previewPanel.scrollIntoView({ behavior: "smooth", block: "start" });
444
+ } catch (error) {
445
+ console.error(error);
446
+ showToast("预览失败");
447
+ }
448
+ }
449
+
450
+ function renderCheckboxList(container, items, selected, onChange) {
451
+ container.innerHTML = items.map((item) => `
452
+ <label class="filter-checkbox-item">
453
+ <input type="checkbox" value="${escapeHTML(item.key)}" ${selected.includes(item.key) ? "checked" : ""}>
454
+ <span>${escapeHTML(item.label)}</span>
455
+ <span class="checkbox-count">${(item.count || 0).toLocaleString()}</span>
456
+ </label>`).join("");
457
+ container.querySelectorAll("input").forEach((input) => {
458
+ input.addEventListener("change", () => {
459
+ const values = Array.from(container.querySelectorAll("input:checked")).map((item) => item.value);
460
+ onChange(values);
461
+ });
462
+ });
463
+ }
464
+
465
+ async function renderSourcesFilter() {
466
+ renderCheckboxList(DOM.filterSourceList, STATE.sources.map((source) => ({ key: source.slug, label: source.name, count: source.count })), STATE.selectedSources, (values) => {
467
+ STATE.selectedSources = values;
468
+ STATE.page = 1;
469
+ doSearch();
470
+ });
471
+ }
472
+
473
+ function flattenNodes(nodes, into) {
474
+ for (const node of nodes) {
475
+ if (!node.isRoot && node.path) into.push(node.path);
476
+ flattenNodes(node.children || [], into);
477
+ }
478
+ }
479
+
480
+ function renderFolderNodes(container, nodes, depth) {
481
+ for (const node of nodes) {
482
+ const row = document.createElement("div");
483
+ row.className = "filter-folder-item";
484
+ row.style.setProperty("--fdepth", depth);
485
+ const checked = STATE.folderSelections.includes(node.path);
486
+ row.innerHTML = `<span style="width:16px;display:inline-block"></span><input type="checkbox" value="${escapeHTML(node.path)}" ${checked ? "checked" : ""}><span class="folder-name">${escapeHTML(node.name)}</span><span class="folder-count">${(node.count || 0).toLocaleString()}</span>`;
487
+ const checkbox = row.querySelector("input");
488
+ checkbox.addEventListener("change", () => {
489
+ if (checkbox.checked) {
490
+ if (!STATE.folderSelections.includes(node.path)) STATE.folderSelections.push(node.path);
491
+ } else {
492
+ STATE.folderSelections = STATE.folderSelections.filter((path) => path !== node.path);
493
+ }
494
+ STATE.page = 1;
495
+ doSearch();
496
+ });
497
+ container.appendChild(row);
498
+ if (node.children && node.children.length) renderFolderNodes(container, node.children, depth + 1);
499
+ }
500
+ }
501
+
502
+ async function renderFolderTree() {
503
+ DOM.filterFolderTree.innerHTML = "";
504
+ if (!STATE.source) {
505
+ DOM.filterFolderTree.innerHTML = '<div style="font-size:12px;opacity:0.6">进入单仓库后可按路径过滤</div>';
506
+ return;
507
+ }
508
+ STATE.folderTree = await API.getFolders(STATE.source);
509
+ renderFolderNodes(DOM.filterFolderTree, STATE.folderTree, 0);
510
+ }
511
+
512
+ async function renderSidebar() {
513
+ if (!STATE.source) {
514
+ DOM.sidebarTitle.textContent = "数据包";
515
+ DOM.sidebarContent.innerHTML = STATE.sources.map((source) => `<div class="repo-list-item" data-source="${escapeHTML(source.slug)}"><span class="repo-name">${escapeHTML(source.name)}</span><span class="repo-count">${(source.count || 0).toLocaleString()}</span></div>`).join("");
516
+ return;
517
+ }
518
+ DOM.sidebarTitle.textContent = STATE.sourceMap[STATE.source]?.name || STATE.source;
519
+ const data = await API.getContents(STATE.source, "");
520
+ let html = '<div class="back-to-global" id="back-home">返回全局搜索</div>';
521
+ html += (data.folders || []).map((item) => `<div class="browser-item" data-folder-open="${escapeHTML(item.path)}">📁 <span class="browser-name">${escapeHTML(item.name)}</span><span class="browser-count">${(item.count || 0).toLocaleString()}</span></div>`).join("");
522
+ html += (data.files || []).map((item) => `<div class="browser-item" data-doc-id="${escapeHTML(item.doc_id)}">📄 <span class="browser-name">${escapeHTML(item.name)}.txt</span><span class="browser-size">${escapeHTML(formatSize(item.size))}</span></div>`).join("");
523
+ DOM.sidebarContent.innerHTML = html;
524
+ }
525
+
526
+ async function openFolder(path) {
527
+ const data = await API.getContents(STATE.source, path);
528
+ const parts = path ? path.split("/") : [];
529
+ let html = '<div class="back-to-global" id="back-home">返回全局搜索</div>';
530
+ html += `<div class="sidebar-breadcrumb"><span class="crumb-item" data-folder-nav="">根目录</span>${parts.map((part, index) => `<span class="crumb-sep">/</span><span class="crumb-item" data-folder-nav="${escapeHTML(parts.slice(0, index + 1).join("/"))}">${escapeHTML(part)}</span>`).join("")}</div>`;
531
+ html += (data.folders || []).map((item) => `<div class="browser-item" data-folder-open="${escapeHTML(item.path)}">📁 <span class="browser-name">${escapeHTML(item.name)}</span><span class="browser-count">${(item.count || 0).toLocaleString()}</span></div>`).join("");
532
+ html += (data.files || []).map((item) => `<div class="browser-item" data-doc-id="${escapeHTML(item.doc_id)}">📄 <span class="browser-name">${escapeHTML(item.name)}.txt</span><span class="browser-size">${escapeHTML(formatSize(item.size))}</span></div>`).join("");
533
+ DOM.sidebarContent.innerHTML = html;
534
+ }
535
+
536
+ function updateSidebarVisibility() {
537
+ DOM.leftSidebar.classList.toggle("collapsed", !STATE.leftSidebarOpen);
538
+ DOM.rightSidebar.classList.toggle("collapsed", !STATE.rightSidebarOpen);
539
+ DOM.leftSidebar.classList.toggle("open", STATE.leftSidebarOpen);
540
+ DOM.rightSidebar.classList.toggle("open", STATE.rightSidebarOpen);
541
+ DOM.overlay.style.display = (STATE.leftSidebarOpen || STATE.rightSidebarOpen) ? "" : "none";
542
+ }
543
+
544
+ function clearFilters() {
545
+ STATE.folderSelections = [];
546
+ STATE.selectedSources = [];
547
+ STATE.minSize = null;
548
+ STATE.maxSize = null;
549
+ DOM.filterMinSize.value = "";
550
+ DOM.filterMaxSize.value = "";
551
+ DOM.filterMinUnit.value = "MB";
552
+ DOM.filterMaxUnit.value = "MB";
553
+ renderSourcesFilter();
554
+ renderFolderTree();
555
+ doSearch();
556
+ }
557
+
558
+ async function randomDoc() {
559
+ const data = await API.random(STATE.source);
560
+ if (!data.doc_id) {
561
+ showToast("暂无可用文档");
562
+ return;
563
+ }
564
+ openPreview(data.doc_id, "");
565
+ }
566
+
567
+ function updateMultiUi() {
568
+ STATE.multiSelect = DOM.multiSelectToggle.checked;
569
+ DOM.multiActionBar.style.display = STATE.multiSelect ? "" : "none";
570
+ if (!STATE.multiSelect) STATE.selectedIds.clear();
571
+ DOM.multiSelectedCount.textContent = STATE.selectedIds.size ? `已选 ${STATE.selectedIds.size} 项` : "";
572
+ renderResults();
573
+ }
574
+
575
+ function attachEvents() {
576
+ DOM.headerLogo.addEventListener("click", (event) => {
577
+ if (window.location.pathname !== "/") return;
578
+ event.preventDefault();
579
+ window.location.href = DOM.headerLogo.href;
580
+ });
581
+ DOM.hamburgerBtn.addEventListener("click", () => {
582
+ STATE.leftSidebarOpen = !STATE.leftSidebarOpen;
583
+ updateSidebarVisibility();
584
+ });
585
+ DOM.settingsBtn.addEventListener("click", () => {
586
+ STATE.rightSidebarOpen = !STATE.rightSidebarOpen;
587
+ updateSidebarVisibility();
588
+ });
589
+ DOM.closeFiltersBtn.addEventListener("click", () => {
590
+ STATE.rightSidebarOpen = false;
591
+ updateSidebarVisibility();
592
+ });
593
+ DOM.overlay.addEventListener("click", () => {
594
+ STATE.leftSidebarOpen = false;
595
+ STATE.rightSidebarOpen = false;
596
+ updateSidebarVisibility();
597
+ });
598
+ DOM.themeBtn.addEventListener("click", () => {
599
+ STATE.isDark = !STATE.isDark;
600
+ applyTheme();
601
+ localStorage.setItem("theme", STATE.isDark ? "dark" : "light");
602
+ });
603
+ DOM.searchInput.addEventListener("focus", renderHistoryDropdown);
604
+ DOM.searchInput.addEventListener("blur", () => setTimeout(() => { DOM.searchHistoryDropdown.style.display = "none"; }, 100));
605
+ DOM.searchHistoryDropdown.addEventListener("click", (event) => {
606
+ const item = event.target.closest("[data-query]");
607
+ if (!item) return;
608
+ DOM.searchInput.value = item.dataset.query;
609
+ STATE.query = item.dataset.query;
610
+ doSearch();
611
+ });
612
+ let timer = null;
613
+ DOM.searchInput.addEventListener("input", () => {
614
+ clearTimeout(timer);
615
+ timer = setTimeout(() => {
616
+ STATE.query = DOM.searchInput.value.trim();
617
+ STATE.page = 1;
618
+ addHistory(STATE.query);
619
+ doSearch();
620
+ }, 250);
621
+ });
622
+ DOM.sortSelect.addEventListener("change", () => {
623
+ STATE.page = 1;
624
+ doSearch();
625
+ });
626
+ DOM.exactToggle.addEventListener("change", () => {
627
+ STATE.exact = DOM.exactToggle.checked;
628
+ STATE.page = 1;
629
+ doSearch();
630
+ });
631
+ DOM.historyToggle.addEventListener("change", () => {
632
+ STATE.historyEnabled = DOM.historyToggle.checked;
633
+ if (!STATE.historyEnabled) saveHistory([]);
634
+ });
635
+ DOM.fulltextToggle.addEventListener("change", async () => {
636
+ STATE.fulltext = DOM.fulltextToggle.checked;
637
+ DOM.searchInput.placeholder = STATE.fulltext ? "全文搜索 TXT 正文..." : "搜索 TXT 文件或路径...";
638
+ STATE.page = 1;
639
+ if (STATE.fulltext) await ensureFulltextLoaded();
640
+ doSearch();
641
+ });
642
+ DOM.clearFiltersBtn.addEventListener("click", clearFilters);
643
+ DOM.emptyRandomBtn.addEventListener("click", randomDoc);
644
+ DOM.filterMinSize.addEventListener("input", () => {
645
+ STATE.minSize = bytesFromInput(DOM.filterMinSize.value, DOM.filterMinUnit.value);
646
+ doSearch();
647
+ });
648
+ DOM.filterMaxSize.addEventListener("input", () => {
649
+ STATE.maxSize = bytesFromInput(DOM.filterMaxSize.value, DOM.filterMaxUnit.value);
650
+ doSearch();
651
+ });
652
+ DOM.filterMinUnit.addEventListener("change", () => {
653
+ STATE.minSize = bytesFromInput(DOM.filterMinSize.value, DOM.filterMinUnit.value);
654
+ doSearch();
655
+ });
656
+ DOM.filterMaxUnit.addEventListener("change", () => {
657
+ STATE.maxSize = bytesFromInput(DOM.filterMaxSize.value, DOM.filterMaxUnit.value);
658
+ doSearch();
659
+ });
660
+ DOM.folderSelectAll.addEventListener("click", () => {
661
+ const paths = [];
662
+ flattenNodes(STATE.folderTree, paths);
663
+ STATE.folderSelections = paths;
664
+ renderFolderTree();
665
+ doSearch();
666
+ });
667
+ DOM.folderDeselectAll.addEventListener("click", () => {
668
+ STATE.folderSelections = [];
669
+ renderFolderTree();
670
+ doSearch();
671
+ });
672
+ DOM.multiSelectToggle.addEventListener("change", updateMultiUi);
673
+ DOM.multiSelectAll.addEventListener("click", () => {
674
+ STATE.selectedIds = new Set(STATE.results.map((item) => item.doc_id));
675
+ updateMultiUi();
676
+ });
677
+ DOM.multiDeselect.addEventListener("click", () => {
678
+ STATE.selectedIds.clear();
679
+ updateMultiUi();
680
+ });
681
+ DOM.multiBatchDownload.addEventListener("click", () => {
682
+ const ids = Array.from(STATE.selectedIds);
683
+ if (!ids.length) {
684
+ showToast("未选中任何文件");
685
+ return;
686
+ }
687
+ ids.forEach((id, index) => setTimeout(() => { window.open(`/api/download/${encodeURIComponent(id)}`, "_blank"); }, index * 250));
688
+ });
689
+ DOM.multiZipDownload.addEventListener("click", async () => {
690
+ const ids = Array.from(STATE.selectedIds);
691
+ if (!ids.length) {
692
+ showToast("未选中任何文件");
693
+ return;
694
+ }
695
+ const resp = await fetch("/api/zip", {
696
+ method: "POST",
697
+ headers: { "Content-Type": "application/json" },
698
+ body: JSON.stringify({ doc_ids: ids }),
699
+ });
700
+ const blob = await resp.blob();
701
+ const link = document.createElement("a");
702
+ link.href = URL.createObjectURL(blob);
703
+ link.download = `vomebook_batch_${Date.now()}.zip`;
704
+ link.click();
705
+ URL.revokeObjectURL(link.href);
706
+ });
707
+ DOM.resultsList.addEventListener("click", async (event) => {
708
+ const checkbox = event.target.closest(".result-checkbox");
709
+ if (checkbox) {
710
+ const docId = checkbox.dataset.docId;
711
+ if (checkbox.checked) STATE.selectedIds.add(docId);
712
+ else STATE.selectedIds.delete(docId);
713
+ DOM.multiSelectedCount.textContent = STATE.selectedIds.size ? `已选 ${STATE.selectedIds.size} 项` : "";
714
+ return;
715
+ }
716
+ const action = event.target.closest("[data-action='preview']");
717
+ if (action) {
718
+ openPreview(action.dataset.docId, STATE.query);
719
+ return;
720
+ }
721
+ const folder = event.target.closest(".path-folder[data-folder]");
722
+ if (folder) {
723
+ const source = folder.dataset.source;
724
+ const path = folder.dataset.folder;
725
+ if (STATE.mode === "global" && source && !STATE.source) {
726
+ navigateToSource(source);
727
+ if (path) STATE.folderSelections = [path];
728
+ } else {
729
+ STATE.folderSelections = path ? [path] : [];
730
+ }
731
+ renderFolderTree();
732
+ doSearch();
733
+ }
734
+ });
735
+ DOM.sidebarContent.addEventListener("click", async (event) => {
736
+ const sourceItem = event.target.closest("[data-source]");
737
+ if (sourceItem) {
738
+ navigateToSource(sourceItem.dataset.source);
739
+ return;
740
+ }
741
+ const backHome = event.target.closest("#back-home");
742
+ if (backHome) {
743
+ navigateHome();
744
+ return;
745
+ }
746
+ const folderOpen = event.target.closest("[data-folder-open]");
747
+ if (folderOpen) {
748
+ openFolder(folderOpen.dataset.folderOpen);
749
+ return;
750
+ }
751
+ const folderNav = event.target.closest("[data-folder-nav]");
752
+ if (folderNav) {
753
+ openFolder(folderNav.dataset.folderNav);
754
+ return;
755
+ }
756
+ const docItem = event.target.closest("[data-doc-id]");
757
+ if (docItem) {
758
+ openPreview(docItem.dataset.docId, STATE.query);
759
+ }
760
+ });
761
+ DOM.resultsContainer.addEventListener("scroll", () => {
762
+ const nearBottom = DOM.resultsContainer.scrollTop + DOM.resultsContainer.clientHeight >= DOM.resultsContainer.scrollHeight - 200;
763
+ if (nearBottom && STATE.results.length < STATE.total && !STATE.isLoading) {
764
+ STATE.page += 1;
765
+ doSearch();
766
+ }
767
+ });
768
+ document.addEventListener("keydown", (event) => {
769
+ if (event.key === "/" && document.activeElement !== DOM.searchInput) {
770
+ event.preventDefault();
771
+ DOM.searchInput.focus();
772
+ DOM.searchInput.select();
773
+ }
774
+ });
775
+ window.addEventListener("popstate", async () => {
776
+ syncRoute();
777
+ await renderSidebar();
778
+ await renderFolderTree();
779
+ doSearch();
780
+ });
781
+ }
782
+
783
+ async function init() {
784
+ cacheDom();
785
+ STATE.isDark = localStorage.getItem("theme") !== "light";
786
+ applyTheme();
787
+ syncRoute();
788
+ STATE.sources = await API.getSources();
789
+ STATE.sourceMap = Object.fromEntries(STATE.sources.map((source) => [source.slug, source]));
790
+ await renderSourcesFilter();
791
+ await renderSidebar();
792
+ await renderFolderTree();
793
+ attachEvents();
794
+ updateSidebarVisibility();
795
+ doSearch();
796
+ }
797
+
798
+ document.addEventListener("DOMContentLoaded", init);
static/icons/icon-192.png ADDED
static/index.html ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <title>VOMEBOOK Search</title>
7
+ <link rel="manifest" href="/manifest.json">
8
+ <meta name="theme-color" content="#1a1c1e">
9
+ <link rel="apple-touch-icon" href="/icons/icon-192.png">
10
+ <link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
11
+ <link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png">
12
+ <link rel="stylesheet" href="/static/style.css">
13
+ </head>
14
+ <body class="dark">
15
+ <header id="header">
16
+ <div class="header-left">
17
+ <button id="hamburger-btn" class="icon-btn" aria-label="菜单">☰</button>
18
+ <a id="header-logo" class="header-logo" href="https://huggingface.co/VOMEBOOK" title="VOMEBOOK">
19
+ <span id="header-title">VOMEBOOK</span>
20
+ </a>
21
+ </div>
22
+ <div class="header-center">
23
+ <div class="search-box">
24
+ <span class="search-icon">⌕</span>
25
+ <input type="search" id="search-input" class="search-input" placeholder="搜索 TXT 文件或路径..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
26
+ <kbd class="search-kbd">/</kbd>
27
+ <div id="search-history-dropdown" class="search-history-dropdown" style="display:none"></div>
28
+ </div>
29
+ </div>
30
+ <div class="header-right">
31
+ <button id="settings-btn" class="icon-btn" aria-label="搜索设置">⚙</button>
32
+ <button id="theme-btn" class="icon-btn" aria-label="切换主题">◐</button>
33
+ </div>
34
+ </header>
35
+
36
+ <div id="app-body" class="app-body">
37
+ <aside id="left-sidebar" class="sidebar left-sidebar">
38
+ <div class="sidebar-header"><span id="sidebar-title">数据包</span></div>
39
+ <div id="sidebar-content" class="sidebar-content"><div class="sidebar-loading">加载中...</div></div>
40
+ </aside>
41
+
42
+ <main id="main-content" class="main-content">
43
+ <div id="status-bar" class="status-bar">
44
+ <div class="status-left">
45
+ <span id="result-count" class="result-count"></span>
46
+ <label class="multi-toggle" id="multi-toggle-label" style="display:none"><input type="checkbox" id="multi-select-toggle"><span>多选</span></label>
47
+ <span id="did-you-mean" class="did-you-mean"></span>
48
+ </div>
49
+ <div class="status-right">
50
+ <button id="clear-filters-btn" class="text-btn" style="display:none">清空筛选</button>
51
+ <div class="sort-group">
52
+ <span class="sort-label">排序:</span>
53
+ <select id="sort-select" class="sort-select">
54
+ <option value="relevance">相关度</option>
55
+ <option value="name">文件名</option>
56
+ <option value="size">文件大小</option>
57
+ </select>
58
+ </div>
59
+ </div>
60
+ </div>
61
+
62
+ <div id="multi-action-bar" class="multi-action-bar" style="display:none">
63
+ <button id="multi-select-all" class="multi-action-btn">全选</button>
64
+ <button id="multi-deselect" class="multi-action-btn">取消选择</button>
65
+ <button id="multi-batch-download" class="multi-action-btn primary">批量下载</button>
66
+ <button id="multi-zip-download" class="multi-action-btn primary">合并下载</button>
67
+ <span id="multi-selected-count" class="multi-selected-count"></span>
68
+ </div>
69
+
70
+ <div id="results-container" class="results-container">
71
+ <div id="results-list" class="results-list"></div>
72
+ <div id="results-loading" class="loading-spinner" style="display:none"><div class="spinner"></div></div>
73
+ <div id="empty-state" class="empty-state" style="display:none">
74
+ <div class="empty-title">没有找到结果</div>
75
+ <div id="empty-desc" class="empty-desc">试试其他关键词</div>
76
+ <button id="empty-random-btn" class="random-big-btn">随机来一篇</button>
77
+ </div>
78
+ </div>
79
+
80
+ <div id="load-info" class="load-info" style="display:none">已加载 <span id="loaded-count">0</span> / <span id="total-count">0</span> 条</div>
81
+ <div id="preview-panel" class="preview-panel" style="display:none"></div>
82
+ </main>
83
+
84
+ <aside id="right-sidebar" class="sidebar right-sidebar">
85
+ <div class="sidebar-header"><span>搜索过滤</span><button id="close-filters-btn" class="icon-btn-sm" aria-label="关闭过滤">×</button></div>
86
+ <div class="sidebar-content">
87
+ <div class="filter-section">
88
+ <label class="toggle-row">
89
+ <span class="toggle-label">全文搜索</span>
90
+ <input type="checkbox" id="fulltext-toggle">
91
+ <span class="toggle-switch"></span>
92
+ </label>
93
+ </div>
94
+ <div class="filter-section">
95
+ <label class="toggle-row">
96
+ <span class="toggle-label">记录搜索历史</span>
97
+ <input type="checkbox" id="history-toggle" checked>
98
+ <span class="toggle-switch"></span>
99
+ </label>
100
+ </div>
101
+ <div class="filter-section">
102
+ <label class="toggle-row">
103
+ <span class="toggle-label">精准搜索</span>
104
+ <input type="checkbox" id="exact-search-toggle">
105
+ <span class="toggle-switch"></span>
106
+ </label>
107
+ </div>
108
+ <div id="filter-source-section" class="filter-section">
109
+ <div class="filter-section-title">数据包过滤</div>
110
+ <div id="filter-source-list" class="filter-checkbox-list"></div>
111
+ </div>
112
+ <div id="filter-folder-section" class="filter-section">
113
+ <div class="filter-section-title"><span>路径过滤</span><span class="filter-actions"><button id="folder-select-all" class="text-btn-sm">全选</button><button id="folder-deselect-all" class="text-btn-sm">反选</button></span></div>
114
+ <div id="filter-folder-tree" class="filter-folder-tree"></div>
115
+ </div>
116
+ <div class="filter-section">
117
+ <div class="filter-section-title">文件大小</div>
118
+ <div class="filter-size-row">
119
+ <input type="number" id="filter-min-size" class="size-input" placeholder="最小" min="0" step="0.1">
120
+ <select id="filter-min-unit" class="size-unit"><option value="B">B</option><option value="KB">KB</option><option value="MB" selected>MB</option><option value="GB">GB</option></select>
121
+ <span class="size-sep">—</span>
122
+ <input type="number" id="filter-max-size" class="size-input" placeholder="最大" min="0" step="0.1">
123
+ <select id="filter-max-unit" class="size-unit"><option value="B">B</option><option value="KB">KB</option><option value="MB" selected>MB</option><option value="GB">GB</option></select>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ </aside>
128
+ </div>
129
+
130
+ <div id="overlay" class="overlay" style="display:none"></div>
131
+ <div id="toast" class="toast" style="display:none"></div>
132
+ <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { scope: "/" }); }</script>
133
+ <script src="/static/app.js"></script>
134
+ </body>
135
+ </html>
static/manifest.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "VOMEBOOK Search",
3
+ "short_name": "VOMEBOOK",
4
+ "description": "TXT 搜索与全文检索空间",
5
+ "start_url": "/",
6
+ "scope": "/",
7
+ "display": "standalone",
8
+ "orientation": "any",
9
+ "theme_color": "#1a1c1e",
10
+ "background_color": "#1a1c1e",
11
+ "categories": ["books", "education", "search"],
12
+ "lang": "zh-CN",
13
+ "icons": [
14
+ {
15
+ "src": "/icons/icon-192.png",
16
+ "sizes": "192x192",
17
+ "type": "image/png",
18
+ "purpose": "any maskable"
19
+ },
20
+ {
21
+ "src": "/icons/icon-512.png",
22
+ "sizes": "512x512",
23
+ "type": "image/png",
24
+ "purpose": "any maskable"
25
+ }
26
+ ]
27
+ }
static/style.css ADDED
@@ -0,0 +1,1650 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════════════════
2
+ VoiceOfML Search — MD3 Design System
3
+ ═══════════════════════════════════════════════════════════ */
4
+
5
+ /* ── CSS Variables (Dark Theme — Default) ────────────── */
6
+ :root {
7
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
8
+ --font-mono: "SF Mono", "Cascadia Code", "Consolas", "Menlo", monospace;
9
+
10
+ --header-h: 48px;
11
+
12
+ --surface: #1a1c1e;
13
+ --surface-variant: #282a2d;
14
+ --surface-container: #1e2022;
15
+ --on-surface: #e2e2e6;
16
+ --on-surface-variant: #c4c6cf;
17
+
18
+ --primary: #8ab4f8;
19
+ --on-primary: #003258;
20
+ --primary-container: #004a7c;
21
+ --on-primary-container: #d1e4ff;
22
+
23
+ --secondary: #bcc7db;
24
+ --on-secondary: #263141;
25
+ --secondary-container: #3c4758;
26
+ --on-secondary-container: #d8e3f8;
27
+
28
+ --outline: #8d9099;
29
+ --outline-variant: #43474e;
30
+
31
+ --error: #f2b8b5;
32
+ --on-error: #601410;
33
+
34
+ --radius-sm: 6px;
35
+ --radius-md: 12px;
36
+ --radius-lg: 16px;
37
+ --radius-full: 999px;
38
+
39
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
40
+ --shadow-md: 0 2px 8px rgba(0,0,0,0.4);
41
+ --shadow-lg: 0 4px 16px rgba(0,0,0,0.5);
42
+
43
+ --transition: 150ms ease;
44
+
45
+ --sidebar-w: 280px;
46
+ --sidebar-expanded-w: 100vw;
47
+
48
+ color-scheme: dark;
49
+ }
50
+
51
+ /* ── Light Theme ─────────────────────────────────────── */
52
+ body.light {
53
+ --surface: #fdfbff;
54
+ --surface-variant: #f0eff4;
55
+ --surface-container: #f5f3f7;
56
+ --on-surface: #1a1c1e;
57
+ --on-surface-variant: #44474e;
58
+
59
+ --primary: #1a6db5;
60
+ --on-primary: #ffffff;
61
+ --primary-container: #d1e4ff;
62
+ --on-primary-container: #001d36;
63
+
64
+ --secondary: #525f70;
65
+ --on-secondary: #ffffff;
66
+ --secondary-container: #d8e3f8;
67
+ --on-secondary-container: #121c29;
68
+
69
+ --outline: #74777f;
70
+ --outline-variant: #c4c6cf;
71
+
72
+ --error: #ba1a1a;
73
+ --on-error: #ffffff;
74
+
75
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.08);
76
+ --shadow-md: 0 2px 8px rgba(0,0,0,0.12);
77
+ --shadow-lg: 0 4px 16px rgba(0,0,0,0.16);
78
+
79
+ color-scheme: light;
80
+ }
81
+
82
+ /* ── Reset & Base ────────────────────────────────────── */
83
+ *, *::before, *::after {
84
+ box-sizing: border-box;
85
+ margin: 0;
86
+ padding: 0;
87
+ }
88
+
89
+ html, body {
90
+ height: 100%;
91
+ overflow: hidden;
92
+ }
93
+
94
+ body {
95
+ font-family: var(--font-sans);
96
+ font-size: 14px;
97
+ line-height: 1.5;
98
+ color: var(--on-surface);
99
+ background: var(--surface);
100
+ display: flex;
101
+ flex-direction: column;
102
+ -webkit-font-smoothing: antialiased;
103
+ -moz-osx-font-smoothing: grayscale;
104
+ }
105
+
106
+ :focus-visible {
107
+ outline: 2px solid var(--primary);
108
+ outline-offset: 2px;
109
+ }
110
+ .icon-btn:focus-visible,
111
+ .icon-btn-sm:focus-visible,
112
+ .text-btn:focus-visible,
113
+ .text-btn-sm:focus-visible,
114
+ .browser-item:focus-visible,
115
+ .repo-list-item:focus-visible,
116
+ .result-action-btn:focus-visible,
117
+ .random-book-btn:focus-visible,
118
+ .random-big-btn:focus-visible,
119
+ .filter-checkbox-item:focus-visible,
120
+ .back-to-global:focus-visible,
121
+ .sidebar-breadcrumb .crumb-item:focus-visible,
122
+ input:focus-visible,
123
+ select:focus-visible {
124
+ outline: none;
125
+ }
126
+
127
+ a {
128
+ color: var(--primary);
129
+ text-decoration: none;
130
+ }
131
+ a:hover, a:focus-visible {
132
+ text-decoration: underline;
133
+ }
134
+
135
+ button {
136
+ font-family: inherit;
137
+ font-size: inherit;
138
+ cursor: pointer;
139
+ border: none;
140
+ background: none;
141
+ color: inherit;
142
+ }
143
+
144
+ input, select {
145
+ font-family: inherit;
146
+ font-size: inherit;
147
+ color: var(--on-surface);
148
+ background: none;
149
+ border: none;
150
+ outline: none;
151
+ }
152
+
153
+ ul, li {
154
+ list-style: none;
155
+ }
156
+
157
+ /* ── Scrollbar ───────────────────────────────────────── */
158
+ ::-webkit-scrollbar {
159
+ width: 6px;
160
+ height: 6px;
161
+ }
162
+ ::-webkit-scrollbar-track {
163
+ background: transparent;
164
+ }
165
+ ::-webkit-scrollbar-thumb {
166
+ background: var(--outline-variant);
167
+ border-radius: 3px;
168
+ }
169
+ ::-webkit-scrollbar-thumb:hover {
170
+ background: var(--outline);
171
+ }
172
+
173
+ /* ═══════════════════════════════════════════════════════════
174
+ Header
175
+ ═══════════════════════════════════════════════════════════ */
176
+
177
+ #header {
178
+ height: var(--header-h);
179
+ display: flex;
180
+ align-items: center;
181
+ gap: 8px;
182
+ padding: 0 8px;
183
+ background: var(--surface-container);
184
+ border-bottom: 1px solid var(--outline-variant);
185
+ z-index: 100;
186
+ flex-shrink: 0;
187
+ }
188
+
189
+ .header-left {
190
+ display: flex;
191
+ align-items: center;
192
+ gap: 4px;
193
+ flex-shrink: 0;
194
+ }
195
+
196
+ .header-logo {
197
+ display: flex;
198
+ align-items: center;
199
+ gap: 6px;
200
+ color: var(--on-surface);
201
+ font-weight: 600;
202
+ font-size: 15px;
203
+ padding: 4px 8px;
204
+ border-radius: var(--radius-sm);
205
+ transition: background var(--transition);
206
+ text-decoration: none;
207
+ }
208
+ .header-logo:hover, .header-logo:focus-visible {
209
+ background: var(--surface-variant);
210
+ text-decoration: none;
211
+ }
212
+
213
+ .header-center {
214
+ flex: 1;
215
+ display: flex;
216
+ justify-content: center;
217
+ min-width: 0;
218
+ }
219
+
220
+ .search-box {
221
+ position: relative;
222
+ display: flex;
223
+ align-items: center;
224
+ width: 100%;
225
+ max-width: 680px;
226
+ height: 36px;
227
+ background: var(--surface);
228
+ border: 1px solid var(--outline-variant);
229
+ border-radius: var(--radius-full);
230
+ transition: all var(--transition);
231
+ }
232
+ .search-box:focus-within {
233
+ border-color: var(--primary);
234
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 25%, transparent);
235
+ }
236
+
237
+ .search-icon {
238
+ flex-shrink: 0;
239
+ margin-left: 12px;
240
+ color: var(--on-surface-variant);
241
+ }
242
+
243
+ .search-input {
244
+ flex: 1;
245
+ height: 100%;
246
+ padding: 0 8px;
247
+ font-size: 14px;
248
+ background: transparent;
249
+ min-width: 0;
250
+ }
251
+ .search-input::placeholder {
252
+ color: var(--on-surface-variant);
253
+ opacity: 0.6;
254
+ }
255
+
256
+ .search-kbd {
257
+ flex-shrink: 0;
258
+ margin-right: 8px;
259
+ font-size: 10px;
260
+ padding: 2px 6px;
261
+ border-radius: 4px;
262
+ border: 1px solid var(--outline-variant);
263
+ color: var(--on-surface-variant);
264
+ opacity: 0.7;
265
+ font-family: var(--font-mono);
266
+ }
267
+
268
+ .search-history-dropdown {
269
+ position: absolute;
270
+ top: 100%;
271
+ left: 0;
272
+ right: 0;
273
+ margin-top: 4px;
274
+ background: var(--surface);
275
+ border: 1px solid var(--outline-variant);
276
+ border-radius: var(--radius-md);
277
+ box-shadow: 0 8px 24px rgba(0,0,0,0.3);
278
+ z-index: 100;
279
+ max-height: 320px;
280
+ overflow-y: auto;
281
+ padding: 4px 0;
282
+ }
283
+ .search-history-dropdown .history-item {
284
+ display: flex;
285
+ align-items: center;
286
+ gap: 8px;
287
+ padding: 8px 12px;
288
+ cursor: pointer;
289
+ font-size: 13px;
290
+ color: var(--on-surface);
291
+ transition: background 0.1s;
292
+ }
293
+ .search-history-dropdown .history-item:hover {
294
+ background: var(--surface-variant);
295
+ }
296
+ .search-history-dropdown .history-icon {
297
+ flex-shrink: 0;
298
+ color: var(--on-surface-variant);
299
+ opacity: 0.5;
300
+ }
301
+ .search-history-dropdown .history-text {
302
+ flex: 1;
303
+ overflow: hidden;
304
+ text-overflow: ellipsis;
305
+ white-space: nowrap;
306
+ }
307
+ .search-history-dropdown .history-del {
308
+ flex-shrink: 0;
309
+ background: none;
310
+ border: none;
311
+ color: var(--on-surface-variant);
312
+ font-size: 16px;
313
+ cursor: pointer;
314
+ padding: 0 6px;
315
+ opacity: 0.5;
316
+ }
317
+ .search-history-dropdown .history-del:hover {
318
+ opacity: 1;
319
+ color: var(--error);
320
+ }
321
+ .search-history-dropdown .history-footer {
322
+ padding: 4px 12px;
323
+ border-top: 1px solid var(--outline-variant);
324
+ text-align: center;
325
+ }
326
+ .search-history-dropdown .history-clear-all {
327
+ background: none;
328
+ border: none;
329
+ color: var(--on-surface-variant);
330
+ font-size: 12px;
331
+ cursor: pointer;
332
+ opacity: 0.6;
333
+ }
334
+ .search-history-dropdown .history-clear-all:hover {
335
+ opacity: 1;
336
+ }
337
+
338
+ .header-right {
339
+ display: flex;
340
+ align-items: center;
341
+ gap: 2px;
342
+ flex-shrink: 0;
343
+ }
344
+
345
+ /* Icon Buttons */
346
+ .icon-btn {
347
+ width: 36px;
348
+ height: 36px;
349
+ display: flex;
350
+ align-items: center;
351
+ justify-content: center;
352
+ border-radius: var(--radius-full);
353
+ color: var(--on-surface-variant);
354
+ transition: all var(--transition);
355
+ }
356
+ .icon-btn:hover, .icon-btn:focus-visible {
357
+ background: var(--surface-variant);
358
+ color: var(--on-surface);
359
+ }
360
+
361
+ .icon-btn-sm {
362
+ width: 28px;
363
+ height: 28px;
364
+ display: flex;
365
+ align-items: center;
366
+ justify-content: center;
367
+ border-radius: var(--radius-full);
368
+ color: var(--on-surface-variant);
369
+ font-size: 18px;
370
+ transition: all var(--transition);
371
+ }
372
+ .icon-btn-sm:hover, .icon-btn-sm:focus-visible {
373
+ background: var(--surface-variant);
374
+ color: var(--on-surface);
375
+ }
376
+
377
+ .text-btn {
378
+ font-size: 12px;
379
+ color: var(--primary);
380
+ padding: 4px 10px;
381
+ border-radius: var(--radius-sm);
382
+ transition: background var(--transition);
383
+ }
384
+ .text-btn:hover, .text-btn:focus-visible {
385
+ background: var(--surface-variant);
386
+ }
387
+
388
+ .text-btn-sm {
389
+ font-size: 11px;
390
+ color: var(--primary);
391
+ padding: 2px 6px;
392
+ border-radius: 4px;
393
+ transition: background var(--transition);
394
+ }
395
+ .text-btn-sm:hover, .text-btn-sm:focus-visible {
396
+ background: var(--surface-variant);
397
+ }
398
+
399
+ .preview-panel {
400
+ margin: 12px 16px 20px;
401
+ background: var(--surface-container);
402
+ border: 1px solid var(--outline-variant);
403
+ border-radius: var(--radius-md);
404
+ overflow: hidden;
405
+ }
406
+
407
+ .preview-header {
408
+ padding: 12px 14px;
409
+ border-bottom: 1px solid var(--outline-variant);
410
+ }
411
+
412
+ .preview-title {
413
+ font-size: 15px;
414
+ font-weight: 600;
415
+ }
416
+
417
+ .preview-path {
418
+ font-size: 12px;
419
+ opacity: 0.7;
420
+ margin-top: 4px;
421
+ word-break: break-all;
422
+ }
423
+
424
+ .preview-body {
425
+ margin: 0;
426
+ padding: 14px;
427
+ max-height: 50vh;
428
+ overflow: auto;
429
+ white-space: pre-wrap;
430
+ word-break: break-word;
431
+ font-family: var(--font-sans);
432
+ line-height: 1.75;
433
+ }
434
+
435
+ .result-snippet {
436
+ margin-top: 6px;
437
+ font-size: 12px;
438
+ opacity: 0.85;
439
+ display: -webkit-box;
440
+ -webkit-line-clamp: 3;
441
+ -webkit-box-orient: vertical;
442
+ overflow: hidden;
443
+ }
444
+
445
+ /* ═══════════════════════════════════════════════════════════
446
+ Layout
447
+ ═══════════════════════════════════════════════════════════ */
448
+
449
+ .app-body {
450
+ display: flex;
451
+ flex: 1;
452
+ min-height: 0;
453
+ overflow: hidden;
454
+ }
455
+
456
+ /* ── Sidebar ─────────────────────────────────────────── */
457
+ .sidebar {
458
+ width: var(--sidebar-w);
459
+ flex-shrink: 0;
460
+ display: flex;
461
+ flex-direction: column;
462
+ border-right: 1px solid var(--outline-variant);
463
+ background: var(--surface-container);
464
+ transition: width var(--transition), opacity var(--transition);
465
+ overflow: hidden;
466
+ }
467
+ .sidebar.collapsed {
468
+ width: 0 !important;
469
+ border-right: none;
470
+ }
471
+ .right-sidebar {
472
+ border-right: none;
473
+ border-left: 1px solid var(--outline-variant);
474
+ }
475
+ .right-sidebar.collapsed {
476
+ border-left: none;
477
+ }
478
+
479
+ .sidebar-header {
480
+ display: flex;
481
+ align-items: center;
482
+ justify-content: space-between;
483
+ padding: 10px 12px;
484
+ font-size: 12px;
485
+ height: 36px;
486
+ min-height: 36px;
487
+ font-weight: 600;
488
+ color: var(--on-surface-variant);
489
+ text-transform: uppercase;
490
+ letter-spacing: 0.5px;
491
+ border-bottom: 1px solid var(--outline-variant);
492
+ flex-shrink: 0;
493
+ }
494
+
495
+ .sidebar-content {
496
+ flex: 1;
497
+ overflow-y: auto;
498
+ overflow-x: hidden;
499
+ padding: 0;
500
+ }
501
+
502
+ .sidebar-loading {
503
+ padding: 16px;
504
+ text-align: center;
505
+ font-size: 13px;
506
+ color: var(--on-surface-variant);
507
+ opacity: 0.7;
508
+ }
509
+ .sidebar.expanded-wide {
510
+ width: var(--sidebar-expanded-w) !important;
511
+ }
512
+
513
+ /* ── Breadcrumb (Left Sidebar) ───────────────────────── */
514
+ .sidebar-breadcrumb {
515
+ display: flex;
516
+ align-items: center;
517
+ gap: 2px;
518
+ padding: 6px 8px;
519
+ border-bottom: 1px solid var(--outline-variant);
520
+ flex-wrap: wrap;
521
+ font-size: 12px;
522
+ flex-shrink: 0;
523
+ min-height: 32px;
524
+ }
525
+
526
+ .sidebar-breadcrumb .crumb-item {
527
+ color: var(--primary);
528
+ cursor: pointer;
529
+ padding: 2px 4px;
530
+ border-radius: 4px;
531
+ white-space: nowrap;
532
+ transition: background var(--transition);
533
+ }
534
+ .sidebar-breadcrumb .crumb-item:hover,
535
+ .sidebar-breadcrumb .crumb-item:focus-visible {
536
+ background: var(--surface-variant);
537
+ }
538
+
539
+ .sidebar-breadcrumb .crumb-item.current {
540
+ color: var(--on-surface-variant);
541
+ cursor: default;
542
+ font-weight: 500;
543
+ }
544
+ .sidebar-breadcrumb .crumb-item.current:hover,
545
+ .sidebar-breadcrumb .crumb-item.current:focus-visible {
546
+ background: none;
547
+ }
548
+
549
+ .sidebar-breadcrumb .crumb-sep {
550
+ color: var(--outline);
551
+ flex-shrink: 0;
552
+ }
553
+
554
+ /* Back to global button */
555
+ .back-to-global {
556
+ display: flex;
557
+ align-items: center;
558
+ gap: 6px;
559
+ padding: 0 12px;
560
+ height: 40px;
561
+ font-size: 13px;
562
+ color: var(--primary);
563
+ cursor: pointer;
564
+ border-bottom: 1px solid var(--outline-variant);
565
+ transition: background var(--transition);
566
+ flex-shrink: 0;
567
+ box-sizing: border-box;
568
+ }
569
+ .back-to-global:hover, .back-to-global:focus-visible {
570
+ background: var(--surface-variant);
571
+ }
572
+
573
+ /* ── Sidebar Browser Items (Folder + File) ───────────── */
574
+ .browser-list {
575
+ flex: 1;
576
+ overflow-y: auto;
577
+ }
578
+
579
+ .browser-item {
580
+ display: flex;
581
+ align-items: center;
582
+ gap: 8px;
583
+ padding: 7px 12px;
584
+ cursor: pointer;
585
+ transition: background var(--transition);
586
+ font-size: 13px;
587
+ color: var(--on-surface);
588
+ }
589
+ .browser-item:hover, .browser-item:focus-visible {
590
+ background: var(--surface-variant);
591
+ }
592
+
593
+ /* 默认:无条纹 */
594
+ .browser-item:nth-child(even) {
595
+ background: transparent;
596
+ }
597
+
598
+ /* 展开后:显示条纹 */
599
+ .left-sidebar.expanded-wide .browser-item:nth-child(even) {
600
+ background: rgba(255, 255, 255, 0.04);
601
+ }
602
+
603
+ body.light .left-sidebar.expanded-wide .browser-item:nth-child(even) {
604
+ background: rgba(0, 0, 0, 0.04);
605
+ }
606
+
607
+ .left-sidebar.expanded-wide .browser-item:nth-child(even):hover,
608
+ .left-sidebar.expanded-wide .browser-item:nth-child(even):focus-visible {
609
+ background: var(--surface-variant);
610
+ }
611
+
612
+ .browser-item .browser-icon {
613
+ width: 18px;
614
+ height: 18px;
615
+ flex-shrink: 0;
616
+ color: var(--on-surface-variant);
617
+ }
618
+
619
+ .browser-item .browser-name {
620
+ flex: 1;
621
+ min-width: 0;
622
+ overflow: hidden;
623
+ text-overflow: ellipsis;
624
+ white-space: nowrap;
625
+ }
626
+
627
+ .browser-item .browser-count {
628
+ font-size: 10px;
629
+ color: var(--on-surface-variant);
630
+ flex-shrink: 0;
631
+ }
632
+
633
+ .browser-item .browser-size {
634
+ font-size: 10px;
635
+ color: var(--on-surface-variant);
636
+ flex-shrink: 0;
637
+ margin-right: 4px;
638
+ }
639
+
640
+ .browser-item .browser-action {
641
+ font-size: 10px;
642
+ padding: 2px 6px;
643
+ border-radius: 4px;
644
+ color: var(--primary);
645
+ border: 1px solid var(--primary);
646
+ flex-shrink: 0;
647
+ transition: all var(--transition);
648
+ white-space: nowrap;
649
+ }
650
+ .browser-item .browser-action:hover,
651
+ .browser-item .browser-action:focus-visible {
652
+ background: var(--primary-container);
653
+ color: var(--on-primary-container);
654
+ }
655
+
656
+ /* ── Main Content ────────────────────────────────────── */
657
+ .main-content {
658
+ flex: 1;
659
+ display: flex;
660
+ flex-direction: column;
661
+ min-width: 0;
662
+ position: relative;
663
+ overflow: hidden;
664
+ }
665
+
666
+ /* ═══════════════════════════════════════════════════════════
667
+ Repo List (Left Sidebar — Global Mode)
668
+ ═══════════════════════════════════════════════════════════ */
669
+
670
+ .repo-list-item {
671
+ display: flex;
672
+ align-items: center;
673
+ gap: 10px;
674
+ padding: 8px 12px;
675
+ cursor: pointer;
676
+ transition: background var(--transition);
677
+ }
678
+ .repo-list-item:hover, .repo-list-item:focus-visible {
679
+ background: var(--surface-variant);
680
+ }
681
+ .repo-list-item.active {
682
+ background: var(--secondary-container);
683
+ }
684
+
685
+ .repo-list-item .repo-icon {
686
+ width: 18px;
687
+ height: 18px;
688
+ color: var(--on-surface-variant);
689
+ flex-shrink: 0;
690
+ }
691
+
692
+ .repo-list-item .repo-name {
693
+ flex: 1;
694
+ font-size: 13px;
695
+ font-weight: 500;
696
+ color: var(--on-surface);
697
+ min-width: 0;
698
+ overflow: hidden;
699
+ text-overflow: ellipsis;
700
+ white-space: nowrap;
701
+ }
702
+
703
+ .repo-list-item .repo-count {
704
+ font-size: 11px;
705
+ color: var(--on-surface-variant);
706
+ flex-shrink: 0;
707
+ }
708
+
709
+ /* ═══════════════════════════════════════════════════════════
710
+ Status Bar
711
+ ═══════════════════════════════════════════════════════════ */
712
+
713
+ .status-bar {
714
+ display: flex;
715
+ align-items: center;
716
+ justify-content: space-between;
717
+ padding: 6px 16px;
718
+ border-bottom: 1px solid var(--outline-variant);
719
+ flex-shrink: 0;
720
+ min-height: 36px;
721
+ height: 36px;
722
+ }
723
+
724
+ .status-left {
725
+ display: flex;
726
+ align-items: center;
727
+ gap: 6px;
728
+ font-size: 12px;
729
+ color: var(--on-surface-variant);
730
+ }
731
+
732
+ .status-right {
733
+ display: flex;
734
+ align-items: center;
735
+ gap: 8px;
736
+ }
737
+
738
+ body.mobile .status-bar {
739
+ padding: 4px 8px;
740
+ gap: 4px;
741
+ }
742
+ body.mobile .status-left { gap: 4px; }
743
+ body.mobile .status-right { gap: 4px; }
744
+ body.mobile .multi-toggle { padding: 1px 4px; font-size: 11px; }
745
+
746
+ .sort-group {
747
+ display: flex;
748
+ align-items: center;
749
+ gap: 4px;
750
+ font-size: 12px;
751
+ color: var(--on-surface-variant);
752
+ }
753
+
754
+ .sort-select {
755
+ font-size: 12px;
756
+ padding: 2px 6px;
757
+ border: 1px solid var(--outline-variant);
758
+ border-radius: var(--radius-sm);
759
+ background: var(--surface);
760
+ color: var(--on-surface);
761
+ cursor: pointer;
762
+ }
763
+
764
+ .did-you-mean {
765
+ font-size: 12px;
766
+ color: var(--primary);
767
+ cursor: pointer;
768
+ }
769
+ .did-you-mean:hover, .did-you-mean:focus-visible {
770
+ text-decoration: underline;
771
+ }
772
+
773
+ /* ═══════════════════════════════════════════════════════════
774
+ Results
775
+ ═══════════════════════════════════════════════════════════ */
776
+
777
+ .results-container {
778
+ flex: 1;
779
+ overflow-y: auto;
780
+ overflow-x: hidden;
781
+ }
782
+
783
+ /* ── Result Item ─────────────────────────────────────── */
784
+ .result-item {
785
+ display: flex;
786
+ gap: 10px;
787
+ padding: 10px 16px;
788
+ border-bottom: 1px solid var(--outline-variant);
789
+ transition: background var(--transition);
790
+ content-visibility: auto;
791
+ contain-intrinsic-size: auto 60px;
792
+ }
793
+ .result-item:nth-child(even) {
794
+ background: var(--surface-variant);
795
+ }
796
+ .result-item:nth-child(even):hover,
797
+ .result-item:nth-child(even):focus-visible {
798
+ background: var(--secondary-container);
799
+ }
800
+ .result-item:hover, .result-item:focus-visible {
801
+ background: var(--surface-variant);
802
+ }
803
+
804
+ .result-file-icon {
805
+ width: 24px;
806
+ height: 24px;
807
+ flex-shrink: 0;
808
+ margin-top: 2px;
809
+ color: var(--on-surface-variant);
810
+ display: flex;
811
+ align-items: center;
812
+ justify-content: center;
813
+ }
814
+ .result-file-icon svg {
815
+ width: 22px;
816
+ height: 22px;
817
+ }
818
+
819
+ .result-info {
820
+ flex: 1;
821
+ min-width: 0;
822
+ }
823
+
824
+ .result-title {
825
+ font-size: 14px;
826
+ font-weight: 500;
827
+ color: var(--on-surface);
828
+ word-break: break-all;
829
+ line-height: 1.4;
830
+ }
831
+
832
+ .result-title mark {
833
+ background: var(--primary-container);
834
+ color: var(--on-primary-container);
835
+ border-radius: 2px;
836
+ padding: 0 1px;
837
+ }
838
+
839
+ .result-path {
840
+ font-size: 11px;
841
+ color: var(--on-surface-variant);
842
+ margin-top: 3px;
843
+ font-family: var(--font-mono);
844
+ word-break: break-all;
845
+ display: flex;
846
+ align-items: center;
847
+ gap: 2px;
848
+ flex-wrap: wrap;
849
+ line-height: 1.5;
850
+ }
851
+
852
+ .result-path .path-sep {
853
+ color: var(--outline);
854
+ margin: 0 2px;
855
+ user-select: none;
856
+ }
857
+
858
+ .result-path .path-folder {
859
+ cursor: pointer;
860
+ color: var(--secondary);
861
+ transition: color var(--transition);
862
+ }
863
+ .result-path .path-folder:hover,
864
+ .result-path .path-folder:focus-visible {
865
+ color: var(--primary);
866
+ text-decoration: underline;
867
+ }
868
+
869
+ .result-preview {
870
+ font-size: 12px;
871
+ color: var(--on-surface-variant);
872
+ margin-top: 4px;
873
+ line-height: 1.5;
874
+ max-height: 3em;
875
+ overflow: hidden;
876
+ font-family: var(--font-mono);
877
+ }
878
+
879
+ .result-preview mark {
880
+ background: var(--primary-container);
881
+ color: var(--on-primary-container);
882
+ border-radius: 2px;
883
+ padding: 0 1px;
884
+ }
885
+
886
+ .result-meta {
887
+ display: flex;
888
+ align-items: center;
889
+ gap: 8px;
890
+ margin-top: 4px;
891
+ flex-wrap: wrap;
892
+ }
893
+
894
+ .result-repo-tag {
895
+ font-size: 10px;
896
+ padding: 2px 8px;
897
+ border-radius: 10px;
898
+ background: var(--primary-container);
899
+ color: var(--on-primary-container);
900
+ cursor: pointer;
901
+ font-weight: 500;
902
+ transition: opacity var(--transition);
903
+ }
904
+ .result-repo-tag:hover, .result-repo-tag:focus-visible {
905
+ opacity: 0.8;
906
+ }
907
+
908
+ .result-size {
909
+ font-size: 11px;
910
+ color: var(--on-surface-variant);
911
+ }
912
+
913
+ .result-actions {
914
+ display: flex;
915
+ gap: 4px;
916
+ flex-shrink: 0;
917
+ flex-wrap: wrap;
918
+ align-items: flex-start;
919
+ }
920
+
921
+ .result-action-btn {
922
+ font-size: 11px;
923
+ padding: 3px 8px;
924
+ border-radius: var(--radius-sm);
925
+ color: var(--on-surface-variant);
926
+ border: 1px solid var(--outline-variant);
927
+ white-space: nowrap;
928
+ transition: all var(--transition);
929
+ }
930
+ .result-action-btn:hover, .result-action-btn:focus-visible {
931
+ background: var(--surface-variant);
932
+ border-color: var(--outline);
933
+ }
934
+ .result-action-btn.primary {
935
+ color: var(--primary);
936
+ border-color: var(--primary);
937
+ }
938
+ .result-action-btn.primary:hover,
939
+ .result-action-btn.primary:focus-visible {
940
+ background: var(--primary-container);
941
+ border-color: var(--primary);
942
+ color: var(--on-primary-container);
943
+ }
944
+
945
+ /* ═══════════════════════════════════════════════════════════
946
+ Load Info
947
+ ═══════════════════════════════════════════════════════════ */
948
+
949
+ .load-info {
950
+ text-align: center;
951
+ padding: 12px;
952
+ font-size: 12px;
953
+ color: var(--on-surface-variant);
954
+ border-top: 1px solid var(--outline-variant);
955
+ flex-shrink: 0;
956
+ position: relative;
957
+ }
958
+
959
+ .mobile-selected-count {
960
+ display: none;
961
+ color: var(--on-surface-variant);
962
+ white-space: nowrap;
963
+ flex-shrink: 0;
964
+ }
965
+
966
+ body.mobile .load-info {
967
+ text-align: center;
968
+ }
969
+
970
+ body.mobile .mobile-selected-count {
971
+ position: absolute;
972
+ left: 12px;
973
+ top: 50%;
974
+ transform: translateY(-50%);
975
+ }
976
+
977
+ /* ═══════════════════════════════════════════════════════════
978
+ Quick Scroll Track
979
+ ═══════════════════════════════════════════════════════════ */
980
+
981
+ .scroll-track {
982
+ position: absolute;
983
+ right: 2px;
984
+ top: 40px;
985
+ bottom: 50px;
986
+ width: 8px;
987
+ opacity: 0;
988
+ transition: opacity 0.3s;
989
+ pointer-events: none;
990
+ }
991
+ .scroll-track.visible {
992
+ opacity: 1;
993
+ pointer-events: auto;
994
+ }
995
+ .scroll-thumb {
996
+ position: absolute;
997
+ width: 6px;
998
+ left: 1px;
999
+ border-radius: 3px;
1000
+ background: var(--outline);
1001
+ cursor: pointer;
1002
+ min-height: 30px;
1003
+ transition: background var(--transition);
1004
+ }
1005
+ .scroll-thumb:hover, .scroll-thumb:focus-visible {
1006
+ background: var(--on-surface-variant);
1007
+ }
1008
+
1009
+ /* ═══════════════════════════════════════════════════════════
1010
+ Footer
1011
+ ═══════════════════════════════════════════════════════════ */
1012
+
1013
+ .footer {
1014
+ height: 44px;
1015
+ display: flex;
1016
+ align-items: center;
1017
+ justify-content: center;
1018
+ border-top: 1px solid var(--outline-variant);
1019
+ padding: 0 16px;
1020
+ flex-shrink: 0;
1021
+ position: relative;
1022
+ background: var(--surface-container);
1023
+ }
1024
+
1025
+ .hitokoto {
1026
+ font-size: 11px;
1027
+ color: var(--on-surface-variant);
1028
+ opacity: 0.55;
1029
+ text-align: center;
1030
+ max-width: 70%;
1031
+ overflow: hidden;
1032
+ white-space: nowrap;
1033
+ text-overflow: ellipsis;
1034
+ min-height: 18px;
1035
+ }
1036
+
1037
+ .random-book-btn {
1038
+ position: absolute;
1039
+ right: 16px;
1040
+ top: 50%;
1041
+ transform: translateY(-50%);
1042
+ display: flex;
1043
+ align-items: center;
1044
+ gap: 4px;
1045
+ font-size: 11px;
1046
+ color: var(--on-surface-variant);
1047
+ padding: 4px 10px;
1048
+ border-radius: var(--radius-sm);
1049
+ border: 1px solid var(--outline-variant);
1050
+ transition: all var(--transition);
1051
+ }
1052
+ .random-book-btn:hover, .random-book-btn:focus-visible {
1053
+ background: var(--surface-variant);
1054
+ border-color: var(--outline);
1055
+ color: var(--primary);
1056
+ }
1057
+
1058
+ /* ═══════════════════════════════════════════════════════════
1059
+ Filter Section (Right Sidebar)
1060
+ ═══════════════════════════════════════════════════════════ */
1061
+
1062
+ .filter-section {
1063
+ border-bottom: 1px solid var(--outline-variant);
1064
+ padding: 10px 12px;
1065
+ }
1066
+
1067
+ #history-toggle-section,
1068
+ #search-folders-section,
1069
+ #exact-search-section {
1070
+ padding: 0 12px;
1071
+ height: 40px;
1072
+ box-sizing: border-box;
1073
+ }
1074
+
1075
+ #history-toggle-section .toggle-row,
1076
+ #search-folders-section .toggle-row,
1077
+ #exact-search-section .toggle-row {
1078
+ height: 100%;
1079
+ }
1080
+
1081
+ .filter-section-title {
1082
+ display: flex;
1083
+ align-items: center;
1084
+ justify-content: space-between;
1085
+ font-size: 12px;
1086
+ font-weight: 600;
1087
+ color: var(--on-surface-variant);
1088
+ text-transform: uppercase;
1089
+ letter-spacing: 0.5px;
1090
+ margin-bottom: 8px;
1091
+ }
1092
+
1093
+ .filter-actions {
1094
+ display: flex;
1095
+ gap: 4px;
1096
+ }
1097
+
1098
+ .filter-checkbox-list {
1099
+ max-height: 240px;
1100
+ overflow-y: auto;
1101
+ }
1102
+
1103
+ .filter-checkbox-item {
1104
+ display: flex;
1105
+ align-items: center;
1106
+ gap: 8px;
1107
+ padding: 4px 0;
1108
+ font-size: 13px;
1109
+ color: var(--on-surface);
1110
+ cursor: pointer;
1111
+ transition: color var(--transition);
1112
+ }
1113
+ .filter-checkbox-item:hover, .filter-checkbox-item:focus-visible {
1114
+ color: var(--primary);
1115
+ }
1116
+
1117
+ .filter-checkbox-item input[type="checkbox"] {
1118
+ accent-color: var(--primary);
1119
+ width: 16px;
1120
+ height: 16px;
1121
+ cursor: pointer;
1122
+ }
1123
+
1124
+ .filter-checkbox-item .checkbox-count {
1125
+ font-size: 10px;
1126
+ color: var(--on-surface-variant);
1127
+ margin-left: auto;
1128
+ }
1129
+
1130
+ /* ── Folder Tree Filter ──────────────────────────────── */
1131
+ .filter-folder-tree {
1132
+ max-height: 280px;
1133
+ overflow-y: auto;
1134
+ overflow-x: auto;
1135
+ white-space: nowrap;
1136
+ }
1137
+
1138
+ .filter-folder-item {
1139
+ display: flex;
1140
+ align-items: center;
1141
+ gap: 4px;
1142
+ padding: 3px 0 3px calc(var(--fdepth, 0) * 16px);
1143
+ font-size: 13px;
1144
+ color: var(--on-surface);
1145
+ cursor: pointer;
1146
+ white-space: nowrap;
1147
+ flex-wrap: nowrap;
1148
+ width: max-content;
1149
+ min-width: 100%;
1150
+ }
1151
+
1152
+ .filter-folder-item .tree-toggle {
1153
+ width: 16px;
1154
+ height: 16px;
1155
+ display: flex;
1156
+ align-items: center;
1157
+ justify-content: center;
1158
+ flex-shrink: 0;
1159
+ color: var(--on-surface-variant);
1160
+ font-size: 10px;
1161
+ }
1162
+ .filter-folder-item .tree-toggle.expanded {
1163
+ transform: rotate(90deg);
1164
+ }
1165
+
1166
+ .filter-folder-item input[type="checkbox"] {
1167
+ accent-color: var(--primary);
1168
+ width: 14px;
1169
+ height: 14px;
1170
+ cursor: pointer;
1171
+ flex-shrink: 0;
1172
+ }
1173
+
1174
+ .filter-folder-item .folder-name {
1175
+ flex: 0 0 auto;
1176
+ }
1177
+
1178
+ .folder-self-toggle {
1179
+ border: 1px solid var(--outline-variant);
1180
+ background: transparent;
1181
+ color: var(--on-surface-variant);
1182
+ border-radius: 999px;
1183
+ font-size: 11px;
1184
+ line-height: 1;
1185
+ padding: 4px 8px;
1186
+ cursor: pointer;
1187
+ transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
1188
+ flex-shrink: 0;
1189
+ margin-left: 4px;
1190
+ }
1191
+
1192
+ .filter-folder-item .folder-count {
1193
+ font-size: 10px;
1194
+ color: var(--on-surface-variant);
1195
+ margin-left: auto;
1196
+ flex-shrink: 0;
1197
+ }
1198
+
1199
+ .folder-self-toggle:hover,
1200
+ .folder-self-toggle:focus-visible {
1201
+ border-color: var(--primary);
1202
+ color: var(--primary);
1203
+ }
1204
+
1205
+ .folder-self-toggle.active {
1206
+ background: color-mix(in srgb, var(--primary) 18%, transparent);
1207
+ border-color: var(--primary);
1208
+ color: var(--primary);
1209
+ }
1210
+
1211
+ /* ── Size Filter ─────────────────────────────────────── */
1212
+ .filter-size-row {
1213
+ display: flex;
1214
+ align-items: center;
1215
+ gap: 6px;
1216
+ }
1217
+
1218
+ .size-input {
1219
+ flex: 1;
1220
+ font-size: 12px;
1221
+ padding: 6px 8px;
1222
+ border: 1px solid var(--outline-variant);
1223
+ border-radius: var(--radius-sm);
1224
+ background: var(--surface);
1225
+ color: var(--on-surface);
1226
+ min-width: 0;
1227
+ }
1228
+ .size-input:focus {
1229
+ border-color: var(--primary);
1230
+ }
1231
+ .size-unit {
1232
+ font-size: 12px;
1233
+ padding: 6px 4px;
1234
+ border: 1px solid var(--outline-variant);
1235
+ border-radius: var(--radius-sm);
1236
+ background: var(--surface);
1237
+ color: var(--on-surface);
1238
+ cursor: pointer;
1239
+ }
1240
+
1241
+ .size-sep {
1242
+ font-size: 12px;
1243
+ color: var(--on-surface-variant);
1244
+ flex-shrink: 0;
1245
+ }
1246
+
1247
+ /* ═══════════════════════════════════════════════════════════
1248
+ Empty State
1249
+ ═════════════════════════════════════════════════════════���═ */
1250
+
1251
+ .empty-state {
1252
+ display: flex;
1253
+ flex-direction: column;
1254
+ align-items: center;
1255
+ justify-content: center;
1256
+ padding: 64px 16px;
1257
+ text-align: center;
1258
+ gap: 12px;
1259
+ }
1260
+
1261
+ .empty-icon {
1262
+ width: 56px;
1263
+ height: 56px;
1264
+ color: var(--outline-variant);
1265
+ opacity: 0.4;
1266
+ }
1267
+
1268
+ .empty-title {
1269
+ font-size: 18px;
1270
+ font-weight: 600;
1271
+ color: var(--on-surface-variant);
1272
+ }
1273
+
1274
+ .empty-desc {
1275
+ font-size: 13px;
1276
+ color: var(--on-surface-variant);
1277
+ opacity: 0.6;
1278
+ }
1279
+
1280
+ .random-big-btn {
1281
+ font-size: 14px;
1282
+ padding: 10px 24px;
1283
+ border-radius: 24px;
1284
+ background: var(--primary);
1285
+ color: var(--on-primary);
1286
+ font-weight: 500;
1287
+ cursor: pointer;
1288
+ transition: opacity var(--transition);
1289
+ display: flex;
1290
+ align-items: center;
1291
+ gap: 8px;
1292
+ }
1293
+ .random-big-btn:hover, .random-big-btn:focus-visible {
1294
+ opacity: 0.85;
1295
+ }
1296
+
1297
+ /* ═══════════════════════════════════════════════════════════
1298
+ Loading Spinner
1299
+ ═══════════════════════════════════════════════════════════ */
1300
+
1301
+ .loading-spinner {
1302
+ display: flex;
1303
+ align-items: center;
1304
+ justify-content: center;
1305
+ padding: 24px;
1306
+ }
1307
+
1308
+ .spinner {
1309
+ width: 28px;
1310
+ height: 28px;
1311
+ border: 3px solid var(--outline-variant);
1312
+ border-top-color: var(--primary);
1313
+ border-radius: 50%;
1314
+ animation: spin 0.6s linear infinite;
1315
+ }
1316
+
1317
+ @keyframes spin {
1318
+ to { transform: rotate(360deg); }
1319
+ }
1320
+
1321
+ /* ═══════════════════════════════════════════════════════════
1322
+ Overlay
1323
+ ═══════════════════════════════════════════════════════════ */
1324
+
1325
+ .overlay {
1326
+ position: fixed;
1327
+ inset: 0;
1328
+ background: rgba(0, 0, 0, 0.5);
1329
+ z-index: 90;
1330
+ }
1331
+
1332
+ /* ═══════════════════════════════════════════════════════════
1333
+ Toast
1334
+ ═══════════════════════════════════════════════════════════ */
1335
+
1336
+ .toast {
1337
+ position: fixed;
1338
+ bottom: 60px;
1339
+ left: 50%;
1340
+ transform: translateX(-50%);
1341
+ background: var(--secondary-container);
1342
+ color: var(--on-secondary-container);
1343
+ padding: 8px 20px;
1344
+ border-radius: var(--radius-full);
1345
+ font-size: 13px;
1346
+ z-index: 200;
1347
+ box-shadow: var(--shadow-md);
1348
+ animation: toast-in 0.2s ease;
1349
+ }
1350
+
1351
+ @keyframes toast-in {
1352
+ from {
1353
+ opacity: 0;
1354
+ transform: translateX(-50%) translateY(10px);
1355
+ }
1356
+ to {
1357
+ opacity: 1;
1358
+ transform: translateX(-50%) translateY(0);
1359
+ }
1360
+ }
1361
+
1362
+ /* ═══════════════════════════════════════════════════════════
1363
+ Mobile
1364
+ ═══════════════════════════════════════════════════════════ */
1365
+
1366
+ body.mobile .sidebar:not(.open) {
1367
+ width: 0;
1368
+ border-right: none;
1369
+ border-left: none;
1370
+ }
1371
+
1372
+ body.mobile .left-sidebar.open {
1373
+ position: fixed;
1374
+ top: var(--header-h);
1375
+ left: 0;
1376
+ bottom: 0;
1377
+ z-index: 95;
1378
+ width: 85vw;
1379
+ max-width: 320px;
1380
+ box-shadow: var(--shadow-lg);
1381
+ }
1382
+
1383
+ body.mobile .right-sidebar.open {
1384
+ position: fixed;
1385
+ top: var(--header-h);
1386
+ right: 0;
1387
+ bottom: 0;
1388
+ z-index: 95;
1389
+ width: 85vw;
1390
+ max-width: 320px;
1391
+ box-shadow: var(--shadow-lg);
1392
+ }
1393
+
1394
+ body.mobile .footer {
1395
+ display: none;
1396
+ }
1397
+
1398
+ body.mobile .header-logo {
1399
+ display: none;
1400
+ }
1401
+
1402
+ body.mobile .random-book-btn {
1403
+ position: fixed;
1404
+ bottom: 20px;
1405
+ right: 20px;
1406
+ z-index: 50;
1407
+ transform: none;
1408
+ background: var(--surface-container);
1409
+ box-shadow: var(--shadow-md);
1410
+ border-radius: var(--radius-full);
1411
+ }
1412
+
1413
+ body.mobile .hitokoto {
1414
+ display: none;
1415
+ }
1416
+
1417
+ body.mobile .search-kbd {
1418
+ display: none;
1419
+ }
1420
+
1421
+ body.mobile .status-bar {
1422
+ padding: 4px 8px;
1423
+ }
1424
+
1425
+ body.mobile .result-actions {
1426
+ flex-direction: column;
1427
+ align-items: flex-end;
1428
+ }
1429
+
1430
+ /* ═══════════════════════════════════════════════════════════
1431
+ Responsive (auto-detect)
1432
+ ═══════════════════════════════════════════════════════════ */
1433
+
1434
+ @media (max-width: 768px) {
1435
+ body:not(.force-desktop) .sidebar:not(.open) {
1436
+ width: 0;
1437
+ border-right: none;
1438
+ border-left: none;
1439
+ }
1440
+
1441
+ body:not(.force-desktop) .left-sidebar.open {
1442
+ position: fixed;
1443
+ top: var(--header-h);
1444
+ left: 0;
1445
+ bottom: 0;
1446
+ z-index: 95;
1447
+ width: 85vw;
1448
+ max-width: 320px;
1449
+ box-shadow: var(--shadow-lg);
1450
+ }
1451
+
1452
+ body:not(.force-desktop) .right-sidebar.open {
1453
+ position: fixed;
1454
+ top: var(--header-h);
1455
+ right: 0;
1456
+ bottom: 0;
1457
+ z-index: 95;
1458
+ width: 85vw;
1459
+ max-width: 320px;
1460
+ box-shadow: var(--shadow-lg);
1461
+ }
1462
+
1463
+ body:not(.force-desktop) .footer {
1464
+ display: none;
1465
+ }
1466
+
1467
+ body:not(.force-desktop) .random-book-btn {
1468
+ position: fixed;
1469
+ bottom: 20px;
1470
+ right: 20px;
1471
+ z-index: 50;
1472
+ transform: none;
1473
+ background: var(--surface-container);
1474
+ box-shadow: var(--shadow-md);
1475
+ border-radius: var(--radius-full);
1476
+ }
1477
+
1478
+ body:not(.force-desktop) .hitokoto {
1479
+ display: none;
1480
+ }
1481
+
1482
+ body:not(.force-desktop) .search-kbd {
1483
+ display: none;
1484
+ }
1485
+ }
1486
+
1487
+ /* ═══════════════════════════════════════════════════════════
1488
+ Utility
1489
+ ═══════════════════════════════════════════════════════════ */
1490
+
1491
+ .hidden {
1492
+ display: none !important;
1493
+ }
1494
+
1495
+ .truncate {
1496
+ overflow: hidden;
1497
+ text-overflow: ellipsis;
1498
+ white-space: nowrap;
1499
+ }
1500
+
1501
+ /* ── Toggle Switch ──────────────────────────────────── */
1502
+ .toggle-row {
1503
+ display: flex;
1504
+ align-items: center;
1505
+ justify-content: space-between;
1506
+ cursor: pointer;
1507
+ }
1508
+
1509
+ .toggle-label {
1510
+ font-size: 13px;
1511
+ color: var(--on-surface);
1512
+ }
1513
+
1514
+ .toggle-row input[type="checkbox"] {
1515
+ display: none;
1516
+ }
1517
+
1518
+ .toggle-switch {
1519
+ position: relative;
1520
+ width: 40px;
1521
+ height: 22px;
1522
+ background: var(--outline-variant);
1523
+ border-radius: 11px;
1524
+ transition: background var(--transition);
1525
+ flex-shrink: 0;
1526
+ }
1527
+
1528
+ .toggle-switch::after {
1529
+ content: "";
1530
+ position: absolute;
1531
+ top: 2px;
1532
+ left: 2px;
1533
+ width: 18px;
1534
+ height: 18px;
1535
+ background: var(--surface);
1536
+ border-radius: 50%;
1537
+ transition: transform var(--transition);
1538
+ }
1539
+
1540
+ .toggle-row input:checked + .toggle-switch {
1541
+ background: var(--primary);
1542
+ }
1543
+
1544
+ .toggle-row input:checked + .toggle-switch::after {
1545
+ transform: translateX(18px);
1546
+ }
1547
+
1548
+ input[type="checkbox"]:indeterminate {
1549
+ accent-color: var(--primary);
1550
+ }
1551
+ body.theme-transitioning *,
1552
+ body.theme-transitioning *::before,
1553
+ body.theme-transitioning *::after {
1554
+ transition: background-color 0.4s ease, color 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease !important;
1555
+ }
1556
+
1557
+ .theme-ripple {
1558
+ position: fixed;
1559
+ border-radius: 50%;
1560
+ pointer-events: none;
1561
+ z-index: 9999;
1562
+ width: 0;
1563
+ height: 0;
1564
+ animation: theme-ripple-expand 0.6s ease-out forwards;
1565
+ }
1566
+
1567
+ @keyframes theme-ripple-expand {
1568
+ 0% { width: 0; height: 0; opacity: 0.6; }
1569
+ 100% { width: 300vmax; height: 300vmax; opacity: 0; transform: translate(-150vmax, -150vmax); }
1570
+ }
1571
+
1572
+ @media (prefers-reduced-motion: reduce) {
1573
+ *, *::before, *::after {
1574
+ animation-duration: 0.01ms !important;
1575
+ animation-iteration-count: 1 !important;
1576
+ transition-duration: 0.01ms !important;
1577
+ }
1578
+ }
1579
+
1580
+ /* ═══════════════════════════════════════════════════════════
1581
+ Multi-Select
1582
+ ═══════════════════════════════════════════════════════════ */
1583
+
1584
+ .multi-toggle {
1585
+ display: inline-flex;
1586
+ align-items: center;
1587
+ gap: 4px;
1588
+ font-size: 12px;
1589
+ color: var(--on-surface-variant);
1590
+ cursor: pointer;
1591
+ margin-left: 0;
1592
+ padding: 2px 6px;
1593
+ border-radius: var(--radius-sm);
1594
+ border: 1px solid transparent;
1595
+ }
1596
+ .multi-toggle:hover { border-color: var(--outline-variant); }
1597
+ .multi-toggle input { margin: 0; }
1598
+
1599
+ .multi-action-bar {
1600
+ display: flex;
1601
+ align-items: center;
1602
+ gap: 8px;
1603
+ padding: 0 12px;
1604
+ height: 40px;
1605
+ background: var(--surface-variant);
1606
+ border-bottom: 1px solid var(--outline-variant);
1607
+ box-sizing: border-box;
1608
+ flex-wrap: nowrap;
1609
+ }
1610
+ .multi-action-btn {
1611
+ padding: 4px 12px;
1612
+ border: 1px solid var(--outline-variant);
1613
+ border-radius: var(--radius-sm);
1614
+ background: var(--surface);
1615
+ color: var(--on-surface);
1616
+ font-size: 12px;
1617
+ cursor: pointer;
1618
+ }
1619
+ .multi-action-btn.primary {
1620
+ background: var(--primary);
1621
+ color: var(--on-primary);
1622
+ border-color: var(--primary);
1623
+ }
1624
+ .multi-action-btn:hover { opacity: 0.85; }
1625
+ .multi-selected-count {
1626
+ margin-left: auto;
1627
+ font-size: 12px;
1628
+ color: var(--on-surface-variant);
1629
+ white-space: nowrap;
1630
+ flex-shrink: 0;
1631
+ }
1632
+
1633
+ body.mobile .multi-action-btn {
1634
+ flex-shrink: 0;
1635
+ }
1636
+
1637
+ .result-checkbox {
1638
+ display: none;
1639
+ flex-shrink: 0;
1640
+ width: 16px;
1641
+ height: 16px;
1642
+ margin-right: 6px;
1643
+ align-self: center;
1644
+ accent-color: var(--primary);
1645
+ }
1646
+ body.multiselect .result-checkbox { display: block; }
1647
+ body.multiselect .result-file-icon { display: none; }
1648
+ .result-item.selected {
1649
+ background: color-mix(in srgb, var(--primary) 15%, transparent) !important;
1650
+ }
static/sw.js ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const CACHE_NAME = "vomebook-search-v1";
2
+
3
+ const PRECACHE_URLS = [
4
+ "/",
5
+ "/static/style.css",
6
+ "/static/app.js",
7
+ "/manifest.json"
8
+ ];
9
+
10
+ // ── Install: precache app shell ─────────────────────
11
+ self.addEventListener("install", (event) => {
12
+ event.waitUntil(
13
+ caches.open(CACHE_NAME).then((cache) => {
14
+ return cache.addAll(PRECACHE_URLS).catch((err) => {
15
+ console.warn("[SW] precache partial failure:", err);
16
+ });
17
+ }).then(() => self.skipWaiting())
18
+ );
19
+ });
20
+
21
+ // ── Activate: clean old caches ──────────────────────
22
+ self.addEventListener("activate", (event) => {
23
+ event.waitUntil(
24
+ caches.keys().then((keys) => {
25
+ return Promise.all(
26
+ keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
27
+ );
28
+ }).then(() => self.clients.claim())
29
+ );
30
+ });
31
+
32
+ // ── Fetch: cache strategies ─────────────────────────
33
+ self.addEventListener("fetch", (event) => {
34
+ const url = new URL(event.request.url);
35
+
36
+ // External domains: network only
37
+ if (url.hostname !== self.location.hostname) {
38
+ return;
39
+ }
40
+
41
+ // ── API: network first, no caching ────────────────
42
+ if (url.pathname.startsWith("/api/")) {
43
+ return;
44
+ }
45
+
46
+ // ── Download proxy: network only ──────────────────
47
+ if (url.pathname.startsWith("/txt/")) {
48
+ return;
49
+ }
50
+
51
+ // ── App shell / static: cache first, network update
52
+ event.respondWith(
53
+ caches.match(event.request).then((cached) => {
54
+ const fetchPromise = fetch(event.request).then((response) => {
55
+ if (response.ok && response.status !== 206) {
56
+ const clone = response.clone();
57
+ caches.open(CACHE_NAME).then((cache) => {
58
+ cache.put(event.request, clone);
59
+ });
60
+ }
61
+ return response;
62
+ }).catch(() => cached);
63
+ return cached || fetchPromise;
64
+ })
65
+ );
66
+ });