diff --git a/CHANGELOG.md b/CHANGELOG.md index d3d53d4..0168776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,19 @@ ### Добавлено +- Глобальный полнотекстовый поиск с `⌘ K` / `Ctrl K`, клавиатурной навигацией, подсветкой совпадений и понятными empty states. +- Страницы «О проекте», «Архитектура» и «Помочь StackMIREA». +- Опциональные учебные метаданные, прогресс курса в браузере, стандарт образовательной страницы и MDX-компоненты для самопроверки, иллюстраций и сворачиваемого кода. - Новый векторный логотип StackMIREA для README. - `AGENTS.md` с инструкциями для кодовых агентов. - Этот журнал изменений. ### Изменено -- Футер сайта получил новую адаптивную структуру с навигацией, каналами связи, ссылками на поддержку и отдельными карточками лицензий для кода и учебных материалов. +- Главная страница и каталог перестроены вокруг поиска и наполненных направлений; пустые дисциплины вынесены в блок «Скоро появятся». +- Страницы направлений получили roadmap, прогресс и структуру mini-course; статьи — метаданные, mobile TOC и навигацию только внутри своего курса. +- Обновлены репрезентативные материалы Java MVC, AI/KNN и Big Data по новому образовательному стандарту. +- Упрощены header, footer и представление команды; улучшены focus states, reduced motion и адаптивность контента. - Светлая и тёмная темы сайта переведены с монохромной палитры на фиолетовую; обновлены фоновые градиенты, акценты, состояния фокуса и оформление документации. - Фирменные SVG- и PNG-знаки приведены к новой фиолетовой палитре; социальное превью обновлено в формате 1200×630. - README получил новую структуру, навигацию, badges, схему контентного pipeline и актуальные команды. @@ -24,6 +30,7 @@ ### Исправлено +- Исправлены пути статических ассетов и favicon для production `basePath` на GitHub Pages. - Исправлен синтаксис YAML в шаблонах issues со значениями, содержащими двоеточие. ## Как вести журнал diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03edd93..d43ed17 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,21 @@ order: 1 --- ``` +При необходимости добавьте тип (`theory`, `practice`, `notebook`, `guide`, `reference`), теги, сложность, дату обновления, ожидаемое время и статус: + +```yaml +type: practice +tags: + - Java + - MVC +difficulty: intermediate +updatedAt: 2026-09-02 +estimatedMinutes: 20 +status: published +``` + +Статусы `draft`, `published` и `archived` позволяют управлять жизненным циклом страницы. Черновики не попадают в production manifest. Подробная структура страницы описана в [стандарте учебного материала](./docs/algorithms/content-standard.mdx). + Вместо `order` можно использовать `sidebar_position`. Для нового учебного трека добавьте запись в `lib/tracks.json` и индексную страницу `docs//index.mdx`. Хороший материал: diff --git a/app/about/page.tsx b/app/about/page.tsx new file mode 100644 index 0000000..60f9fc8 --- /dev/null +++ b/app/about/page.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowRight, BookOpenText, Code2, Github, Search } from "lucide-react"; + +import { GitHubUserBadge } from "@/components/ui/GitHubUserBadge"; +import { buttonVariants } from "@/components/ui/button"; +import { getAuthorsWithSummary } from "@/lib/contributors"; +import { buildPageMetadata } from "@/lib/seo"; +import { cn, REPO_URL } from "@/lib/utils"; + +export const metadata: Metadata = buildPageMetadata({ title: "О проекте", description: "Зачем существует StackMIREA, для кого он сделан и как устроена открытая база учебных материалов.", pathname: "/about" }); + +export default function AboutPage() { + const authors = getAuthorsWithSummary(); + const maintainer = authors.find((author) => author.github.toLowerCase() === "minkinad"); + const contributors = authors.filter((author) => author.github.toLowerCase() !== "minkinad"); + + return ( +
+

О проекте

StackMIREA превращает разрозненные учебные файлы в понятную базу знаний

Это открытая статическая платформа с практиками, ноутбуками и разборами по IT-дисциплинам РТУ МИРЭА.

+ +
+ {[{ icon: BookOpenText, title: "Для студентов", text: "Чтобы быстро найти тему, пройти материалы по порядку и вернуться к нужному фрагменту." }, { icon: Search, title: "Для самостоятельного обучения", text: "Чтобы искать по содержимому, а не помнить название дисциплины или файла." }, { icon: Code2, title: "Для авторов", text: "Чтобы публиковать воспроизводимые материалы в одном проверяемом формате." }].map((item) =>

{item.title}

{item.text}

)} +
+ +

Почему появился проект

Полезные практики часто остаются в локальных папках, чатах и отдельных репозиториях. StackMIREA сохраняет их в читаемом MDX-формате, строит единый каталог и делает содержание доступным через поиск.

+ +

Как устроен контент

Канонические материалы находятся в docs/. Перед публикацией проверяются метаданные, внутренние ссылки, структура треков и поисковый индекс. Сайт собирается статически и не требует аккаунта или backend.

Посмотреть архитектуру
+ +

Команда

Роли отражают реальный вклад в проект без корпоративной иерархии.

{maintainer ?

Alexander Minkin

Разработка платформы, сопровождение контента и публикация проекта.

: null}{contributors.length ?

Участники

{contributors.map((author) =>

{author.docsCount} публикаций

)}
: null}
+ +

Open Source

Исходный код открыт по MIT, учебные материалы — по CC BY-NC-SA 4.0. Предложения и исправления принимаются через GitHub.

