feat(teachers): добавить поиск с fuzzy-матчингом на страницу преподавателей
- Реализовать нечеткий поиск с алгоритмом Левенштейна (1-2 ошибки)
- Добавить поддержку поиска по порядку символов без совпадения подряд
- Увеличить размер шрифта input до 16px для предотвращения зума на iOS
- Увеличить высоту строки поиска для удобства на мобильных
feat(groups): улучшить отображение списка групп
- Изменить сетку: 2 колонки на мобильных, 3 на планшете, 4 на десктопе
- Разрешить перенос названий групп на несколько строк
- Переместить группы заочного отделения (начинаются с '(') в конец списка
This commit is contained in:
@@ -23,6 +23,13 @@ export default function App(props: AppProps) {
|
|||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5" />
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: `
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
input, select, textarea {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` }} />
|
||||||
</Head>
|
</Head>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
|
|||||||
@@ -179,17 +179,18 @@ export default function HomePage(props: HomePageProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||||
{courseGroups.map(({ id, name }, groupIndex) => {
|
{courseGroups.map(({ id, name }, groupIndex) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={id}
|
key={id}
|
||||||
className="stagger-card"
|
className="stagger-card"
|
||||||
>
|
>
|
||||||
<Link href={`/${id}`}>
|
<Link href={`/${id}`} className="block">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full justify-center h-auto py-3 px-2 sm:px-4 text-sm sm:text-base whitespace-nowrap"
|
className="w-full justify-center h-auto py-3 px-2 sm:px-4 text-sm sm:text-base h-auto min-h-[48px] whitespace-normal"
|
||||||
|
title={name}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -318,8 +319,19 @@ export const getServerSideProps: GetServerSideProps<HomePageProps> = async () =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Сортируем группы внутри каждого курса по имени
|
// Сортируем группы внутри каждого курса по имени
|
||||||
|
// Группы начинающиеся с "(" (заочка) перемещаем в конец
|
||||||
for (const course in groupsByCourse) {
|
for (const course in groupsByCourse) {
|
||||||
groupsByCourse[Number(course)].sort((a, b) => a.name.localeCompare(b.name))
|
groupsByCourse[Number(course)].sort((a, b) => {
|
||||||
|
const aIsZaoch = a.name.startsWith('(')
|
||||||
|
const bIsZaoch = b.name.startsWith('(')
|
||||||
|
|
||||||
|
// Если одна из групп заочка, а другая нет - заочку вниз
|
||||||
|
if (aIsZaoch && !bIsZaoch) return 1
|
||||||
|
if (!aIsZaoch && bIsZaoch) return -1
|
||||||
|
|
||||||
|
// Иначе сортируем по имени
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,24 +1,117 @@
|
|||||||
import React from 'react'
|
import React, { useState, useMemo } from 'react'
|
||||||
import { GetServerSideProps } from 'next'
|
import { GetServerSideProps } from 'next'
|
||||||
import { loadTeachers, TeachersData } from '@/shared/data/teachers-loader'
|
import { loadTeachers, TeachersData } from '@/shared/data/teachers-loader'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shadcn/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shadcn/ui/card'
|
||||||
import { Button } from '@/shadcn/ui/button'
|
import { Button } from '@/shadcn/ui/button'
|
||||||
|
import { Input } from '@/shadcn/ui/input'
|
||||||
import { ThemeSwitcher } from '@/features/theme-switch'
|
import { ThemeSwitcher } from '@/features/theme-switch'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import { GITHUB_REPO_URL } from '@/shared/constants/urls'
|
import { GITHUB_REPO_URL } from '@/shared/constants/urls'
|
||||||
import { FaGithub } from 'react-icons/fa'
|
import { FaGithub } from 'react-icons/fa'
|
||||||
import { ArrowLeft } from 'lucide-react'
|
import { ArrowLeft, Search } from 'lucide-react'
|
||||||
|
|
||||||
type TeachersPageProps = {
|
type TeachersPageProps = {
|
||||||
teachers: TeachersData
|
teachers: TeachersData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Функция для нечеткого поиска (fuzzy search) с допустимыми ошибками
|
||||||
|
function fuzzyMatch(query: string, text: string): boolean {
|
||||||
|
const queryLower = query.toLowerCase()
|
||||||
|
const textLower = text.toLowerCase()
|
||||||
|
|
||||||
|
// Прямое вхождение
|
||||||
|
if (textLower.includes(queryLower)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разрешаем 1-2 ошибки в зависимости от длины запроса
|
||||||
|
const maxErrors = queryLower.length <= 3 ? 1 : 2
|
||||||
|
|
||||||
|
// Проверяем расстояние Левенштейна для подстрок той же длины
|
||||||
|
if (hasCloseSubstring(textLower, queryLower, maxErrors)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нечеткий поиск: символы запроса должны идти в том же порядке
|
||||||
|
// с возможностью пропуска символов
|
||||||
|
let queryIndex = 0
|
||||||
|
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
|
||||||
|
if (textLower[i] === queryLower[queryIndex]) {
|
||||||
|
queryIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryIndex === queryLower.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяет, есть ли в тексте подстрока, близкая к образцу
|
||||||
|
function hasCloseSubstring(text: string, pattern: string, maxErrors: number): boolean {
|
||||||
|
const m = pattern.length
|
||||||
|
const n = text.length
|
||||||
|
|
||||||
|
if (m > n) return false
|
||||||
|
|
||||||
|
// Проверяем все подстроки длины m
|
||||||
|
for (let i = 0; i <= n - m; i++) {
|
||||||
|
const substring = text.slice(i, i + m)
|
||||||
|
const distance = levenshteinDistance(pattern, substring)
|
||||||
|
if (distance <= maxErrors) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вычисление расстояния Левенштейна (количество операций редактирования)
|
||||||
|
function levenshteinDistance(str1: string, str2: string): number {
|
||||||
|
const m = str1.length
|
||||||
|
const n = str2.length
|
||||||
|
|
||||||
|
// Создаем матрицу расстояний
|
||||||
|
const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0))
|
||||||
|
|
||||||
|
// Инициализация первой строки и столбца
|
||||||
|
for (let i = 0; i <= m; i++) dp[i][0] = i
|
||||||
|
for (let j = 0; j <= n; j++) dp[0][j] = j
|
||||||
|
|
||||||
|
// Заполнение матрицы
|
||||||
|
for (let i = 1; i <= m; i++) {
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
if (str1[i - 1] === str2[j - 1]) {
|
||||||
|
dp[i][j] = dp[i - 1][j - 1]
|
||||||
|
} else {
|
||||||
|
dp[i][j] = 1 + Math.min(
|
||||||
|
dp[i - 1][j], // удаление
|
||||||
|
dp[i][j - 1], // вставка
|
||||||
|
dp[i - 1][j - 1] // замена
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dp[m][n]
|
||||||
|
}
|
||||||
|
|
||||||
export default function TeachersPage({ teachers }: TeachersPageProps) {
|
export default function TeachersPage({ teachers }: TeachersPageProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
// Преобразуем объект преподавателей в массив и сортируем по имени
|
// Преобразуем объект преподавателей в массив и сортируем по имени
|
||||||
const teachersList = Object.entries(teachers)
|
const allTeachers = Object.entries(teachers)
|
||||||
.map(([id, teacher]) => ({ id, parseId: teacher.parseId, name: teacher.name }))
|
.map(([id, teacher]) => ({ id, parseId: teacher.parseId, name: teacher.name }))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
|
||||||
|
// Фильтруем преподавателей с учетом нечеткого поиска
|
||||||
|
const teachersList = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
return allTeachers
|
||||||
|
}
|
||||||
|
|
||||||
|
return allTeachers.filter(teacher =>
|
||||||
|
fuzzyMatch(searchQuery, teacher.name)
|
||||||
|
)
|
||||||
|
}, [allTeachers, searchQuery])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -33,10 +126,33 @@ export default function TeachersPage({ teachers }: TeachersPageProps) {
|
|||||||
<p className="text-muted-foreground">Выберите преподавателя для просмотра расписания</p>
|
<p className="text-muted-foreground">Выберите преподавателя для просмотра расписания</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Поиск преподавателей */}
|
||||||
|
<div className="stagger-card" style={{ animationDelay: '0.1s' } as React.CSSProperties}>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-6 w-6 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск преподавателя..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-12 h-14 text-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{teachersList.length === 0 ? (
|
{teachersList.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-8 text-center text-muted-foreground">
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
Преподаватели не найдены
|
{searchQuery ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>Преподаватели не найдены по запросу "{searchQuery}"</p>
|
||||||
|
<Button variant="link" onClick={() => setSearchQuery('')}>
|
||||||
|
Сбросить поиск
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>Преподаватели не найдены</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user