添加搜索

This commit is contained in:
2026-07-18 10:58:33 +08:00 Unverified
parent 3867373850
commit c8b2039593
11 changed files with 859 additions and 3 deletions
+9
View File
@@ -0,0 +1,9 @@
# 浏览器可见,只能填写限制为 documents:search 权限的 Search-only Key。
PUBLIC_TYPESENSE_HOST=search.example.com
PUBLIC_TYPESENSE_PORT=443
PUBLIC_TYPESENSE_PROTOCOL=https
PUBLIC_TYPESENSE_SEARCH_API_KEY=replace-with-search-only-key
PUBLIC_TYPESENSE_COLLECTION=class_memories
# 仅供 npm run search:sync 使用,绝不能使用 PUBLIC_ 前缀。
TYPESENSE_ADMIN_API_KEY=replace-with-admin-key
+3
View File
@@ -3,5 +3,8 @@ dist/
.astro/ .astro/
*.log *.log
.env.local .env.local
.env
.env.*
!.env.example
.tmp/ .tmp/
gzhh/ gzhh/
+21
View File
@@ -10,6 +10,7 @@
- 如今的我们:展示同学卡片和个人近况页面。 - 如今的我们:展示同学卡片和个人近况页面。
- 综合试卷:把纪念试卷做成网页,并保留 Word 原卷下载。 - 综合试卷:把纪念试卷做成网页,并保留 Word 原卷下载。
- 留言墙:接入 Twikoo 后可在页面中留言。 - 留言墙:接入 Twikoo 后可在页面中留言。
- 全站搜索:通过 Typesense 搜索并筛选时间线、照片、文章和同学公开资料。
## 技术栈 ## 技术栈
@@ -33,8 +34,28 @@ npm run dev
npm run dev # 启动开发服务器 npm run dev # 启动开发服务器
npm run build # 构建静态站点到 dist npm run build # 构建静态站点到 dist
npm run preview # 预览构建结果 npm run preview # 预览构建结果
npm run search:sync # 将公开内容同步到 Typesense
``` ```
## Typesense 全站搜索
复制 `.env.example` 中的配置到本地 `.env``.env.local` 或部署平台的环境变量。`npm run search:sync` 会自动读取这两个本地文件,且 `.env.local` 中的同名配置优先:
- `PUBLIC_TYPESENSE_*` 会进入浏览器,只能使用具有搜索权限的 Search-only Key。
- `TYPESENSE_ADMIN_API_KEY` 只用于同步索引,不能添加 `PUBLIC_` 前缀。
配置完成后直接执行,不需要在终端中逐个设置环境变量:
```bash
npm run search:key
npm run search:sync
npm run build
```
`npm run search:key` 使用 `TYPESENSE_ADMIN_API_KEY` 创建一个仅能搜索 `class_memories` 集合的只读密钥,并自动写入 `.env.local`。如果已经存在只读密钥,脚本会停止;需要轮换时使用 `npm run search:key -- --force`
同步脚本会在集合不存在时创建 `class_memories`,然后批量更新现有搜索文档。部署流程应在内容更新后先执行同步,再构建和发布站点。
## 项目结构 ## 项目结构
```text ```text
+40
View File
@@ -11,6 +11,10 @@
"@astrojs/check": "^0.9.9", "@astrojs/check": "^0.9.9",
"astro": "^7.1.0", "astro": "^7.1.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
},
"devDependencies": {
"@types/node": "^26.1.1",
"tsx": "^4.23.1"
} }
}, },
"node_modules/@astrojs/check": { "node_modules/@astrojs/check": {
@@ -2124,6 +2128,16 @@
"@types/unist": "*" "@types/unist": "*"
} }
}, },
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/unist": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -4406,6 +4420,25 @@
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
"node_modules/tsx": {
"version": "4.23.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typesafe-path": { "node_modules/typesafe-path": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz", "resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz",
@@ -4452,6 +4485,13 @@
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unified": { "node_modules/unified": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+7 -1
View File
@@ -6,11 +6,17 @@
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview" "preview": "astro preview",
"search:key": "node --env-file-if-exists=.env --env-file-if-exists=.env.local scripts/create-typesense-search-key.mjs",
"search:sync": "node --env-file-if-exists=.env --env-file-if-exists=.env.local --import tsx scripts/sync-typesense.ts"
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.9", "@astrojs/check": "^0.9.9",
"astro": "^7.1.0", "astro": "^7.1.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
},
"devDependencies": {
"@types/node": "^26.1.1",
"tsx": "^4.23.1"
} }
} }
+78
View File
@@ -0,0 +1,78 @@
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const collectionName = process.env.PUBLIC_TYPESENSE_COLLECTION || "class_memories";
const host = process.env.PUBLIC_TYPESENSE_HOST?.replace(/^https?:\/\//, "").replace(/\/$/, "");
const port = process.env.PUBLIC_TYPESENSE_PORT || "443";
const protocol = process.env.PUBLIC_TYPESENSE_PROTOCOL || "https";
const adminKey = process.env.TYPESENSE_ADMIN_API_KEY;
const existingSearchKey = process.env.PUBLIC_TYPESENSE_SEARCH_API_KEY;
const force = process.argv.includes("--force");
if (!host || !adminKey) {
throw new Error(
"缺少 Typesense 配置。请在 .env 或 .env.local 中设置 PUBLIC_TYPESENSE_HOST 和 TYPESENSE_ADMIN_API_KEY。"
);
}
const isPlaceholder = (value) =>
!value || /replace|example|your|请填写|只读密钥/i.test(value);
if (!isPlaceholder(existingSearchKey) && !force) {
throw new Error(
"PUBLIC_TYPESENSE_SEARCH_API_KEY 已存在。如需轮换密钥,请执行 npm run search:key -- --force。"
);
}
const origin = `${protocol}://${host}${
(protocol === "https" && port === "443") || (protocol === "http" && port === "80")
? ""
: `:${port}`
}`;
const response = await fetch(`${origin}/keys`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-TYPESENSE-API-KEY": adminKey
},
body: JSON.stringify({
description: `Search-only key for ${collectionName}`,
actions: ["documents:search"],
collections: [collectionName]
})
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(`创建只读密钥失败:${response.status} ${responseText}`);
}
const result = JSON.parse(responseText);
if (!result.value) {
throw new Error("Typesense 没有返回完整密钥,无法保存配置。请检查服务端响应。" );
}
const envLocalPath = fileURLToPath(new URL("../.env.local", import.meta.url));
let envLocal = "";
try {
envLocal = await readFile(envLocalPath, "utf8");
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
const lineEnding = envLocal.includes("\r\n") ? "\r\n" : "\n";
const assignment = `PUBLIC_TYPESENSE_SEARCH_API_KEY=${JSON.stringify(result.value)}`;
if (/^PUBLIC_TYPESENSE_SEARCH_API_KEY=.*$/m.test(envLocal)) {
envLocal = envLocal.replace(/^PUBLIC_TYPESENSE_SEARCH_API_KEY=.*$/m, assignment);
} else {
const separator = envLocal.length === 0 || envLocal.endsWith("\n") ? "" : lineEnding;
envLocal = `${envLocal}${separator}${assignment}${lineEnding}`;
}
await writeFile(envLocalPath, envLocal, "utf8");
console.log(`已创建仅限 ${collectionName} 的 documents:search 密钥。`);
console.log(`密钥 ID${result.id},已写入 .env.local。`);
console.log("现在可以执行 npm run search:sync 和 npm run build。");
+173
View File
@@ -0,0 +1,173 @@
import { articles } from "../src/data/articles";
import { people } from "../src/data/people";
import { photoTopics } from "../src/data/photos";
import { timeline } from "../src/data/timeline";
interface SearchDocument {
id: string;
type: "时间线" | "照片" | "文章" | "同学";
title: string;
content: string;
category?: string;
tags?: string[];
year?: number;
timestamp?: number;
url: string;
image?: string;
}
const collectionName = process.env.PUBLIC_TYPESENSE_COLLECTION || "class_memories";
const host = process.env.PUBLIC_TYPESENSE_HOST?.replace(/^https?:\/\//, "").replace(/\/$/, "");
const port = process.env.PUBLIC_TYPESENSE_PORT || "443";
const protocol = process.env.PUBLIC_TYPESENSE_PROTOCOL || "https";
const adminKey = process.env.TYPESENSE_ADMIN_API_KEY;
if (!host || !adminKey) {
throw new Error(
"缺少 Typesense 配置。请设置 PUBLIC_TYPESENSE_HOST 和 TYPESENSE_ADMIN_API_KEY。"
);
}
const origin = `${protocol}://${host}${
(protocol === "https" && port === "443") || (protocol === "http" && port === "80")
? ""
: `:${port}`
}`;
const schema = {
name: collectionName,
fields: [
{ name: "title", type: "string", locale: "zh" },
{ name: "content", type: "string", locale: "zh" },
{ name: "type", type: "string", facet: true },
{ name: "category", type: "string", facet: true, optional: true },
{ name: "tags", type: "string[]", facet: true, optional: true },
{ name: "year", type: "int32", facet: true, optional: true },
{ name: "timestamp", type: "int64", optional: true },
{ name: "url", type: "string", index: false },
{ name: "image", type: "string", index: false, optional: true }
]
};
const slugify = (value: string) =>
value
.normalize("NFKC")
.toLocaleLowerCase("zh-CN")
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
.replace(/^-|-$/g, "")
.slice(0, 80);
const getYear = (value: string) => Number(value.match(/\d{4}/)?.[0]) || undefined;
const toTimestamp = (value: string) => {
const timestamp = Date.parse(`${value}T00:00:00+08:00`);
return Number.isNaN(timestamp) ? undefined : Math.floor(timestamp / 1000);
};
const documents: SearchDocument[] = [
...timeline.map((moment, index) => ({
id: `timeline-${index + 1}-${slugify(moment.title)}`,
type: "时间线" as const,
title: moment.title,
content: `${moment.date} ${moment.text}`,
category: "高中三年",
tags: ["时间线", moment.title],
year: getYear(moment.date),
url: "/timeline/"
})),
...photoTopics.flatMap((topic) =>
topic.photos.map((photo, index) => ({
id: `photo-${topic.slug}-${index + 1}`,
type: "照片" as const,
title: photo.title,
content: `${topic.title} ${photo.caption}`,
category: topic.title,
tags: ["照片", topic.title],
url: `/gallery/${topic.slug}/`,
image: photo.image
}))
),
...articles.map((article, index) => ({
id: `article-${index + 1}-${slugify(article.fileName)}`,
type: "文章" as const,
title: article.title,
content: article.excerpt,
category: article.category,
tags: ["公众号", article.category],
year: getYear(article.date),
timestamp: toTimestamp(article.date),
url: article.sourceUrl,
image: article.cover
})),
...people.map((person) => ({
id: `person-${person.slug}`,
type: "同学" as const,
title: person.name,
content: [person.location, person.school, person.direction, person.text, person.currentStatus]
.filter(Boolean)
.join(" "),
category: person.direction,
tags: person.keywords,
url: `/people/${person.slug}/`,
image: person.photo || undefined
}))
];
const request = (path: string, init: RequestInit = {}) =>
fetch(`${origin}${path}`, {
...init,
headers: {
"X-TYPESENSE-API-KEY": adminKey,
...init.headers
}
});
const ensureCollection = async () => {
const response = await request(`/collections/${encodeURIComponent(collectionName)}`);
if (response.ok) return;
if (response.status !== 404) {
throw new Error(`读取集合失败:${response.status} ${await response.text()}`);
}
const createResponse = await request("/collections", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(schema)
});
if (!createResponse.ok) {
throw new Error(`创建集合失败:${createResponse.status} ${await createResponse.text()}`);
}
};
const sync = async () => {
await ensureCollection();
const payload = documents.map((document) => JSON.stringify(document)).join("\n");
const response = await request(
`/collections/${encodeURIComponent(collectionName)}/documents/import?action=upsert`,
{
method: "POST",
headers: { "Content-Type": "text/plain" },
body: payload
}
);
if (!response.ok) {
throw new Error(`导入失败:${response.status} ${await response.text()}`);
}
const results = (await response.text())
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as { success: boolean; error?: string; document?: string });
const failures = results.filter((result) => !result.success);
if (failures.length > 0) {
failures.slice(0, 10).forEach((failure) => console.error(failure.error, failure.document));
throw new Error(`${failures.length} 条搜索文档同步失败。`);
}
console.log(`已同步 ${results.length} 条文档到 Typesense 集合 ${collectionName}`);
};
await sync();
+2 -1
View File
@@ -34,7 +34,8 @@ export const navItems = [
{ label: "青春刊物", href: "/articles/" }, { label: "青春刊物", href: "/articles/" },
{ label: "如今的我们", href: "/people/" }, { label: "如今的我们", href: "/people/" },
{ label: "综合试卷", href: "/exam/" }, { label: "综合试卷", href: "/exam/" },
{ label: "留言墙", href: "/messages/" } { label: "留言墙", href: "/messages/" },
{ label: "搜索", href: "/search/" }
]; ];
const leavingCampusDate = "2024-06-08"; const leavingCampusDate = "2024-06-08";
+12
View File
@@ -1 +1,13 @@
/// <reference path="../.astro/types.d.ts" /> /// <reference path="../.astro/types.d.ts" />
interface ImportMetaEnv {
readonly PUBLIC_TYPESENSE_HOST?: string;
readonly PUBLIC_TYPESENSE_PORT?: string;
readonly PUBLIC_TYPESENSE_PROTOCOL?: "http" | "https";
readonly PUBLIC_TYPESENSE_SEARCH_API_KEY?: string;
readonly PUBLIC_TYPESENSE_COLLECTION?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+297
View File
@@ -0,0 +1,297 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import { site } from "../data/site";
const typesenseConfig = {
host: import.meta.env.PUBLIC_TYPESENSE_HOST ?? "",
port: import.meta.env.PUBLIC_TYPESENSE_PORT ?? "443",
protocol: import.meta.env.PUBLIC_TYPESENSE_PROTOCOL ?? "https",
apiKey: import.meta.env.PUBLIC_TYPESENSE_SEARCH_API_KEY ?? "",
collection: import.meta.env.PUBLIC_TYPESENSE_COLLECTION ?? "class_memories"
};
const isConfigured = Boolean(typesenseConfig.host && typesenseConfig.apiKey);
---
<BaseLayout title={`全站搜索 · ${site.className}`}>
<main>
<section class="page-hero search-hero">
<div class="section-heading">
<div>
<a class="back-link" href="/">返回首页</a>
<p class="eyebrow">Search Class 612</p>
<h1>全站搜索</h1>
<p>在时间线、照片、青春刊物和同学近况中寻找一段记忆。</p>
</div>
</div>
</section>
<section class="search-section" aria-labelledby="search-title">
<h2 id="search-title" class="sr-only">搜索班级记忆</h2>
{
isConfigured ? (
<div class="search-app" data-search-app>
<form class="search-form" role="search" data-search-form>
<label for="site-search">关键词</label>
<div class="search-input-row">
<input
id="site-search"
name="q"
type="search"
placeholder="试试“毕业”“百日誓师”或同学姓名"
autocomplete="off"
data-search-input
/>
<button class="button primary" type="submit">搜索</button>
</div>
</form>
<div class="search-filters" aria-label="搜索筛选">
<label>
内容类型
<select data-filter="type">
<option value="">全部类型</option>
</select>
</label>
<label>
分类
<select data-filter="category">
<option value="">全部分类</option>
</select>
</label>
<label>
年份
<select data-filter="year">
<option value="">全部年份</option>
</select>
</label>
<button class="search-clear" type="button" data-search-clear>清除筛选</button>
</div>
<p class="search-status" aria-live="polite" data-search-status>正在载入记忆……</p>
<div class="search-results" data-search-results></div>
<nav class="search-pagination" aria-label="搜索结果分页" data-search-pagination></nav>
</div>
) : (
<div class="search-config-note">
<p class="eyebrow">Typesense 待配置</p>
<h2>搜索服务还没有连接</h2>
<p>
请参考 <code>.env.example</code> 配置公开的 Search-only Key,并运行 <code>npm run search:sync</code> 建立索引。
</p>
</div>
)
}
</section>
</main>
{
isConfigured && (
<script is:inline define:vars={{ typesenseConfig }}>
const app = document.querySelector("[data-search-app]");
const form = app?.querySelector("[data-search-form]");
const input = app?.querySelector("[data-search-input]");
const status = app?.querySelector("[data-search-status]");
const results = app?.querySelector("[data-search-results]");
const pagination = app?.querySelector("[data-search-pagination]");
const clearButton = app?.querySelector("[data-search-clear]");
const filters = [...(app?.querySelectorAll("[data-filter]") ?? [])];
const perPage = 12;
let page = 1;
let debounceTimer;
let requestController;
const origin = `${typesenseConfig.protocol}://${typesenseConfig.host}${
(typesenseConfig.protocol === "https" && typesenseConfig.port === "443") ||
(typesenseConfig.protocol === "http" && typesenseConfig.port === "80")
? ""
: `:${typesenseConfig.port}`
}`;
const setSelectOptions = (select, values) => {
const current = select.value;
const firstOption = select.options[0];
select.replaceChildren(firstOption);
values.forEach(({ value, count }) => {
const option = document.createElement("option");
option.value = value;
option.textContent = `${value}${count}`;
select.append(option);
});
select.value = current;
};
const updateFacets = (facetCounts = []) => {
filters.forEach((select) => {
const facet = facetCounts.find((item) => item.field_name === select.dataset.filter);
const values = (facet?.counts ?? []).map((item) => ({
value: String(item.value),
count: item.count
}));
if (select.dataset.filter === "year") {
values.sort((a, b) => Number(b.value) - Number(a.value));
}
setSelectOptions(select, values);
});
};
const renderResults = (hits = []) => {
results.replaceChildren();
hits.forEach(({ document: item }) => {
const article = document.createElement("article");
article.className = "search-result-card";
if (item.image) {
const image = document.createElement("img");
image.src = item.image;
image.alt = "";
image.loading = "lazy";
article.append(image);
}
const body = document.createElement("div");
const meta = document.createElement("p");
meta.className = "search-result-meta";
meta.textContent = [item.type, item.category, item.year].filter(Boolean).join(" · ");
const title = document.createElement("h2");
const link = document.createElement("a");
link.href = item.url;
link.textContent = item.title;
if (/^https?:\/\//.test(item.url)) {
link.target = "_blank";
link.rel = "noreferrer";
}
title.append(link);
const content = document.createElement("p");
content.textContent = item.content;
body.append(meta, title, content);
article.append(body);
results.append(article);
});
};
const renderPagination = (found) => {
pagination.replaceChildren();
const totalPages = Math.ceil(found / perPage);
if (totalPages <= 1) return;
const makeButton = (label, nextPage, disabled = false) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.disabled = disabled;
button.addEventListener("click", () => {
page = nextPage;
search();
app.scrollIntoView({ behavior: "smooth", block: "start" });
});
return button;
};
pagination.append(
makeButton("上一页", page - 1, page <= 1),
document.createTextNode(`第 ${page} / ${totalPages} 页`),
makeButton("下一页", page + 1, page >= totalPages)
);
};
const updateUrl = () => {
const params = new URLSearchParams();
if (input.value.trim()) params.set("q", input.value.trim());
filters.forEach((select) => {
if (select.value) params.set(select.dataset.filter, select.value);
});
if (page > 1) params.set("page", String(page));
history.replaceState(null, "", `${location.pathname}${params.size ? `?${params}` : ""}`);
};
const search = async () => {
requestController?.abort();
requestController = new AbortController();
status.textContent = "正在寻找记忆……";
const params = new URLSearchParams({
q: input.value.trim() || "*",
query_by: "title,content,tags",
query_by_weights: "5,2,3",
facet_by: "type,category,year",
page: String(page),
per_page: String(perPage),
include_fields: "title,content,type,category,tags,year,url,image"
});
const conditions = filters
.filter((select) => select.value)
.map((select) => `${select.dataset.filter}:=${select.value}`);
if (conditions.length) params.set("filter_by", conditions.join(" && "));
try {
const response = await fetch(
`${origin}/collections/${encodeURIComponent(typesenseConfig.collection)}/documents/search?${params}`,
{
headers: { "X-TYPESENSE-API-KEY": typesenseConfig.apiKey },
signal: requestController.signal
}
);
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
const data = await response.json();
renderResults(data.hits);
updateFacets(data.facet_counts);
renderPagination(data.found);
status.textContent = data.found
? `找到 ${data.found} 段相关记忆`
: "没有找到相关内容,换个关键词试试吧。";
updateUrl();
} catch (error) {
if (error.name === "AbortError") return;
console.error(error);
results.replaceChildren();
pagination.replaceChildren();
status.textContent = "搜索服务暂时无法访问,请稍后重试。";
}
};
const initialParams = new URLSearchParams(location.search);
input.value = initialParams.get("q") ?? "";
page = Math.max(1, Number(initialParams.get("page")) || 1);
filters.forEach((select) => {
const value = initialParams.get(select.dataset.filter);
if (value) {
const option = document.createElement("option");
option.value = value;
option.textContent = value;
select.append(option);
select.value = value;
}
});
form.addEventListener("submit", (event) => {
event.preventDefault();
page = 1;
search();
});
input.addEventListener("input", () => {
clearTimeout(debounceTimer);
page = 1;
debounceTimer = setTimeout(search, 280);
});
filters.forEach((select) =>
select.addEventListener("change", () => {
page = 1;
search();
})
);
clearButton.addEventListener("click", () => {
input.value = "";
filters.forEach((select) => (select.value = ""));
page = 1;
search();
input.focus();
});
search();
</script>
)
}
</BaseLayout>
+216
View File
@@ -2602,3 +2602,219 @@ h2 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.search-section {
padding-top: 0;
}
.search-app,
.search-config-note {
max-width: 1080px;
margin: 0 auto;
padding: clamp(20px, 4vw, 40px);
background: rgba(255, 255, 255, 0.78);
border: 1px solid var(--line);
border-radius: 28px;
box-shadow: var(--shadow);
}
.search-form > label,
.search-filters label {
display: grid;
gap: 7px;
color: var(--muted);
font-size: 14px;
font-weight: 700;
}
.search-input-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.search-input-row input,
.search-filters select {
width: 100%;
min-height: 48px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--paper);
color: var(--ink);
font: inherit;
}
.search-input-row input {
padding: 0 16px;
font-size: 17px;
}
.search-input-row input:focus,
.search-filters select:focus {
outline: 3px solid rgba(69, 111, 148, 0.2);
border-color: var(--blue);
}
.search-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
align-items: end;
gap: 12px;
margin-top: 18px;
}
.search-filters select {
padding: 0 38px 0 12px;
}
.search-clear {
min-height: 48px;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 14px;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.search-status {
min-height: 26px;
margin: 26px 0 14px;
color: var(--muted);
}
.search-results {
display: grid;
gap: 14px;
}
.search-result-card {
display: grid;
grid-template-columns: 148px minmax(0, 1fr);
gap: 20px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 20px;
background: var(--paper);
transition: transform 160ms ease, border-color 160ms ease;
}
.search-result-card:not(:has(img)) {
grid-template-columns: 1fr;
}
.search-result-card:hover {
transform: translateY(-2px);
border-color: rgba(55, 109, 90, 0.42);
}
.search-result-card img {
width: 148px;
height: 112px;
border-radius: 14px;
object-fit: cover;
}
.search-result-card h2 {
margin: 3px 0 6px;
font-size: clamp(19px, 2vw, 24px);
line-height: 1.35;
}
.search-result-card h2 a:hover {
color: var(--green);
}
.search-result-card p {
margin: 0;
}
.search-result-card div > p:last-child {
display: -webkit-box;
overflow: hidden;
color: var(--muted);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.search-result-meta {
color: var(--green);
font-size: 13px;
font-weight: 700;
}
.search-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
margin-top: 24px;
color: var(--muted);
}
.search-pagination button {
min-height: 40px;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--paper);
color: var(--ink);
cursor: pointer;
}
.search-pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.search-config-note code {
padding: 2px 6px;
border-radius: 6px;
background: var(--soft);
}
@media (max-width: 760px) {
.search-filters {
grid-template-columns: 1fr 1fr;
}
.search-result-card {
grid-template-columns: 104px minmax(0, 1fr);
gap: 14px;
}
.search-result-card img {
width: 104px;
height: 92px;
}
}
@media (max-width: 520px) {
.search-input-row,
.search-filters,
.search-result-card {
grid-template-columns: 1fr;
}
.search-input-row .button,
.search-clear {
width: 100%;
}
.search-result-card img {
width: 100%;
height: 180px;
}
}