Открыть репозиторий
+
+ ); +} diff --git a/app/architecture/page.tsx b/app/architecture/page.tsx new file mode 100644 index 0000000..1815d74 --- /dev/null +++ b/app/architecture/page.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowRight, Boxes, FileCode2, Github, Search, ShieldCheck } from "lucide-react"; + +import { buildPageMetadata } from "@/lib/seo"; +import { REPO_URL } from "@/lib/utils"; + +export const metadata: Metadata = buildPageMetadata({ title: "Архитектура платформы", description: "Реальный путь контента StackMIREA от MDX-файла до статической публикации в GitHub Pages.", pathname: "/architecture" }); + +const stack = [ + ["Next.js 14", "App Router, маршруты и статический export."], + ["TypeScript", "Строгая типизация компонентов и data layer."], + ["MDX", "Учебный текст вместе с переиспользуемыми компонентами."], + ["Shiki", "Build-time подсветка синтаксиса без тяжёлого runtime."], + ["GitHub Actions", "Проверки качества и воспроизводимая сборка."], + ["GitHub Pages", "Хостинг готовых статических файлов с basePath."] +]; + +export default function ArchitecturePage() { + return ( +
+

Engineering

Как StackMIREA превращает MDX в статическую образовательную платформу

Один pipeline отвечает за навигацию, страницы, поиск и проверку качества — без отдельной CMS и backend.

+ +

Путь материала

docs/**/*.mdx
Validation + manifest
Next.js static build
Navigation
Docs pages
Search index
GitHub Pages
+ +

Компоненты pipeline

{[{ title: "Источник", text: "Только docs/ является каноническим источником учебных материалов." }, { title: "Content manifest", text: "Содержит маршруты, метаданные, TOC, темы, превью и данные для навигации." }, { title: "Поисковый индекс", text: "На build-time материал делится на смысловые фрагменты и получает ключевые слова." }, { title: "Quality gates", text: "CI проверяет frontmatter, дубликаты, ссылки, orphan pages, TypeScript, тесты и build." }].map((item) =>

{item.title}

{item.text}

)}
+ +

Технологии

{stack.map(([name, description]) =>
{name}
{description}
)}
+ + Изучить исходный код +
+ ); +} diff --git a/app/ask/page.tsx b/app/ask/page.tsx index 6179287..75df37b 100644 --- a/app/ask/page.tsx +++ b/app/ask/page.tsx @@ -22,16 +22,14 @@ export default function AskPage() {

- Новый способ навигации по StackMIREA + Поиск ответа по содержимому

- Семантический поиск и -
- “Спроси StackMIREA” + Спроси StackMIREA

- Ищи не только по заголовкам. Формулируй вопрос естественным языком, а движок подберёт материалы по теме, - смыслу и совпадающим фрагментам внутри документации. + Глобальный поиск находит конкретную страницу. Здесь можно сформулировать вопрос естественным языком — + движок подберёт подходящие материалы и покажет релевантные фрагменты.

diff --git a/app/contribute/page.tsx b/app/contribute/page.tsx new file mode 100644 index 0000000..5431516 --- /dev/null +++ b/app/contribute/page.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowRight, BookPlus, Bug, GitPullRequest, Lightbulb, PencilLine } from "lucide-react"; + +import { buttonVariants } from "@/components/ui/button"; +import { buildPageMetadata } from "@/lib/seo"; +import { cn, DEFAULT_BRANCH, REPO_URL } from "@/lib/utils"; + +export const metadata: Metadata = buildPageMetadata({ title: "Помочь StackMIREA", description: "Как добавить учебный материал, исправить ошибку или предложить новое направление StackMIREA.", pathname: "/contribute" }); + +const ways = [ + { icon: BookPlus, title: "Добавить материал", text: "Опубликовать практику, разбор, notebook или справочник." }, + { icon: PencilLine, title: "Улучшить материал", text: "Уточнить объяснение, обновить код или исправить ссылку." }, + { icon: Bug, title: "Исправить ошибку", text: "Сообщить о проблеме в интерфейсе, поиске или контенте." }, + { icon: Lightbulb, title: "Предложить направление", text: "Описать дисциплину, которой не хватает в каталоге." } +]; + +export default function ContributePage() { + return ( +
+

Contribution

Помочь StackMIREA

Выберите небольшой полезный вклад: новый материал, исправление, улучшение существующей страницы или идея для направления.

+
{ways.map((way) =>

{way.title}

{way.text}

)}
+ +

Как отправить изменение

    {[ + ["Сделайте fork и создайте ветку", `git checkout -b docs/my-material`], + ["Добавьте или обновите материал", "Учебные исходники находятся только в docs//."], + ["Запустите проверки", "npm run quality && npm test && npm run lint && npm run typecheck"], + ["Проверьте production build", "npm run build"], + ["Создайте Pull Request", "Опишите цель, изменённые страницы и результаты проверок."] + ].map(([title, detail], index) =>
  1. {index + 1}

    {title}

    {detail}
  2. )}
+ +

Стандарт материала

Укажите title, description, author и order. По возможности добавьте цели, prerequisites, теорию, задание, объяснение решения, частые ошибки, результат и вопросы для самопроверки. Не помещайте весь разбор в один большой code dump.

Полные правила
+ +
Сделать fork Создать issue
+
+ ); +} diff --git a/app/docs/[...slug]/page.tsx b/app/docs/[...slug]/page.tsx index 57d4982..9112662 100644 --- a/app/docs/[...slug]/page.tsx +++ b/app/docs/[...slug]/page.tsx @@ -6,10 +6,14 @@ import { notFound } from "next/navigation"; import { getBuildInfo } from "@/lib/build-info"; import { MobileDocsMenu } from "@/components/layout/MobileDocsMenu"; import { Sidebar } from "@/components/layout/Sidebar"; -import { Toc } from "@/components/layout/Toc"; +import { MobileToc, Toc } from "@/components/layout/Toc"; import { Breadcrumbs } from "@/components/ui/Breadcrumbs"; import { GitHubUserBadge } from "@/components/ui/GitHubUserBadge"; import { Pagination } from "@/components/ui/Pagination"; +import { ArticleMetadata } from "@/components/docs/ArticleMetadata"; +import { CourseOverview } from "@/components/docs/CourseOverview"; +import { MaterialCompletion } from "@/components/docs/CourseProgress"; +import { getCourseRoadmap, getTrackOverviews } from "@/lib/catalog"; import { compileDocMdx } from "@/lib/mdx"; import { getAllDocs, getDocBySlug, getDocPagination, getSidebarGroups } from "@/lib/navigation"; import { buildPageMetadata } from "@/lib/seo"; @@ -54,6 +58,23 @@ export default async function DocPage({ params }: DocPageProps) { const pagination = getDocPagination(params.slug); const { content } = await compileDocMdx(doc.body, { collectToc: false }); const editUrl = doc.editPath ? `${GITHUB_EDIT_ROOT}/${doc.editPath}` : null; + const track = getTrackOverviews().find((item) => item.id === doc.section); + + if (doc.isSectionIndex && track) { + return ( + <> + +
+ +
+ + +
+ +
+ + ); + } return ( <> @@ -62,14 +83,16 @@ export default async function DocPage({ params }: DocPageProps) {
-
+

{doc.title}

{doc.description ?

{doc.description}

: null} -
+
+
+
{editUrl ? ( +
{content}
- +
diff --git a/app/docs/page.tsx b/app/docs/page.tsx index 2fe3e97..4ac1607 100644 --- a/app/docs/page.tsx +++ b/app/docs/page.tsx @@ -1,87 +1,34 @@ -import Link from "next/link"; import type { Metadata } from "next"; -import { ArrowRight, Bot, Brain, Code2, Database, GitPullRequest, ListChecks, Sigma } from "lucide-react"; +import { CatalogExplorer } from "@/components/catalog/CatalogExplorer"; import { MobileDocsMenu } from "@/components/layout/MobileDocsMenu"; import { getBuildInfo } from "@/lib/build-info"; -import { getAllDocs, getSidebarGroups } from "@/lib/navigation"; +import { getCatalogStats, getTrackOverviews } from "@/lib/catalog"; +import { getSidebarGroups } from "@/lib/navigation"; import { buildPageMetadata } from "@/lib/seo"; -import { getTrackDefinitions, type TrackIconKey } from "@/lib/tracks"; export const dynamic = "force-static"; export const metadata: Metadata = buildPageMetadata({ - title: "Документация StackMIREA", - description: "Каталог учебных треков StackMIREA: практики, ноутбуки, методички и материалы по ключевым IT-дисциплинам.", + title: "Каталог учебных материалов", + description: "Практики, ноутбуки и разборы StackMIREA по Java, AI, Big Data, Python и другим IT-дисциплинам.", pathname: "/docs" }); -const iconByKey: Record = { - bot: Bot, - brain: Brain, - code2: Code2, - database: Database, - gitPullRequest: GitPullRequest, - listChecks: ListChecks, - sigma: Sigma -}; - export default function DocsIndexPage() { - const buildInfo = getBuildInfo(); - const groups = getSidebarGroups(); - const itemCountByGroup = getAllDocs() - .filter((doc) => !doc.isSectionIndex) - .reduce((counts, doc) => counts.set(doc.section, (counts.get(doc.section) ?? 0) + 1), new Map()); - const tracks = getTrackDefinitions().map((track) => ({ - ...track, - href: `/docs/${track.id}`, - icon: iconByKey[track.iconKey], - itemsCount: itemCountByGroup.get(track.id) ?? 0 - })); + const tracks = getTrackOverviews(); + const stats = getCatalogStats(); return ( <> - - -
-
-

Документация StackMIREA: актуальные треки

-

- Здесь собраны учебные направления проекта. Открывайте нужный трек и переходите к практикам, ноутбукам и - методическим материалам без перегруженного списка на входе. -

+ +
+
+

Каталог StackMIREA

+

Выберите дисциплину и откройте нужный материал

+

В каталоге {stats.materialsCount} материалов в {stats.populatedTracksCount} наполненных направлениях. Используйте поиск по технологии или быстрый фильтр по дисциплине.

- -
- {tracks.map((track) => { - const Icon = track.icon; - - return ( - -
-
- -
- - {track.itemsCount} материалов - -
- -

{track.title}

-

{track.homeSubtitle}

- - - Открыть трек - - - - ); - })} -
+
); diff --git a/app/globals.css b/app/globals.css index 0207e00..ce12284 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,5 +1,3 @@ -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); - @tailwind base; @tailwind components; @tailwind utilities; @@ -24,6 +22,8 @@ --ring: 263 83% 58%; --glow-primary: 139 92 246; --glow-secondary: 192 132 252; + --radius-card: 1rem; + --transition-ui: 160ms ease; } .dark { @@ -62,7 +62,12 @@ body { @apply bg-background text-foreground antialiased; min-height: 100vh; text-rendering: optimizeLegibility; - font-family: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +:where(a, button, input, summary):focus-visible { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; } ::selection { @@ -103,3 +108,18 @@ body { clip-path: circle(var(--theme-transition-radius) at var(--theme-transition-x) var(--theme-transition-y)); } } + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 25c426f..ed1ed69 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,14 +5,14 @@ import type { ReactNode } from "react"; import { Footer } from "@/components/layout/Footer"; import { Header } from "@/components/layout/Header"; import { DEFAULT_ROBOTS, SITE_DESCRIPTION, SITE_NAME, socialImage } from "@/lib/seo"; -import { BASE_PATH, SITE_ORIGIN } from "@/lib/utils"; +import { BASE_PATH, SITE_ORIGIN, withBasePath } from "@/lib/utils"; import "./globals.css"; export const metadata: Metadata = { metadataBase: new URL(`${SITE_ORIGIN}${BASE_PATH}/`), title: { - default: `${SITE_NAME} Документация`, + default: `Учебные материалы МИРЭА | ${SITE_NAME}`, template: `%s | ${SITE_NAME}` }, description: SITE_DESCRIPTION, @@ -20,16 +20,16 @@ export const metadata: Metadata = { canonical: "/" }, icons: { - icon: "favicon.svg", - shortcut: "favicon.svg", - apple: "favicon.png" + icon: withBasePath("/favicon.svg"), + shortcut: withBasePath("/favicon.svg"), + apple: withBasePath("/favicon.png") }, - keywords: ["Next.js docs", "documentation platform", "python", "ai", "bigdata", "java", "algorithms", "mdx", "shiki"], + keywords: ["МИРЭА", "учебные материалы", "практики", "Python", "AI", "Big Data", "Java", "алгоритмы"], robots: DEFAULT_ROBOTS, openGraph: { type: "website", url: "/", - title: `${SITE_NAME} Документация`, + title: `Учебные материалы МИРЭА | ${SITE_NAME}`, description: SITE_DESCRIPTION, images: [ { @@ -42,7 +42,7 @@ export const metadata: Metadata = { }, twitter: { card: "summary_large_image", - title: `${SITE_NAME} Документация`, + title: `Учебные материалы МИРЭА | ${SITE_NAME}`, description: SITE_DESCRIPTION, images: [socialImage] } diff --git a/app/not-found.tsx b/app/not-found.tsx index b8f93bb..2525f2f 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,24 +1,22 @@ import Link from "next/link"; +import { Search } from "lucide-react"; + +import { SearchLauncher } from "@/components/search/SearchDialog"; export default function NotFoundPage() { return (

404

-

Page not found

-

Запрошенная страница не найдена или была перемещена.

+

Материал не найден

+

Страница могла быть перемещена. Попробуйте найти тему или вернуться в каталог.

- - На главную - - Открыть docs + Открыть каталог + Найти материал
); diff --git a/app/page.tsx b/app/page.tsx index 2f68bd4..d2a8695 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,192 +1,73 @@ +import type { Metadata } from "next"; import Link from "next/link"; -import { ArrowRight, Brain, BrainCircuit, ExternalLink, GitPullRequest, ListChecks } from "lucide-react"; +import { ArrowRight, BookOpenText, Github, GitPullRequest, Search, Sparkles } from "lucide-react"; -import { ContributorsSection } from "@/components/contributors/ContributorsSection"; -import { WhatsNewSection } from "@/components/home/WhatsNewSection"; +import { MaterialCard } from "@/components/cards/MaterialCard"; +import { TrackCard } from "@/components/cards/TrackCard"; +import { HeroSearch } from "@/components/home/HeroSearch"; +import { SearchLauncher } from "@/components/search/SearchDialog"; import { buttonVariants } from "@/components/ui/button"; +import { getCatalogStats, getTrackOverviews } from "@/lib/catalog"; +import { buildPageMetadata } from "@/lib/seo"; +import { formatWhatsNewDate, getWhatsNewOverview } from "@/lib/whats-new"; import { cn, REPO_URL } from "@/lib/utils"; -import { getContributorsOverview } from "@/lib/contributors"; export const dynamic = "force-static"; -const publicationRules = [ - { - title: "Публикуй материал в нужный трек", - description: "Размещай файл в соответствующем разделе документации и соблюдай структуру текущего трека." - }, - { - title: "Соблюдай единый формат", - description: "Используй понятную структуру: цель, шаги решения, код, результат и краткие выводы." - }, - { - title: "Оформляй кодовые примеры", - description: "Добавляй язык в код-блоки и оставляй только воспроизводимые, проверенные фрагменты." - }, - { - title: "Фиксируй контекст и источники", - description: "Если материал опирается на внешние источники, обязательно оставляй ссылки и пояснения." - }, - { - title: "Проверяй изменения перед PR", - description: "Перед публикацией запускай `npm run lint` и `npm run typecheck`, чтобы не ломать сборку." - } -]; - -const pullRequestLinks = [ - { - title: "Создать Pull Request", - href: `${REPO_URL}/compare?expand=1`, - description: "Откроет форму сравнения веток и создания PR для публикации." - }, - { - title: "Все Pull Request", - href: `${REPO_URL}/pulls`, - description: "Список открытых и закрытых PR по проекту." - }, - { - title: "Открытые Pull Request", - href: `${REPO_URL}/pulls?q=is%3Apr+is%3Aopen`, - description: "Быстрый фильтр для проверки, что уже находится на ревью." - } -]; +export const metadata: Metadata = buildPageMetadata({ + title: "Учебные материалы МИРЭА", + description: "Практики, разборы, ноутбуки и код по IT-дисциплинам МИРЭА — в едином каталоге с полнотекстовым поиском.", + pathname: "/" +}); export default function HomePage() { - const { authors, productTeam, contentManagers, devTeam } = getContributorsOverview(); + const tracks = getTrackOverviews(); + const populatedTracks = tracks.filter((track) => track.materialsCount > 0); + const stats = getCatalogStats(); + const recentMaterials = getWhatsNewOverview().materials; return ( -
-
-
-
- -
-

- - Центр документации StackMIREA -

- -

- Практики и ноутбуки -
- в едином формате docs -

- -

- Учебные материалы StackMIREA по ключевым IT-дисциплинам. Каждая страница оформлена как техническая - документация с кодом, разбором и привязкой к исходникам. -

- -
- - Открыть документацию - - - - - Спроси StackMIREA - +
+
+
+
+

Открытая база знаний для студентов МИРЭА

+

Учебные материалы МИРЭА без хаоса в файлах и репозиториях

+

Практики, разборы, ноутбуки и код по IT-дисциплинам — в едином каталоге с полнотекстовым поиском.

+ + + +
+ Найти материал + Открыть каталог + Как добавить материал →
-
-
-
-

- - Бета семантического поиска -

-

Новая навигация по материалам

-

- Есть вопрос? Можешь найти ответы на него в ИИ-поиске. - Например: `где есть KNN`, `покажи материалы по pandas`, `сравни MVC и OOP`. -

-
- - - Открыть "Спроси StackMIREA" - - -
+
+
{stats.materialsCount}материалов
+
{stats.populatedTracksCount}направлений
+
Полнотекстовыйпоиск по содержимому
+
Open Sourceоткрытый проект
- - - - -
-
-
- - Правила публикации -
-

Как публиковать материалы в StackMIREA

-
    - {publicationRules.map((rule, index) => ( -
  1. - - {index + 1} - -
    -

    {rule.title}

    -

    {rule.description}

    -
    -
  2. - ))} -
-
+
+

Основные направления

Начните с дисциплины

Здесь показаны только направления с опубликованными материалами.

Весь каталог
+
{populatedTracks.map((track) => )}
+
-
-
- - Публикация через PR -
-

Ссылки на Pull Request

-

- Для публикации открывай PR с изменениями в документации. В описании укажи, что именно добавлено и в каком - треке лежит материал. -

+
+

Новое и обновлённое

Последние материалы

+ {recentMaterials.length ?
{recentMaterials.map((material) => )}
:

История изменений недоступна в этой сборке. Все опубликованные материалы остаются доступны в каталоге.

} +
-
- {pullRequestLinks.map((item) => ( - - - {item.title} - {item.description} - - - - ))} -
-
+
+

Поиск и StackMIREA Ask

Ищите страницу или задайте вопрос

Глобальный поиск быстро находит конкретную тему. Ask помогает сформулировать запрос естественным языком и показывает подходящие фрагменты материалов.

Открыть поискКак работает Ask
+

Есть материал?

Помогите StackMIREA стать полезнее

Добавьте практику, исправьте неточность или предложите новое направление. На отдельной странице собраны точные шаги и команды проекта.

Добавить материал
+ +

Открытый проект

Код, материалы и развитие — на GitHub

Изучайте архитектуру, открывайте issues и отправляйте Pull Request.

Открыть GitHub
); } diff --git a/app/sitemap.ts b/app/sitemap.ts index 926ac2c..b8aec9c 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -4,7 +4,7 @@ import { getAllDocs } from "@/lib/navigation"; import { buildAbsoluteUrl } from "@/lib/utils"; export default function sitemap(): MetadataRoute.Sitemap { - const staticRoutes = ["", "/docs", "/ask"]; + const staticRoutes = ["", "/docs", "/ask", "/about", "/architecture", "/contribute"]; const docs = getAllDocs().map((doc) => doc.href); const allRoutes = [...staticRoutes, ...docs]; diff --git a/components/cards/MaterialCard.tsx b/components/cards/MaterialCard.tsx new file mode 100644 index 0000000..9fe157f --- /dev/null +++ b/components/cards/MaterialCard.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; +import { ArrowRight, Clock3 } from "lucide-react"; + +interface MaterialCardProps { + title: string; + href: string; + description: string; + sectionTitle: string; + typeLabel: string; + estimatedMinutes: number; + dateLabel?: string; + badge?: string; +} + +export function MaterialCard({ title, href, description, sectionTitle, typeLabel, estimatedMinutes, dateLabel, badge }: MaterialCardProps) { + return ( + +
{sectionTitle}{typeLabel}{badge ? {badge} : null}
+

{title}

+

{description}

+
≈ {estimatedMinutes} мин{dateLabel ? {dateLabel} : null}
+ + ); +} diff --git a/components/cards/TrackCard.tsx b/components/cards/TrackCard.tsx new file mode 100644 index 0000000..6496e6b --- /dev/null +++ b/components/cards/TrackCard.tsx @@ -0,0 +1,38 @@ +import Link from "next/link"; +import { ArrowRight, Bot, Brain, Code2, Database, GitPullRequest, ListChecks, Sigma } from "lucide-react"; + +import type { TrackOverview } from "@/lib/catalog"; +import type { TrackIconKey } from "@/lib/tracks"; +import { cn } from "@/lib/utils"; + +const iconByKey: Record = { + bot: Bot, + brain: Brain, + code2: Code2, + database: Database, + gitPullRequest: GitPullRequest, + listChecks: ListChecks, + sigma: Sigma +}; + +interface TrackCardProps { + track: TrackOverview; + compact?: boolean; +} + +export function TrackCard({ track, compact = false }: TrackCardProps) { + const Icon = iconByKey[track.iconKey]; + + return ( + +
+ + {track.materialsCount} {track.materialsCount === 1 ? "материал" : "материалов"} +
+

{track.title}

+

{track.description}

+ {track.topics.length ?
{track.topics.map((topic) => {topic})}
: null} + Открыть курс + + ); +} diff --git a/components/catalog/CatalogExplorer.tsx b/components/catalog/CatalogExplorer.tsx new file mode 100644 index 0000000..6f41a4d --- /dev/null +++ b/components/catalog/CatalogExplorer.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Search } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { TrackCard } from "@/components/cards/TrackCard"; +import type { TrackOverview } from "@/lib/catalog"; +import { cn } from "@/lib/utils"; + +interface CatalogExplorerProps { + tracks: TrackOverview[]; +} + +export function CatalogExplorer({ tracks }: CatalogExplorerProps) { + const populated = tracks.filter((track) => track.materialsCount > 0); + const upcoming = tracks.filter((track) => track.materialsCount === 0); + const [filter, setFilter] = useState("all"); + const [query, setQuery] = useState(""); + const visibleTracks = useMemo(() => { + const normalizedQuery = query.trim().toLocaleLowerCase("ru-RU"); + return populated.filter((track) => { + const matchesFilter = filter === "all" || track.id === filter; + const searchable = `${track.title} ${track.description} ${track.topics.join(" ")}`.toLocaleLowerCase("ru-RU"); + return matchesFilter && (!normalizedQuery || searchable.includes(normalizedQuery)); + }); + }, [filter, populated, query]); + + return ( + <> +
+ +
setQuery(event.target.value)} placeholder="Найти направление или технологию..." className="h-12 w-full rounded-xl border border-input bg-background pl-11 pr-4 text-sm outline-none transition-colors focus:border-primary focus:ring-2 focus:ring-ring/20" />
+
+ + {populated.map((track) => )} +
+
+ + {visibleTracks.length ?
{visibleTracks.map((track) => )}
:

Направление не найдено

Попробуйте изменить запрос или сбросить фильтр.

} + + {upcoming.length ? ( +
+

Скоро появятся

+

Направления уже запланированы, но в них пока нет опубликованных материалов.

+
+ Показать направления в разработке {upcoming.length} +
{upcoming.map((track) =>

{track.title}

В разработке

{track.description}

)}
+
+
+ ) : null} + + ); +} diff --git a/components/contributors/ContributorsSection.tsx b/components/contributors/ContributorsSection.tsx index 8d64659..9d9c56d 100644 --- a/components/contributors/ContributorsSection.tsx +++ b/components/contributors/ContributorsSection.tsx @@ -77,7 +77,7 @@ export function ContributorsSection({
-

StackMirea Product

+

Проект

{productTeam.map((member) => ( @@ -86,7 +86,7 @@ export function ContributorsSection({
-

Content Managers

+

Контент

{contentManagers.map((member) => (
-

Developers Group

+

Разработка

{devTeam.map((member) => ( diff --git a/components/docs/ArticleMetadata.tsx b/components/docs/ArticleMetadata.tsx new file mode 100644 index 0000000..81d4b02 --- /dev/null +++ b/components/docs/ArticleMetadata.tsx @@ -0,0 +1,17 @@ +import Link from "next/link"; +import { CalendarDays, Clock3 } from "lucide-react"; + +import { difficultyLabels, materialTypeLabels } from "@/lib/catalog"; +import type { ContentManifestDoc } from "@/lib/content-manifest"; + +export function ArticleMetadata({ doc }: { doc: ContentManifestDoc }) { + return ( +
+ {doc.sectionTitle} + {materialTypeLabels[doc.materialType]} + ≈ {doc.estimatedMinutes} мин + {doc.difficulty ? {difficultyLabels[doc.difficulty]} : null} + {doc.updatedAt ? Обновлено {new Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "long", year: "numeric" }).format(new Date(doc.updatedAt))} : null} +
+ ); +} diff --git a/components/docs/CourseOverview.tsx b/components/docs/CourseOverview.tsx new file mode 100644 index 0000000..e57729f --- /dev/null +++ b/components/docs/CourseOverview.tsx @@ -0,0 +1,56 @@ +import Link from "next/link"; +import { ArrowRight, Clock3 } from "lucide-react"; +import type { ReactNode } from "react"; + +import { CourseProgress } from "@/components/docs/CourseProgress"; +import { materialTypeLabels, type CourseRoadmapGroup, type TrackOverview } from "@/lib/catalog"; + +interface CourseOverviewProps { + track: TrackOverview; + roadmap: CourseRoadmapGroup[]; + introduction: ReactNode; +} + +export function CourseOverview({ track, roadmap, introduction }: CourseOverviewProps) { + const materials = roadmap.flatMap((group) => group.materials); + + return ( + <> +
+

Учебное направление

+

{track.title}

+

{track.description}

+
{track.materialsCount} материалов{track.topics.map((topic) => {topic})}
+
+ + {materials.length ? material.href)} /> : null} + +
+

О курсе

+
{introduction}
+
+ +
+

Маршрут курса

Идите по порядку или сразу откройте нужную тему.

+ {materials.length ?
+ {roadmap.map((group) => ( +
+

{group.title}

{group.description}

+
    + {group.materials.map((material) => ( +
  1. + + {material.order} + {material.title}{materialTypeLabels[material.materialType]}≈ {material.estimatedMinutes} мин + + +
  2. + ))} +
+
+ ))} +
:

В этом направлении пока нет опубликованных материалов.

Посмотрите другие направления или предложите первый материал.

Посмотреть направленияПредложить материал
} +
+ + ); +} diff --git a/components/docs/CourseProgress.tsx b/components/docs/CourseProgress.tsx new file mode 100644 index 0000000..9eb87d8 --- /dev/null +++ b/components/docs/CourseProgress.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { Check, Circle } from "lucide-react"; +import { useEffect, useState } from "react"; + +const STORAGE_KEY = "stackmirea:completed-materials"; +const PROGRESS_EVENT = "stackmirea:progress-change"; + +function readCompleted() { + try { + const value = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? "[]"); + return new Set(Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []); + } catch { + return new Set(); + } +} + +function writeCompleted(completed: Set) { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...completed])); + window.dispatchEvent(new Event(PROGRESS_EVENT)); + } catch { + // Storage may be disabled; the page remains fully usable without progress persistence. + } +} + +function useCompletedMaterials() { + const [completed, setCompleted] = useState>(new Set()); + + useEffect(() => { + const update = () => setCompleted(readCompleted()); + update(); + window.addEventListener(PROGRESS_EVENT, update); + window.addEventListener("storage", update); + return () => { + window.removeEventListener(PROGRESS_EVENT, update); + window.removeEventListener("storage", update); + }; + }, []); + + return completed; +} + +export function CourseProgress({ materialIds }: { materialIds: string[] }) { + const completed = useCompletedMaterials(); + const completedCount = materialIds.filter((id) => completed.has(id)).length; + const percent = materialIds.length ? Math.round((completedCount / materialIds.length) * 100) : 0; + + return ( +
+
Пройдено {completedCount} из {materialIds.length}{percent}%
+
+

Прогресс хранится только в этом браузере.

+
+ ); +} + +export function MaterialCompletion({ materialId }: { materialId: string }) { + const completed = useCompletedMaterials(); + const isCompleted = completed.has(materialId); + + function toggle() { + const next = readCompleted(); + if (next.has(materialId)) next.delete(materialId); + else next.add(materialId); + writeCompleted(next); + } + + return ( + + ); +} diff --git a/components/home/HeroSearch.tsx b/components/home/HeroSearch.tsx new file mode 100644 index 0000000..63ba435 --- /dev/null +++ b/components/home/HeroSearch.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { Search } from "lucide-react"; + +import { SearchLauncher } from "@/components/search/SearchDialog"; + +const examples = ["KNN", "MVC", "SQL", "React", "Hadoop"]; + +export function HeroSearch() { + return ( +
+ + + Найти тему, практику или технологию... + ⌘/Ctrl K + +
+ Например: + {examples.map((example) => {example})} +
+
+ ); +} diff --git a/components/layout/Footer.tsx b/components/layout/Footer.tsx index bfd7d1b..9ef250b 100644 --- a/components/layout/Footer.tsx +++ b/components/layout/Footer.tsx @@ -1,195 +1,26 @@ import Image from "next/image"; import Link from "next/link"; -import { - ArrowUpRight, - BookOpenText, - Bug, - Github, - Heart, - Lightbulb, - MessagesSquare, - Scale, - ShieldCheck -} from "lucide-react"; +import { Github } from "lucide-react"; import siteLogo from "@/public/favicon.svg"; -import { DEFAULT_BRANCH, REPO_URL, SITE_NAME } from "@/lib/utils"; +import { REPO_URL, SITE_NAME } from "@/lib/utils"; -const CODE_LICENSE_URL = `${REPO_URL}/blob/${DEFAULT_BRANCH}/LICENSE`; -const CONTENT_LICENSE_URL = `${REPO_URL}/blob/${DEFAULT_BRANCH}/CC-BY-NC-SA-4.0`; -const SUPPORT_URL = `${REPO_URL}/blob/${DEFAULT_BRANCH}/SUPPORT.md`; -const SECURITY_URL = `${REPO_URL}/blob/${DEFAULT_BRANCH}/SECURITY.md`; - -const footerSections = [ - { - title: "Платформа", - links: [ - { label: "Документация", href: "/docs", external: false }, - { label: "Спроси StackMIREA", href: "/ask", external: false }, - { label: "Авторы и команды", href: "/#contributors", external: false }, - { label: "История изменений", href: `${REPO_URL}/blob/${DEFAULT_BRANCH}/CHANGELOG.md`, external: true } - ] - }, - { - title: "Сообщество", - links: [ - { label: "Добавить материал", href: `${REPO_URL}/compare?expand=1`, external: true }, - { label: "Открытые задачи", href: `${REPO_URL}/issues`, external: true }, - { label: "Pull requests", href: `${REPO_URL}/pulls`, external: true }, - { label: "GitHub", href: REPO_URL, external: true } - ] - }, - { - title: "Контакты", - links: [ - { label: "Центр поддержки", href: SUPPORT_URL, external: true }, - { - label: "Сообщить об ошибке", - href: `${REPO_URL}/issues/new?template=bug_report.yml`, - external: true - }, - { - label: "Предложить идею", - href: `${REPO_URL}/issues/new?template=feature_request.yml`, - external: true - }, - { label: "Безопасность", href: SECURITY_URL, external: true } - ] - } -] as const; +const links = [ + { label: "Материалы", href: "/docs" }, + { label: "О проекте", href: "/about" }, + { label: "Архитектура", href: "/architecture" }, + { label: "Добавить материал", href: "/contribute" } +]; export function Footer() { return ( -
-
-
- -
-
-
-

Есть идея для StackMIREA?

-

- Предложите улучшение, новый учебный материал или помогите сделать платформу удобнее. -

-
- - Связаться с командой -
- -
-
- - - - - {SITE_NAME} - -

- Открытая платформа с практиками, разбором задач и учебными материалами по ключевым IT-дисциплинам. -

- -
- - {footerSections.map((section) => ( - - ))} -
- -
- - - - - Исходный код · MIT - Свободное использование и развитие проекта - -
- -
-

© {new Date().getFullYear()} {SITE_NAME}. Открытый образовательный проект.

-
- -
+
+
+
+ {SITE_NAME} +
+

Open-source educational project.

Код · MIT · Материалы · CC BY-NC-SA 4.0

); diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index c695617..0fd7211 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -1,62 +1,68 @@ -import Link from "next/link"; +"use client"; + import Image from "next/image"; -import { BookOpenText, BrainCircuit, Github, Home } from "lucide-react"; +import Link from "next/link"; +import { BookOpenText, Github, Menu, Search, X } from "lucide-react"; +import { useEffect, useState } from "react"; import siteLogo from "@/public/favicon.svg"; +import { SearchDialog, SearchLauncher } from "@/components/search/SearchDialog"; import { ThemeToggle } from "@/components/ui/ThemeToggle"; import { REPO_URL, SITE_NAME } from "@/lib/utils"; +const navigation = [ + { href: "/docs", label: "Материалы" }, + { href: "/about", label: "О проекте" }, + { href: "/contribute", label: "Добавить материал" } +]; + export function Header() { + const [menuOpen, setMenuOpen] = useState(false); + + useEffect(() => { + if (!menuOpen) return; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setMenuOpen(false); + }; + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [menuOpen]); + return (
-
-
- - {`Логотип - {SITE_NAME} +
+
+ + + {SITE_NAME} - -
+ + Поиск⌘/Ctrl K + + - - - GitHub - + +
+ + {menuOpen ? ( + + ) : null} +
); } diff --git a/components/layout/MobileDocsMenu.tsx b/components/layout/MobileDocsMenu.tsx index fa439f7..43986e3 100644 --- a/components/layout/MobileDocsMenu.tsx +++ b/components/layout/MobileDocsMenu.tsx @@ -29,14 +29,14 @@ export function MobileDocsMenu({ buildInfo, groups, currentPath }: MobileDocsMen return (
diff --git a/components/search/SearchDialog.tsx b/components/search/SearchDialog.tsx new file mode 100644 index 0000000..dcc878f --- /dev/null +++ b/components/search/SearchDialog.tsx @@ -0,0 +1,178 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ArrowRight, Loader2, Search, X } from "lucide-react"; +import { Fragment, useEffect, useMemo, useRef, useState } from "react"; + +import { useSearchIndex } from "@/components/search/useSearchIndex"; +import { runSemanticSearch, tokenizeSearchValue } from "@/lib/search"; +import { cn } from "@/lib/utils"; + +export const SEARCH_DIALOG_EVENT = "stackmirea:open-search"; +const examples = ["KNN", "MVC", "SQL", "React", "Hadoop"]; + +interface SearchDialogEventDetail { + query?: string; +} + +function HighlightedText({ text, query }: { text: string; query: string }) { + const tokens = useMemo( + () => tokenizeSearchValue(query).filter((token) => token.length > 1).slice(0, 6), + [query] + ); + + if (!tokens.length) return <>{text}; + + const pattern = new RegExp(`(${tokens.map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`, "gi"); + + return ( + <> + {text.split(pattern).map((part, index) => + tokens.some((token) => token.toLocaleLowerCase("ru-RU") === part.toLocaleLowerCase("ru-RU")) ? ( + {part} + ) : ( + {part} + ) + )} + + ); +} + +export function openGlobalSearch(query = "") { + window.dispatchEvent(new CustomEvent(SEARCH_DIALOG_EVENT, { detail: { query } })); +} + +export function SearchDialog() { + const router = useRouter(); + const inputRef = useRef(null); + const previousFocusRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const { index, error, isLoading } = useSearchIndex(isOpen); + const results = index && query.trim() ? runSemanticSearch(index, query) : []; + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + previousFocusRef.current = document.activeElement as HTMLElement | null; + setIsOpen(true); + } + if (event.key === "Escape") setIsOpen(false); + } + + function handleOpen(event: Event) { + const customEvent = event as CustomEvent; + previousFocusRef.current = document.activeElement as HTMLElement | null; + setQuery(customEvent.detail?.query ?? ""); + setSelectedIndex(0); + setIsOpen(true); + } + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener(SEARCH_DIALOG_EVENT, handleOpen); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener(SEARCH_DIALOG_EVENT, handleOpen); + }; + }, []); + + useEffect(() => { + if (!isOpen) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + window.setTimeout(() => inputRef.current?.focus(), 0); + return () => { + document.body.style.overflow = previousOverflow; + previousFocusRef.current?.focus(); + }; + }, [isOpen]); + + useEffect(() => setSelectedIndex(0), [query]); + + if (!isOpen) return null; + + function openResult(href: string) { + setIsOpen(false); + router.push(href); + } + + function handleInputKeyDown(event: React.KeyboardEvent) { + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((value) => Math.min(value + 1, Math.max(results.length - 1, 0))); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex((value) => Math.max(value - 1, 0)); + } else if (event.key === "Enter" && results[selectedIndex]) { + event.preventDefault(); + openResult(results[selectedIndex].doc.href); + } + } + + function handleDialogKeyDown(event: React.KeyboardEvent) { + if (event.key !== "Tab") return; + const focusable = [...event.currentTarget.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])')]; + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable.at(-1); + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + + return ( +
setIsOpen(false)}> +
event.stopPropagation()} onKeyDown={handleDialogKeyDown}> +
+
+ +
+ {isLoading ? ( +
Загружаю индекс
+ ) : error ? ( +

{error}

+ ) : !query.trim() ? ( +

Популярные запросы

{examples.map((example) => )}
+ ) : results.length ? ( +
    + {results.map((result, resultIndex) => ( +
  • + +
  • + ))} +
+ ) : ( +

Ничего не найдено

Попробуйте изменить формулировку или найти материал по технологии.

setIsOpen(false)} className="mt-4 inline-flex min-h-11 items-center text-sm font-medium text-primary">Открыть каталог
+ )} +
+ +
↑ ↓ — выбрать · Enter — открытьПолнотекстовый поиск по материалам
+
+
+ ); +} + +interface SearchLauncherProps { + query?: string; + children?: React.ReactNode; + className?: string; + ariaLabel?: string; +} + +export function SearchLauncher({ query = "", children, className, ariaLabel = "Открыть поиск" }: SearchLauncherProps) { + return ; +} diff --git a/components/search/useSearchIndex.ts b/components/search/useSearchIndex.ts index efee44e..e9bc11b 100644 --- a/components/search/useSearchIndex.ts +++ b/components/search/useSearchIndex.ts @@ -13,33 +13,52 @@ interface SearchIndexState { } const SEARCH_INDEX_ERROR = "Не удалось загрузить поисковый индекс. Проверь сборку `npm run search:build`."; +let cachedSearchIndex: ReturnType | null = null; +let searchIndexRequest: Promise> | null = null; -export function useSearchIndex(): SearchIndexState { +async function loadSearchIndex() { + if (cachedSearchIndex) return cachedSearchIndex; + if (!searchIndexRequest) { + searchIndexRequest = fetch(withBasePath("/search-index.json")) + .then((response) => { + if (!response.ok) throw new Error(`Search index request failed with status ${response.status}`); + return response.json() as Promise; + }) + .then((payload) => { + cachedSearchIndex = prepareSearchIndex(payload); + return cachedSearchIndex; + }) + .finally(() => { + searchIndexRequest = null; + }); + } + return searchIndexRequest; +} + +export function useSearchIndex(enabled = true): SearchIndexState { const [state, setState] = useState({ - index: null, + index: cachedSearchIndex, error: "", - isLoading: true + isLoading: !cachedSearchIndex }); useEffect(() => { + if (!enabled || state.index) { + return; + } + let isMounted = true; - async function loadIndex() { + async function resolveIndex() { try { - const response = await fetch(withBasePath("/search-index.json")); - - if (!response.ok) { - throw new Error(`Search index request failed with status ${response.status}`); - } - - const payload = (await response.json()) as SearchIndexPayload; + const preparedIndex = await loadSearchIndex(); if (!isMounted) { return; } setState({ - index: prepareSearchIndex(payload), + index: preparedIndex, error: "", isLoading: false }); @@ -56,12 +75,12 @@ export function useSearchIndex(): SearchIndexState { } } - void loadIndex(); + void resolveIndex(); return () => { isMounted = false; }; - }, []); + }, [enabled, state.index]); return state; } diff --git a/components/ui/Breadcrumbs.tsx b/components/ui/Breadcrumbs.tsx index bccf179..fdcfe05 100644 --- a/components/ui/Breadcrumbs.tsx +++ b/components/ui/Breadcrumbs.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import { ChevronRight } from "lucide-react"; -import { toTitleCase } from "@/lib/utils"; +import { getDocBreadcrumbParents } from "@/lib/docs-navigation"; interface BreadcrumbsProps { slug: string[]; @@ -9,22 +9,14 @@ interface BreadcrumbsProps { } export function Breadcrumbs({ slug, currentTitle }: BreadcrumbsProps) { - const breadcrumbs = slug.slice(0, -1).map((segment, index) => { - const href = `/docs/${slug.slice(0, index + 1).join("/")}`; - return { href, label: toTitleCase(segment) }; - }); + const breadcrumbs = getDocBreadcrumbParents(slug); return (