Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions messages/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,23 @@
},
"globalSearch": {
"title": "Search",
"placeholder": "Search courses, sections, teachers…",
"placeholderSignedIn": "Search courses, sections, homework, todos…",
"pageTitle": "Search",
"pageDescription": "Search courses, teachers, sections, campus links, and your homework or todos.",
"placeholder": "Search courses, teachers, links…",
"placeholderSignedIn": "Search courses, teachers, links, homework, todos…",
"shortcut": "Ctrl K",
"shortcutMac": "⌘ K",
"noResults": "No results found",
"searching": "Searching…",
"hint": "Type at least 2 characters to search",
"openSearch": "Open search",
"close": "Close",
"viewAllResults": "View all search results",
"groups": {
"courses": "Courses",
"sections": "Sections",
"teachers": "Teachers",
"links": "Links",
"homeworks": "Homework",
"todos": "Todos"
}
Expand Down
8 changes: 6 additions & 2 deletions messages/zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,23 @@
},
"globalSearch": {
"title": "搜索",
"placeholder": "搜索课程、班级、教师…",
"placeholderSignedIn": "搜索课程、班级、作业、待办…",
"pageTitle": "搜索",
"pageDescription": "搜索课程、教师、班级、校园链接,以及你的作业与待办。",
"placeholder": "搜索课程、教师、链接…",
"placeholderSignedIn": "搜索课程、教师、链接、作业、待办…",
"shortcut": "Ctrl K",
"shortcutMac": "⌘ K",
"noResults": "未找到相关结果",
"searching": "搜索中…",
"hint": "输入至少 2 个字符开始搜索",
"openSearch": "打开搜索",
"close": "关闭",
"viewAllResults": "查看全部搜索结果",
"groups": {
"courses": "课程",
"sections": "班级",
"teachers": "教师",
"links": "链接",
"homeworks": "作业",
"todos": "待办"
}
Expand Down
36 changes: 36 additions & 0 deletions src/features/dashboard-links/lib/dashboard-link-search.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { pinyin } from "pinyin-pro";
import type { DashboardLinkItem } from "@/features/dashboard-links/lib/dashboard-links";
import {
DASHBOARD_LINK_GROUP_ORDER,
type DashboardLinkGroup,
Expand Down Expand Up @@ -35,6 +37,40 @@ export function linkMatchesTokens(
);
}

function toSearchPinyin(text: string) {
if (!text.trim()) return "";
return pinyin(text, { toneType: "none" }).replace(/\s+/g, "").toLowerCase();
}

function toSearchableFields(
title: string,
description: string,
): DashboardLinkSearchable {
return {
title,
description,
titlePinyin: toSearchPinyin(title),
descriptionPinyin: toSearchPinyin(description),
group: "life",
};
}

export function dashboardLinkItemMatchesTokens(
link: DashboardLinkItem,
tokens: string[],
) {
const localizedFields = [
{ title: link.title, description: link.description },
link.localizations["en-us"],
];
return localizedFields.some((fields) =>
linkMatchesTokens(
toSearchableFields(fields.title, fields.description),
tokens,
),
);
}

export function groupDashboardLinks<Link extends DashboardLinkSearchable>(
links: Link[],
query: string,
Expand Down
19 changes: 19 additions & 0 deletions src/features/search/lib/global-search-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { GlobalSearchResponse } from "@/features/search/server/global-search-types";

export const GLOBAL_SEARCH_MIN_QUERY_LENGTH = 2;
export const GLOBAL_SEARCH_DEBOUNCE_MS = 200;
export const GLOBAL_SEARCH_DIALOG_LIMIT = 5;
export const GLOBAL_SEARCH_PAGE_LIMIT = 20;

export async function fetchGlobalSearch(
query: string,
limit: number,
): Promise<GlobalSearchResponse> {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}&limit=${limit}`,
);
if (!response.ok) {
throw new Error("Search request failed");
}
return (await response.json()) as GlobalSearchResponse;
}
106 changes: 106 additions & 0 deletions src/features/search/lib/global-search-keyboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type {
GlobalSearchResultGroup,
GlobalSearchResultItem,
} from "@/features/search/server/global-search-types";

export const GLOBAL_SEARCH_LISTBOX_ID = "global-search-listbox";
export const GLOBAL_SEARCH_ITEM_ID_PREFIX = "global-search-item-";

export function flattenSearchGroups(groups: GlobalSearchResultGroup[]) {
return groups.flatMap((group) => group.items);
}

export function globalSearchItemDomId(itemId: string) {
return `${GLOBAL_SEARCH_ITEM_ID_PREFIX}${itemId}`;
}

export function moveSearchActiveIndex(
itemCount: number,
currentIndex: number,
direction: "down" | "up",
) {
if (itemCount <= 0) return -1;

if (direction === "down") {
if (currentIndex < itemCount - 1) return currentIndex + 1;
return currentIndex < 0 ? 0 : currentIndex;
}

if (currentIndex <= 0) return -1;
return currentIndex - 1;
}

export function focusSearchResultItem(
items: GlobalSearchResultItem[],
index: number,
inputElement?: HTMLInputElement | null,
) {
if (index < 0) {
inputElement?.focus();
return;
}

const item = items[index];
if (!item) return;

const element = document.getElementById(globalSearchItemDomId(item.id));
if (!(element instanceof HTMLElement)) return;
element.focus();
element.scrollIntoView({ block: "nearest" });
}

export function activeItemIdFromIndex(
items: GlobalSearchResultItem[],
index: number,
) {
if (index < 0) return null;
return items[index]?.id ?? null;
}

export function handleSearchListboxKeydown(input: {
activeIndex: number;
event: KeyboardEvent;
inputElement?: HTMLInputElement | null;
isInteractive: boolean;
items: GlobalSearchResultItem[];
onActiveIndexChange: (index: number) => void;
onSelect: (item: GlobalSearchResultItem) => void;
}) {
if (!input.isInteractive || input.items.length === 0) return false;

const { event, items } = input;

if (event.key === "ArrowDown") {
event.preventDefault();
const nextIndex = moveSearchActiveIndex(
items.length,
input.activeIndex,
"down",
);
input.onActiveIndexChange(nextIndex);
focusSearchResultItem(items, nextIndex, input.inputElement);
return true;
}

if (event.key === "ArrowUp") {
event.preventDefault();
const nextIndex = moveSearchActiveIndex(
items.length,
input.activeIndex,
"up",
);
input.onActiveIndexChange(nextIndex);
focusSearchResultItem(items, nextIndex, input.inputElement);
return true;
}

if (event.key === "Enter" && input.activeIndex >= 0) {
const item = items[input.activeIndex];
if (!item) return false;
event.preventDefault();
input.onSelect(item);
return true;
}

return false;
}
22 changes: 18 additions & 4 deletions src/features/search/server/global-search-catalog-queries.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import { buildCourseListWhere } from "@/features/catalog/server/course-query-filters";
import { buildSectionListQuery } from "@/features/catalog/server/section-query-filters";
import { SECTION_SUMMARY_DEFAULT_ORDER_BY } from "@/features/catalog/server/section-summary-read-model";
import { buildTeacherWhere } from "@/features/catalog/server/teacher-query";
import type { Prisma } from "@/generated/prisma/client";
import type { AppLocale } from "@/i18n/config";
import { getPrisma } from "@/lib/db/prisma";
import { ilike } from "@/lib/query-filter-helpers";

function buildGlobalSectionSearchWhere(
query: string,
): Prisma.SectionWhereInput {
return {
retiredAt: null,
OR: [
{ course: { nameCn: ilike(query) } },
{ course: { nameEn: ilike(query) } },
{ course: { code: ilike(query) } },
{ code: ilike(query) },
],
};
}

export async function searchCoursesForGlobal(
query: string,
Expand All @@ -28,10 +43,9 @@ export async function searchSectionsForGlobal(
locale: AppLocale,
limit: number,
) {
const { orderBy, where } = buildSectionListQuery({ search: query });
return getPrisma(locale).section.findMany({
where,
orderBy: orderBy ?? SECTION_SUMMARY_DEFAULT_ORDER_BY,
where: buildGlobalSectionSearchWhere(query),
orderBy: SECTION_SUMMARY_DEFAULT_ORDER_BY,
select: {
code: true,
jwId: true,
Expand Down
37 changes: 37 additions & 0 deletions src/features/search/server/global-search-link-queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
dashboardLinkItemMatchesTokens,
searchQueryToTokens,
} from "@/features/dashboard-links/lib/dashboard-link-search";
import {
localizeDashboardLink,
USTC_DASHBOARD_LINKS,
} from "@/features/dashboard-links/lib/dashboard-links";
import type { AppLocale } from "@/i18n/config";

const MIN_QUERY_LENGTH = 2;

export function searchLinksForGlobal(
query: string,
locale: AppLocale,
limit: number,
) {
const trimmed = query.trim();
if (trimmed.length < MIN_QUERY_LENGTH) return [];

const tokens = searchQueryToTokens(trimmed);
if (tokens.length === 0) return [];

return USTC_DASHBOARD_LINKS.filter((link) =>
dashboardLinkItemMatchesTokens(link, tokens),
)
.slice(0, limit)
.map((link) => {
const localized = localizeDashboardLink(link, locale);
return {
description: localized.description,
slug: localized.slug,
title: localized.title,
url: localized.url,
};
});
}
62 changes: 34 additions & 28 deletions src/features/search/server/global-search-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import {
searchSectionsForGlobal,
searchTeachersForGlobal,
} from "@/features/search/server/global-search-catalog-queries";
import { searchLinksForGlobal } from "@/features/search/server/global-search-link-queries";
import type {
GlobalSearchResponse,
GlobalSearchResultGroup,
GlobalSearchResultGroupType,
GlobalSearchResultItem,
} from "@/features/search/server/global-search-types";
import { GLOBAL_SEARCH_GROUP_ORDER } from "@/features/search/server/global-search-types";
import type { AppLocale } from "@/i18n/config";
import { cachedCatalogRuntimeData } from "@/lib/catalog-runtime-cache";
import { withUserDbContext } from "@/lib/db/prisma";
Expand Down Expand Up @@ -71,37 +74,40 @@ async function searchCatalogGroups(
locale: AppLocale,
limit: number,
): Promise<GlobalSearchResultGroup[]> {
const [courses, sections, teachers] = await Promise.all([
const [courses, teachers, sections, links] = await Promise.all([
searchCoursesForGlobal(query, locale, limit),
searchSectionsForGlobal(query, locale, limit),
searchTeachersForGlobal(query, locale, limit),
searchSectionsForGlobal(query, locale, limit),
Promise.resolve(searchLinksForGlobal(query, locale, limit)),
]);

const groups: GlobalSearchResultGroup[] = [];
if (courses.length > 0) {
groups.push({
type: "courses",
items: courses.map(toCourseItem),
});
}
if (sections.length > 0) {
groups.push({
type: "sections",
items: sections.map((section) => toSectionItem(section, locale)),
});
}
if (teachers.length > 0) {
groups.push({
type: "teachers",
items: teachers.map((teacher) => ({
id: `teacher:${teacher.id}`,
title: teacher.nameCn,
description: teacher.department?.nameCn ?? teacher.code,
href: `/catalog/teachers/${teacher.id}`,
})),
});
}
return groups;
const groupItems: Record<
GlobalSearchResultGroupType,
GlobalSearchResultItem[]
> = {
courses: courses.map(toCourseItem),
teachers: teachers.map((teacher) => ({
id: `teacher:${teacher.id}`,
title: teacher.nameCn,
description: teacher.department?.nameCn ?? teacher.code,
href: `/catalog/teachers/${teacher.id}`,
})),
sections: sections.map((section) => toSectionItem(section, locale)),
links: links.map((link) => ({
id: `link:${link.slug}`,
title: link.title,
description: link.description,
href: link.url,
external: true,
})),
homeworks: [],
todos: [],
};

return GLOBAL_SEARCH_GROUP_ORDER.flatMap((type) => {
const items = groupItems[type];
return items.length > 0 ? [{ type, items }] : [];
});
}

function catalogSearchCacheKey(query: string, limit: number) {
Expand All @@ -114,7 +120,7 @@ async function searchCachedCatalogGroups(input: {
origin: string;
query: string;
}): Promise<GlobalSearchResultGroup[]> {
const namespace: PublicRuntimeCacheAnalyticsNamespace = `search:catalog:${input.locale}`;
const namespace: PublicRuntimeCacheAnalyticsNamespace = `search:catalog:v2:${input.locale}`;
return cachedCatalogRuntimeData(
namespace,
catalogSearchCacheKey(input.query, input.limit),
Expand Down
Loading