feat: Грибы Крыма — полная энциклопедия и справочник грибника

- Энциклопедия 20 видов грибов Крыма с детальными описаниями
- Интерактивный календарь грибника по месяцам
- Справочник: правила сбора, первая помощь, кулинария
- Поиск и фильтрация по съедобности и сезону
- Адаптивный дизайн, природная цветовая палитра
- Docker-конфигурация для деплоя

Tech: Next.js 15, TypeScript, Tailwind CSS 4, React 19
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Денис Шкабатур
2026-02-11 13:05:24 +03:00
parent 08263135dd
commit 72e07dad3d
28 changed files with 2598 additions and 104 deletions

View File

@@ -0,0 +1,29 @@
---
description: React component patterns for the project
globs: "**/*.tsx"
alwaysApply: false
---
# Component Patterns
## Structure
```tsx
// 1. Imports
// 2. Types/Interfaces
// 3. Component
// 4. Sub-components (if small)
```
## Rules
- Extract reusable logic into custom hooks (`src/hooks/`)
- Colocate component-specific types with the component
- Use `cn()` utility for conditional classNames
- Prefer composition over prop drilling
- Server Components by default, add `'use client'` only when needed
- Keep components under 150 lines — extract if larger
## Accessibility
- All images must have descriptive `alt` text
- Interactive elements must be keyboard-accessible
- Use proper heading hierarchy (h1 → h2 → h3)
- Color contrast ratio ≥ 4.5:1

View File

@@ -0,0 +1,22 @@
---
description: Data patterns for mushroom encyclopedia
globs: "src/data/**"
alwaysApply: false
---
# Data Patterns
## Mushroom Data Structure
Each mushroom entry must include:
- Scientific name (Latin), Russian common name(s)
- Edibility classification: edible / conditionally-edible / inedible / poisonous
- Detailed description: cap, stem, flesh, spore print
- Habitat, season months, locations in Crimea
- Look-alikes with distinguishing features
- Photo URL(s)
## Data Accuracy
- Cross-reference with mycological sources
- Always include toxicity warnings for poisonous species
- Mark conditionally-edible species with preparation requirements
- Include confidence level for rare/disputed species

View File

@@ -0,0 +1,26 @@
---
description: Core project conventions for Crimean Mushroom Encyclopedia
alwaysApply: true
---
# Project: Грибы Крыма — Encyclopedia & Calendar
## Tech Stack
- Next.js 15 (App Router), TypeScript strict, Tailwind CSS 4, React 19
- Static data (JSON) — no backend/database needed
- Deploy target: static export or Node.js server
## Architecture
- `src/app/` — pages (App Router)
- `src/components/` — reusable UI components
- `src/data/` — mushroom database (JSON/TS)
- `src/lib/` — utilities, helpers, types
- `public/images/` — mushroom photos
## Coding Standards
- Functional components only, prefer named exports
- Use `interface` for props, `type` for unions/intersections
- All components must be typed — no `any`
- Russian UI text, English code identifiers
- Use semantic HTML elements
- Mobile-first responsive design

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
.next
.git
*.md
.cursor
.env*
Dockerfile
docker-compose.yml

24
.gitignore vendored
View File

@@ -1,6 +1,4 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
# Dependencies
/node_modules
/.pnp
.pnp.*
@@ -10,32 +8,30 @@
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
# Next.js
/.next/
/out/
# production
# Production
/build
# misc
# Misc
.DS_Store
*.pem
# debug
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# Env files
.env*.local
.env
# vercel
# Vercel
.vercel
# typescript
# TypeScript
*.tsbuildinfo
next-env.d.ts

44
Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

92
PROGRESS.md Normal file
View File

@@ -0,0 +1,92 @@
# Грибы Крыма — Прогресс разработки
## Статус: В разработке
## Архитектура
### Tech Stack
- **Framework**: Next.js 15 (App Router) + React 19
- **Language**: TypeScript (strict mode)
- **Styling**: Tailwind CSS 4
- **Data**: Статические JSON/TS файлы (без БД)
- **Deploy**: Static export → самохост на git.dshkabatur.ru
### Структура страниц
| Страница | Route | Описание |
|----------|-------|----------|
| Главная | `/` | Hero, избранные грибы, сезон сейчас |
| Энциклопедия | `/encyclopedia` | Каталог всех грибов с фильтрами |
| Карточка гриба | `/encyclopedia/[slug]` | Детальная страница гриба |
| Календарь | `/calendar` | Интерактивный календарь грибника |
| Справочник | `/guide` | Правила сбора, советы, первая помощь |
### Компоненты
- `Header` — навигация, логотип
- `Footer` — контакты, копирайт
- `MushroomCard` — карточка гриба в каталоге
- `MushroomDetail` — детальная информация
- `SeasonCalendar` — календарь по месяцам
- `SearchBar` — поиск с автодополнением
- `FilterPanel` — фильтры (съедобность, сезон, место)
- `EdibilityBadge` — бейдж съедобности
---
## Чеклист
### Фаза 1: Настройка
- [x] Cursor rules
- [x] Файл прогресса
- [ ] Инициализация Next.js проекта
- [ ] Настройка Tailwind CSS
- [ ] Базовый layout (Header/Footer)
### Фаза 2: Данные
- [ ] Типы TypeScript для грибов
- [ ] База данных грибов (20+ видов Крыма)
- [ ] Фотографии грибов
### Фаза 3: Страницы
- [ ] Главная страница
- [ ] Энциклопедия (каталог + фильтры)
- [ ] Детальная страница гриба
- [ ] Календарь грибника
- [ ] Справочник грибника
### Фаза 4: Полировка
- [ ] Адаптивность (mobile/tablet/desktop)
- [ ] SEO мета-теги
- [ ] Анимации и переходы
- [ ] Accessibility проверка
### Фаза 5: Деплой
- [ ] Git репозиторий
- [ ] Деплой на сервер
---
## Детали реализации
### Данные о грибах
Каждый гриб содержит:
- Научное название (латынь)
- Русское название (одно или несколько)
- Классификация съедобности: съедобный / условно-съедобный / несъедобный / ядовитый
- Описание: шляпка, ножка, мякоть, споровый порошок
- Места произрастания в Крыму
- Сезон сбора (по месяцам)
- Похожие виды (двойники)
- Фотографии
### Календарь
- 12 месяцев, интерактивный
- При клике на месяц — список грибов сезона
- Визуальная шкала обилия (мало/средне/много)
- Текущий месяц выделен
### Справочник
- Правила безопасного сбора
- Как отличить ядовитые от съедобных
- Первая помощь при отравлении
- Способы приготовления
- Снаряжение грибника

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
grib:
build: .
ports:
- "3000:3000"
restart: unless-stopped
environment:
- NODE_ENV=production

View File

@@ -1,7 +1,15 @@
import type { NextConfig } from "next";
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
/* config options here */
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
],
},
};
export default nextConfig;

20
package-lock.json generated
View File

@@ -8,6 +8,8 @@
"name": "grib",
"version": "0.1.0",
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
@@ -2576,6 +2578,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4833,6 +4844,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.563.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz",
"integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View File

@@ -9,6 +9,8 @@
"lint": "eslint"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"

233
src/app/calendar/page.tsx Normal file
View File

@@ -0,0 +1,233 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react';
import { mushrooms } from '@/data/mushrooms';
import { EdibilityBadge } from '@/components/EdibilityBadge';
import { cn, getCurrentMonth, getMushroomsByMonth, getMushroomAbundance } from '@/lib/utils';
import { MONTH_NAMES, type Month } from '@/lib/types';
import type { Metadata } from 'next';
const abundanceLabel: Record<string, string> = {
rare: 'Редко',
moderate: 'Умеренно',
abundant: 'Обильно',
};
const abundanceDot: Record<string, string> = {
rare: 'bg-amber-300',
moderate: 'bg-forest-400',
abundant: 'bg-forest-600',
};
const monthDescriptions: Record<number, string> = {
1: 'Январь — самый тихий месяц для грибника. В тёплые зимы можно найти вёшенки на стволах деревьев.',
2: 'Февраль похож на январь. Вёшенки продолжают плодоносить в буковых лесах горного Крыма.',
3: 'Весна начинается. Появляются первые вёшенки. В лесу пока ещё прохладно для массового роста грибов.',
4: 'Апрель — месяц первых весенних грибов. Вёшенки обильны, могут появиться первые сморчки.',
5: 'Май — начало грибного сезона. Появляются первые маслята и шампиньоны после весенних дождей.',
6: 'Июнь — разнообразие нарастает. Лисички, маслята, белые грибы начинают появляться в горных лесах.',
7: 'Июль — жаркий месяц. Грибы появляются после дождей. В горных лесах можно найти белые, лисички, рыжики.',
8: 'Август — активный месяц. Дождевики, маслята, моховики обильны. Начинают появляться осенние виды.',
9: 'Сентябрь — разгар сезона! Максимальное разнообразие видов. Белые грибы, лисички, опята, рыжики.',
10: 'Октябрь — пик грибного сезона в Крыму! Мышата, опята, маслята, белые — всё в изобилии.',
11: 'Ноябрь — продолжение осеннего сезона. Мышата, опята, вёшенки в большом количестве.',
12: 'Декабрь — завершение сезона. Мышата, опята, вёшенки — последний шанс набрать корзинку.',
};
export default function CalendarPage() {
const currentMonth = getCurrentMonth();
const [selectedMonth, setSelectedMonth] = useState<Month>(currentMonth);
const monthMushrooms = getMushroomsByMonth(mushrooms, selectedMonth);
const prevMonth = () => {
setSelectedMonth((m) => (m === 1 ? 12 : m - 1) as Month);
};
const nextMonth = () => {
setSelectedMonth((m) => (m === 12 ? 1 : m + 1) as Month);
};
return (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-100">
<Calendar className="h-5 w-5 text-amber-600" />
</div>
<h1 className="text-3xl font-bold text-foreground">Календарь грибника</h1>
</div>
<p className="text-muted-foreground ml-[52px]">
Узнайте, какие грибы растут в каждом месяце в Крыму
</p>
</div>
{/* Month Selector - Grid */}
<div className="grid grid-cols-4 sm:grid-cols-6 lg:grid-cols-12 gap-2 mb-8">
{Array.from({ length: 12 }, (_, i) => {
const month = (i + 1) as Month;
const count = getMushroomsByMonth(mushrooms, month).length;
const isCurrent = month === currentMonth;
const isSelected = month === selectedMonth;
return (
<button
key={month}
onClick={() => setSelectedMonth(month)}
className={cn(
'relative rounded-xl border p-3 text-center transition-all',
isSelected
? 'border-forest-400 bg-forest-50 shadow-sm ring-2 ring-forest-200'
: isCurrent
? 'border-amber-300 bg-amber-50 hover:shadow-sm'
: 'border-border bg-card hover:bg-muted hover:shadow-sm'
)}
>
<div className={cn(
'text-xs font-semibold',
isSelected ? 'text-forest-700' : isCurrent ? 'text-amber-700' : 'text-muted-foreground'
)}>
{MONTH_NAMES[month].slice(0, 3)}
</div>
<div className={cn(
'mt-1 text-lg font-bold',
isSelected ? 'text-forest-800' : 'text-foreground'
)}>
{count}
</div>
<div className="text-[10px] text-muted-foreground">
{count === 0 ? 'нет' : count === 1 ? 'вид' : count < 5 ? 'вида' : 'видов'}
</div>
{isCurrent && (
<div className="absolute -top-1 -right-1 h-3 w-3 rounded-full bg-amber-400 border-2 border-white" />
)}
</button>
);
})}
</div>
{/* Selected Month Content */}
<div className="rounded-2xl border border-border bg-card overflow-hidden">
{/* Month Header */}
<div className="flex items-center justify-between border-b border-border bg-muted/50 px-6 py-4">
<button
onClick={prevMonth}
className="rounded-lg p-1.5 hover:bg-white transition-colors"
aria-label="Предыдущий месяц"
>
<ChevronLeft className="h-5 w-5 text-muted-foreground" />
</button>
<div className="text-center">
<h2 className="text-xl font-bold text-foreground">
{MONTH_NAMES[selectedMonth]}
</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{monthMushrooms.length} {monthMushrooms.length === 1 ? 'вид' : monthMushrooms.length < 5 ? 'вида' : 'видов'} грибов
</p>
</div>
<button
onClick={nextMonth}
className="rounded-lg p-1.5 hover:bg-white transition-colors"
aria-label="Следующий месяц"
>
<ChevronRight className="h-5 w-5 text-muted-foreground" />
</button>
</div>
{/* Month Description */}
<div className="px-6 py-4 border-b border-border bg-amber-50/30">
<p className="text-sm text-muted-foreground leading-relaxed">
{monthDescriptions[selectedMonth]}
</p>
</div>
{/* Mushroom List */}
<div className="divide-y divide-border">
{monthMushrooms.length > 0 ? (
monthMushrooms
.sort((a, b) => {
const aAbundance = getMushroomAbundance(a, selectedMonth);
const bAbundance = getMushroomAbundance(b, selectedMonth);
const order = { abundant: 0, moderate: 1, rare: 2, none: 3 };
return order[aAbundance] - order[bAbundance];
})
.map((mushroom) => {
const abundance = getMushroomAbundance(mushroom, selectedMonth);
return (
<Link
key={mushroom.id}
href={`/encyclopedia/${mushroom.slug}`}
className="flex items-center gap-4 px-6 py-4 hover:bg-muted/50 transition-colors group"
>
{/* Image */}
<div className="h-14 w-14 flex-shrink-0 overflow-hidden rounded-xl bg-muted">
<img
src={mushroom.imageUrl}
alt={mushroom.name}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-foreground group-hover:text-forest-700 transition-colors truncate">
{mushroom.name}
</h3>
<EdibilityBadge edibility={mushroom.edibility} size="sm" />
</div>
<p className="text-xs italic text-muted-foreground mt-0.5">
{mushroom.scientificName}
</p>
</div>
{/* Abundance */}
<div className="flex items-center gap-2 flex-shrink-0">
<span className={cn('h-2.5 w-2.5 rounded-full', abundanceDot[abundance])} />
<span className="text-xs font-medium text-muted-foreground hidden sm:inline">
{abundanceLabel[abundance]}
</span>
</div>
</Link>
);
})
) : (
<div className="px-6 py-12 text-center">
<Calendar className="mx-auto h-10 w-10 text-muted-foreground/30" />
<p className="mt-3 text-sm text-muted-foreground">
В этом месяце грибной сезон не активен
</p>
<p className="mt-1 text-xs text-muted-foreground">
Основной сезон в Крыму с мая по декабрь
</p>
</div>
)}
</div>
</div>
{/* Legend */}
<div className="mt-6 flex flex-wrap items-center gap-6 text-sm text-muted-foreground">
<span className="font-medium">Обилие:</span>
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-amber-300" />
Редко
</span>
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-forest-400" />
Умеренно
</span>
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-forest-600" />
Обильно
</span>
<span className="flex items-center gap-1.5">
<span className="h-3 w-3 rounded-full bg-amber-400 border-2 border-white shadow-sm" />
Текущий месяц
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,259 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, MapPin, AlertTriangle, Lightbulb, ChefHat, Info } from 'lucide-react';
import { mushrooms, getMushroomBySlug } from '@/data/mushrooms';
import { EdibilityBadge } from '@/components/EdibilityBadge';
import { SeasonBar } from '@/components/SeasonBar';
import { getActiveSeasonMonths, getSeasonLabel } from '@/lib/utils';
import type { Metadata } from 'next';
interface PageProps {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
return mushrooms.map((m) => ({ slug: m.slug }));
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const mushroom = getMushroomBySlug(slug);
if (!mushroom) return {};
return {
title: `${mushroom.name} (${mushroom.scientificName})`,
description: mushroom.description,
};
}
export default async function MushroomDetailPage({ params }: PageProps) {
const { slug } = await params;
const mushroom = getMushroomBySlug(slug);
if (!mushroom) {
notFound();
}
const activeMonths = getActiveSeasonMonths(mushroom);
const seasonLabel = getSeasonLabel(activeMonths);
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
{/* Back */}
<Link
href="/encyclopedia"
className="inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors mb-6"
>
<ArrowLeft className="h-4 w-4" />
Назад к энциклопедии
</Link>
{/* Hero */}
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
{/* Image */}
<div className="relative aspect-[4/3] overflow-hidden rounded-2xl bg-muted shadow-lg">
<img
src={mushroom.imageUrl}
alt={mushroom.name}
className="h-full w-full object-cover"
/>
</div>
{/* Info */}
<div>
<EdibilityBadge edibility={mushroom.edibility} size="lg" />
<h1 className="mt-4 text-3xl font-bold text-foreground sm:text-4xl">
{mushroom.name}
</h1>
<p className="mt-1 text-lg italic text-muted-foreground">
{mushroom.scientificName}
</p>
{mushroom.nameAlt && mushroom.nameAlt.length > 0 && (
<p className="mt-1 text-sm text-muted-foreground">
Также: {mushroom.nameAlt.join(', ')}
</p>
)}
<p className="mt-1 text-sm text-muted-foreground">
Семейство: {mushroom.family}
</p>
<p className="mt-4 text-base text-foreground leading-relaxed">
{mushroom.description}
</p>
{/* Season */}
<div className="mt-6">
<h3 className="text-sm font-semibold text-foreground mb-2">
Сезон сбора: {seasonLabel}
</h3>
<SeasonBar season={mushroom.season} />
</div>
{/* Locations */}
<div className="mt-5">
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-1.5">
<MapPin className="h-4 w-4 text-forest-600" />
Где искать в Крыму
</h3>
<div className="flex flex-wrap gap-2">
{mushroom.locations.map((loc) => (
<span
key={loc}
className="rounded-lg bg-forest-50 border border-forest-100 px-3 py-1 text-xs font-medium text-forest-700"
>
{loc}
</span>
))}
</div>
</div>
</div>
</div>
{/* Details */}
<div className="mt-12 grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Description cards */}
{[
{ title: 'Шляпка', content: mushroom.cap, icon: '🍄' },
{ title: 'Ножка', content: mushroom.stem, icon: '🌿' },
{ title: 'Мякоть', content: mushroom.flesh, icon: '🔬' },
{ title: 'Споровый порошок', content: mushroom.sporePrint, icon: '🌫️' },
].map((detail) => (
<div
key={detail.title}
className="rounded-xl border border-border bg-card p-5"
>
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<span>{detail.icon}</span>
{detail.title}
</h3>
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">
{detail.content}
</p>
</div>
))}
</div>
{/* Habitat */}
<div className="mt-6 rounded-xl border border-border bg-card p-5">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<span>🌲</span> Места обитания
</h3>
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">
{mushroom.habitat}
</p>
</div>
{/* Warnings */}
{mushroom.warnings && (
<div className="mt-6 rounded-xl border border-red-200 bg-red-50 p-5">
<h3 className="text-sm font-semibold text-red-700 flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Предупреждение
</h3>
<p className="mt-2 text-sm text-red-600 leading-relaxed">
{mushroom.warnings}
</p>
</div>
)}
{/* Lookalikes */}
{mushroom.lookalikes && mushroom.lookalikes.length > 0 && (
<div className="mt-6">
<h3 className="text-lg font-bold text-foreground mb-4 flex items-center gap-2">
<Info className="h-5 w-5 text-amber-500" />
Похожие виды (двойники)
</h3>
<div className="space-y-3">
{mushroom.lookalikes.map((lookalike) => (
<div
key={lookalike.name}
className={`rounded-xl border p-4 ${
lookalike.dangerous
? 'border-red-200 bg-red-50/50'
: 'border-border bg-card'
}`}
>
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-foreground">
{lookalike.slug ? (
<Link href={`/encyclopedia/${lookalike.slug}`} className="hover:text-forest-600 underline underline-offset-2">
{lookalike.name}
</Link>
) : (
lookalike.name
)}
</h4>
{lookalike.dangerous && (
<span className="rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-semibold text-red-700 border border-red-200">
ОПАСНЫЙ
</span>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
<span className="font-medium">Отличия:</span> {lookalike.difference}
</p>
</div>
))}
</div>
</div>
)}
{/* Cooking */}
{mushroom.cookingTips && (
<div className="mt-6 rounded-xl border border-forest-200 bg-forest-50/50 p-5">
<h3 className="text-sm font-semibold text-forest-700 flex items-center gap-2">
<ChefHat className="h-4 w-4" />
Кулинарные советы
</h3>
<p className="mt-2 text-sm text-forest-600 leading-relaxed">
{mushroom.cookingTips}
</p>
</div>
)}
{/* Fun fact */}
{mushroom.funFact && (
<div className="mt-6 rounded-xl border border-amber-200 bg-amber-50/50 p-5">
<h3 className="text-sm font-semibold text-amber-700 flex items-center gap-2">
<Lightbulb className="h-4 w-4" />
Интересный факт
</h3>
<p className="mt-2 text-sm text-amber-600 leading-relaxed">
{mushroom.funFact}
</p>
</div>
)}
{/* Navigation */}
<div className="mt-12 border-t border-border pt-8">
<h3 className="text-lg font-bold text-foreground mb-4">Другие грибы</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{mushrooms
.filter((m) => m.id !== mushroom.id)
.slice(0, 4)
.map((m) => (
<Link
key={m.id}
href={`/encyclopedia/${m.slug}`}
className="group rounded-xl border border-border p-3 hover:border-forest-200 hover:shadow-sm transition-all"
>
<div className="aspect-square rounded-lg overflow-hidden mb-2">
<img
src={m.imageUrl}
alt={m.name}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
</div>
<p className="text-sm font-medium text-foreground group-hover:text-forest-700 truncate">
{m.name}
</p>
<p className="text-xs text-muted-foreground italic truncate">
{m.scientificName}
</p>
</Link>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,186 @@
'use client';
import { useState, useMemo } from 'react';
import { Search, SlidersHorizontal, X, BookOpen } from 'lucide-react';
import { mushrooms } from '@/data/mushrooms';
import { MushroomCard } from '@/components/MushroomCard';
import { filterMushrooms } from '@/lib/utils';
import { EDIBILITY_LABELS, MONTH_NAMES, type Edibility, type Month } from '@/lib/types';
import { cn } from '@/lib/utils';
import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
function EncyclopediaContent() {
const searchParams = useSearchParams();
const initialEdibility = searchParams.get('edibility') || '';
const [query, setQuery] = useState('');
const [edibility, setEdibility] = useState(initialEdibility);
const [month, setMonth] = useState<string>('');
const [showFilters, setShowFilters] = useState(!!initialEdibility);
const filtered = useMemo(() => {
return filterMushrooms(
mushrooms,
query,
edibility || undefined,
month ? (parseInt(month) as Month) : undefined
);
}, [query, edibility, month]);
const hasFilters = query || edibility || month;
return (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-forest-100">
<BookOpen className="h-5 w-5 text-forest-600" />
</div>
<h1 className="text-3xl font-bold text-foreground">Энциклопедия грибов</h1>
</div>
<p className="text-muted-foreground ml-[52px]">
{mushrooms.length} видов грибов Крымского полуострова
</p>
</div>
{/* Search & Filters */}
<div className="mb-8 space-y-4">
{/* Search */}
<div className="flex gap-3">
<div className="relative flex-1">
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Поиск по названию, латинскому имени..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-xl border border-border bg-white py-2.5 pl-10 pr-4 text-sm outline-none transition-colors focus:border-forest-400 focus:ring-2 focus:ring-forest-100"
/>
{query && (
<button
onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={cn(
'flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-colors',
showFilters || hasFilters
? 'border-forest-300 bg-forest-50 text-forest-700'
: 'border-border bg-white text-muted-foreground hover:bg-muted'
)}
>
<SlidersHorizontal className="h-4 w-4" />
<span className="hidden sm:inline">Фильтры</span>
</button>
</div>
{/* Filter Panel */}
{showFilters && (
<div className="rounded-xl border border-border bg-white p-4 animate-fade-in">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Edibility */}
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Съедобность
</label>
<div className="flex flex-wrap gap-2">
{(['', 'edible', 'conditionally-edible', 'poisonous'] as const).map((val) => (
<button
key={val}
onClick={() => setEdibility(val)}
className={cn(
'rounded-lg px-3 py-1.5 text-xs font-medium transition-colors border',
edibility === val
? 'border-forest-300 bg-forest-50 text-forest-700'
: 'border-border bg-white text-muted-foreground hover:bg-muted'
)}
>
{val ? EDIBILITY_LABELS[val as Edibility] : 'Все'}
</button>
))}
</div>
</div>
{/* Month */}
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Месяц сбора
</label>
<select
value={month}
onChange={(e) => setMonth(e.target.value)}
className="w-full rounded-lg border border-border bg-white px-3 py-1.5 text-sm outline-none focus:border-forest-400"
>
<option value="">Любой месяц</option>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<option key={m} value={m}>
{MONTH_NAMES[m as Month]}
</option>
))}
</select>
</div>
</div>
{hasFilters && (
<button
onClick={() => { setQuery(''); setEdibility(''); setMonth(''); }}
className="mt-3 text-xs font-medium text-forest-600 hover:text-forest-700"
>
Сбросить все фильтры
</button>
)}
</div>
)}
</div>
{/* Results */}
{filtered.length > 0 ? (
<>
<p className="mb-4 text-sm text-muted-foreground">
Найдено: {filtered.length} {filtered.length === 1 ? 'вид' : filtered.length < 5 ? 'вида' : 'видов'}
</p>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map((m) => (
<MushroomCard key={m.id} mushroom={m} />
))}
</div>
</>
) : (
<div className="py-16 text-center">
<BookOpen className="mx-auto h-12 w-12 text-muted-foreground/30" />
<h3 className="mt-4 text-lg font-semibold text-foreground">Ничего не найдено</h3>
<p className="mt-2 text-sm text-muted-foreground">
Попробуйте изменить параметры поиска или фильтры
</p>
</div>
)}
</div>
);
}
export default function EncyclopediaPage() {
return (
<Suspense fallback={
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="animate-pulse space-y-4">
<div className="h-10 w-64 bg-muted rounded-lg" />
<div className="h-12 bg-muted rounded-xl" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-80 bg-muted rounded-2xl" />
))}
</div>
</div>
</div>
}>
<EncyclopediaContent />
</Suspense>
);
}

View File

@@ -1,26 +1,142 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--color-forest-50: #f0f7ec;
--color-forest-100: #dcefd3;
--color-forest-200: #b8dfaa;
--color-forest-300: #8cc978;
--color-forest-400: #65b34e;
--color-forest-500: #4a9435;
--color-forest-600: #3a7629;
--color-forest-700: #2f5c23;
--color-forest-800: #294a21;
--color-forest-900: #243f1e;
--color-forest-950: #0f220c;
--color-amber-50: #fffbeb;
--color-amber-100: #fef3c7;
--color-amber-200: #fde68a;
--color-amber-400: #fbbf24;
--color-amber-500: #f59e0b;
--color-amber-600: #d97706;
--color-amber-700: #b45309;
--color-earth-50: #faf6f1;
--color-earth-100: #f0e6d6;
--color-earth-200: #e0ccad;
--color-earth-400: #c19a5e;
--color-earth-600: #8c6422;
--color-earth-700: #6e4e1a;
--color-earth-800: #5a3f17;
--background: #faf9f7;
--foreground: #1a1a1a;
--card: #ffffff;
--card-foreground: #1a1a1a;
--muted: #f4f2ee;
--muted-foreground: #6b6560;
--border: #e5e0d8;
--ring: #4a9435;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-ring: var(--ring);
--color-forest-50: var(--color-forest-50);
--color-forest-100: var(--color-forest-100);
--color-forest-200: var(--color-forest-200);
--color-forest-300: var(--color-forest-300);
--color-forest-400: var(--color-forest-400);
--color-forest-500: var(--color-forest-500);
--color-forest-600: var(--color-forest-600);
--color-forest-700: var(--color-forest-700);
--color-forest-800: var(--color-forest-800);
--color-forest-900: var(--color-forest-900);
--color-forest-950: var(--color-forest-950);
--color-earth-50: var(--color-earth-50);
--color-earth-100: var(--color-earth-100);
--color-earth-200: var(--color-earth-200);
--color-earth-400: var(--color-earth-400);
--color-earth-600: var(--color-earth-600);
--color-earth-700: var(--color-earth-700);
--color-earth-800: var(--color-earth-800);
--color-amber-50: var(--color-amber-50);
--color-amber-100: var(--color-amber-100);
--color-amber-200: var(--color-amber-200);
--color-amber-400: var(--color-amber-400);
--color-amber-500: var(--color-amber-500);
--color-amber-600: var(--color-amber-600);
--color-amber-700: var(--color-amber-700);
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-sans);
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--muted);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out forwards;
}
.animate-slide-in {
animation: slideIn 0.4s ease-out forwards;
}
/* Staggered animation delays */
.delay-100 { animation-delay: 0.1s; }
.delay-200 { animation-delay: 0.2s; }
.delay-300 { animation-delay: 0.3s; }
.delay-400 { animation-delay: 0.4s; }
/* Image hover zoom */
.img-zoom {
overflow: hidden;
}
.img-zoom img {
transition: transform 0.5s ease;
}
.img-zoom:hover img {
transform: scale(1.07);
}

149
src/app/guide/page.tsx Normal file
View File

@@ -0,0 +1,149 @@
import { Shield, AlertTriangle, Heart, ChefHat, Backpack, MapPin } from 'lucide-react';
import { guideSections } from '@/data/guide';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Справочник грибника',
description: 'Правила безопасного сбора грибов, первая помощь при отравлении, советы по приготовлению и лучшие грибные места Крыма.',
};
const iconMap: Record<string, typeof Shield> = {
Shield,
AlertTriangle,
Heart,
ChefHat,
Backpack,
MapPin,
};
const sectionColors: Record<string, { bg: string; icon: string; border: string }> = {
safety: { bg: 'bg-forest-50', icon: 'text-forest-600', border: 'border-forest-200' },
identification: { bg: 'bg-amber-50', icon: 'text-amber-600', border: 'border-amber-200' },
firstaid: { bg: 'bg-red-50', icon: 'text-red-600', border: 'border-red-200' },
cooking: { bg: 'bg-emerald-50', icon: 'text-emerald-600', border: 'border-emerald-200' },
equipment: { bg: 'bg-blue-50', icon: 'text-blue-600', border: 'border-blue-200' },
'crimea-spots': { bg: 'bg-purple-50', icon: 'text-purple-600', border: 'border-purple-200' },
};
export default function GuidePage() {
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-10">
<div className="flex items-center gap-3 mb-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-100">
<Shield className="h-5 w-5 text-blue-600" />
</div>
<h1 className="text-3xl font-bold text-foreground">Справочник грибника</h1>
</div>
<p className="text-muted-foreground ml-[52px]">
Всё, что нужно знать для безопасного и успешного сбора грибов в Крыму
</p>
</div>
{/* Quick nav */}
<nav className="mb-10 rounded-xl border border-border bg-card p-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
Содержание
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{guideSections.map((section) => {
const colors = sectionColors[section.id] || sectionColors.safety;
const Icon = iconMap[section.icon] || Shield;
return (
<a
key={section.id}
href={`#${section.id}`}
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
<Icon className={`h-4 w-4 ${colors.icon}`} />
{section.title}
</a>
);
})}
</div>
</nav>
{/* Sections */}
<div className="space-y-8">
{guideSections.map((section) => {
const colors = sectionColors[section.id] || sectionColors.safety;
const Icon = iconMap[section.icon] || Shield;
return (
<article
key={section.id}
id={section.id}
className={`rounded-2xl border ${colors.border} overflow-hidden scroll-mt-24`}
>
{/* Section Header */}
<div className={`${colors.bg} px-6 py-5 border-b ${colors.border}`}>
<div className="flex items-center gap-3">
<Icon className={`h-6 w-6 ${colors.icon}`} />
<h2 className="text-xl font-bold text-foreground">{section.title}</h2>
</div>
</div>
{/* Content */}
<div className="bg-white px-6 py-6">
<ul className="space-y-4">
{section.content.map((item, i) => (
<li key={i} className="flex gap-3">
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-muted text-xs font-bold text-muted-foreground">
{i + 1}
</span>
<p className="text-sm text-foreground leading-relaxed pt-0.5">{item}</p>
</li>
))}
</ul>
{/* Warning */}
{section.warning && (
<div className="mt-6 rounded-xl border border-red-200 bg-red-50 p-4">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
<p className="text-sm text-red-700 font-medium leading-relaxed">
{section.warning}
</p>
</div>
</div>
)}
{/* Tips */}
{section.tips && section.tips.length > 0 && (
<div className="mt-6 rounded-xl border border-forest-200 bg-forest-50/50 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-forest-700 mb-3">
Полезные советы
</h3>
<ul className="space-y-2">
{section.tips.map((tip, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-forest-700">
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-forest-400 flex-shrink-0" />
{tip}
</li>
))}
</ul>
</div>
)}
</div>
</article>
);
})}
</div>
{/* Emergency */}
<div className="mt-10 rounded-2xl border-2 border-red-300 bg-red-50 p-6 text-center">
<h2 className="text-lg font-bold text-red-700">
Экстренная помощь при отравлении грибами
</h2>
<p className="mt-2 text-3xl font-extrabold text-red-800">
103 или 112
</p>
<p className="mt-2 text-sm text-red-600">
При первых признаках отравления немедленно звоните в скорую помощь.
Сохраните остатки грибов для анализа.
</p>
</div>
</div>
);
}

View File

@@ -1,20 +1,31 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import './globals.css';
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
const inter = Inter({
subsets: ['latin', 'cyrillic'],
variable: '--font-inter',
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: {
default: 'Грибы Крыма — Энциклопедия грибника',
template: '%s | Грибы Крыма',
},
description:
'Полная энциклопедия грибов Крымского полуострова. Описания, фотографии, календарь сбора, справочник грибника. Съедобные и ядовитые грибы Крыма.',
keywords: [
'грибы Крыма',
'энциклопедия грибов',
'съедобные грибы',
'ядовитые грибы',
'грибной календарь',
'тихая охота',
'мышата',
'белый гриб',
],
};
export default function RootLayout({
@@ -23,11 +34,11 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<html lang="ru" className={inter.variable}>
<body className="min-h-screen flex flex-col antialiased">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</body>
</html>
);

24
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,24 @@
import Link from 'next/link';
import { TreePine, ArrowLeft } from 'lucide-react';
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center py-24 px-4 text-center">
<TreePine className="h-16 w-16 text-forest-300 mb-6" />
<h1 className="text-4xl font-bold text-foreground">404</h1>
<p className="mt-2 text-lg text-muted-foreground">
Этот гриб не найден в нашей энциклопедии
</p>
<p className="mt-1 text-sm text-muted-foreground">
Возможно, он спрятался под листвой
</p>
<Link
href="/"
className="mt-8 inline-flex items-center gap-2 rounded-xl bg-forest-600 px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-forest-700"
>
<ArrowLeft className="h-4 w-4" />
Вернуться на главную
</Link>
</div>
);
}

View File

@@ -1,65 +1,218 @@
import Image from "next/image";
import Link from 'next/link';
import { ArrowRight, BookOpen, Calendar, Shield, TreePine, Leaf, Mountain } from 'lucide-react';
import { mushrooms } from '@/data/mushrooms';
import { MushroomCard } from '@/components/MushroomCard';
import { getCurrentMonth, getMushroomsByMonth } from '@/lib/utils';
import { MONTH_NAMES, type Month } from '@/lib/types';
export default function HomePage() {
const currentMonth = getCurrentMonth();
const seasonMushrooms = getMushroomsByMonth(mushrooms, currentMonth);
const featuredMushrooms = mushrooms.filter((m) => m.edibility === 'edible').slice(0, 4);
const poisonousMushrooms = mushrooms.filter((m) => m.edibility === 'poisonous').slice(0, 3);
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<div>
{/* Hero Section */}
<section className="relative overflow-hidden bg-gradient-to-br from-forest-900 via-forest-800 to-forest-950 text-white">
<div className="absolute inset-0 opacity-10">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.3'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}} />
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
<div className="relative mx-auto max-w-7xl px-4 py-20 sm:px-6 sm:py-28 lg:px-8 lg:py-36">
<div className="max-w-3xl">
<div className="flex items-center gap-2 text-forest-300 mb-4">
<Mountain className="h-5 w-5" />
<span className="text-sm font-medium tracking-wide uppercase">Крымский полуостров</span>
</div>
<h1 className="text-4xl font-extrabold tracking-tight sm:text-5xl lg:text-6xl">
Энциклопедия{' '}
<span className="text-forest-300">грибов Крыма</span>
</h1>
<p className="mt-5 text-lg text-forest-200 leading-relaxed sm:text-xl max-w-2xl">
Полный справочник грибника с описаниями, фотографиями и интерактивным
календарём сбора. Узнайте всё о съедобных и ядовитых грибах полуострова.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link
href="/encyclopedia"
className="inline-flex items-center gap-2 rounded-xl bg-white px-6 py-3 text-sm font-semibold text-forest-900 shadow-lg transition-all hover:bg-forest-50 hover:shadow-xl"
>
<BookOpen className="h-4 w-4" />
Открыть энциклопедию
<ArrowRight className="h-4 w-4" />
</Link>
<Link
href="/calendar"
className="inline-flex items-center gap-2 rounded-xl border border-forest-500 px-6 py-3 text-sm font-semibold text-white transition-all hover:bg-forest-700"
>
<Calendar className="h-4 w-4" />
Календарь грибника
</Link>
</div>
</div>
{/* Stats */}
<div className="mt-14 grid grid-cols-2 gap-4 sm:grid-cols-4 max-w-2xl">
{[
{ value: mushrooms.length, label: 'видов грибов' },
{ value: mushrooms.filter(m => m.edibility === 'edible').length, label: 'съедобных' },
{ value: mushrooms.filter(m => m.edibility === 'poisonous').length, label: 'ядовитых' },
{ value: '12', label: 'месяцев в календаре' },
].map((stat) => (
<div key={stat.label} className="rounded-xl bg-white/10 backdrop-blur-sm px-4 py-3">
<div className="text-2xl font-bold text-white">{stat.value}</div>
<div className="text-xs text-forest-300">{stat.label}</div>
</div>
))}
</div>
</div>
</main>
</section>
{/* Current Season */}
<section className="bg-amber-50/50 border-b border-amber-100">
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-100">
<Leaf className="h-5 w-5 text-amber-600" />
</div>
<div>
<h2 className="text-xl font-bold text-foreground">
Сейчас в сезоне {MONTH_NAMES[currentMonth]}
</h2>
<p className="text-sm text-muted-foreground">
{seasonMushrooms.length > 0
? `${seasonMushrooms.length} видов можно найти в этом месяце`
: 'В этом месяце грибной сезон не активен'}
</p>
</div>
</div>
{seasonMushrooms.length > 0 ? (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
{seasonMushrooms.slice(0, 4).map((m) => (
<MushroomCard key={m.id} mushroom={m} />
))}
</div>
) : (
<p className="text-muted-foreground text-center py-8">
Зимой грибной сезон в Крыму минимален. Обратите внимание на вёшенки, которые можно найти в тёплые зимы.
</p>
)}
{seasonMushrooms.length > 4 && (
<div className="mt-6 text-center">
<Link
href="/calendar"
className="inline-flex items-center gap-1.5 text-sm font-medium text-amber-700 hover:text-amber-800"
>
Все грибы сезона <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
)}
</div>
</section>
{/* Featured Edible */}
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
<div className="flex items-end justify-between mb-8">
<div>
<h2 className="text-2xl font-bold text-foreground sm:text-3xl">
Съедобные грибы Крыма
</h2>
<p className="mt-2 text-muted-foreground">
Самые ценные и популярные виды для сбора
</p>
</div>
<Link
href="/encyclopedia?edibility=edible"
className="hidden sm:inline-flex items-center gap-1.5 text-sm font-medium text-forest-600 hover:text-forest-700"
>
Все съедобные <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
{featuredMushrooms.map((m) => (
<MushroomCard key={m.id} mushroom={m} />
))}
</div>
</section>
{/* Danger Warning */}
<section className="bg-red-50/50 border-y border-red-100">
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
<div className="flex items-end justify-between mb-8">
<div>
<div className="flex items-center gap-2 text-red-600 mb-2">
<Shield className="h-5 w-5" />
<span className="text-sm font-semibold uppercase tracking-wide">Осторожно</span>
</div>
<h2 className="text-2xl font-bold text-foreground sm:text-3xl">
Ядовитые грибы Крыма
</h2>
<p className="mt-2 text-muted-foreground">
Знай врага в лицо изучите опасные виды, чтобы избежать отравления
</p>
</div>
<Link
href="/encyclopedia?edibility=poisonous"
className="hidden sm:inline-flex items-center gap-1.5 text-sm font-medium text-red-600 hover:text-red-700"
>
Все ядовитые <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{poisonousMushrooms.map((m) => (
<MushroomCard key={m.id} mushroom={m} />
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{[
{
icon: BookOpen,
title: 'Энциклопедия',
desc: 'Полные описания с фото, характеристиками и местами произрастания',
href: '/encyclopedia',
color: 'bg-forest-50 text-forest-600',
},
{
icon: Calendar,
title: 'Календарь сбора',
desc: 'Узнайте, какие грибы растут в каждом месяце года',
href: '/calendar',
color: 'bg-amber-50 text-amber-600',
},
{
icon: Shield,
title: 'Справочник грибника',
desc: 'Правила безопасности, первая помощь, советы по приготовлению',
href: '/guide',
color: 'bg-blue-50 text-blue-600',
},
].map((item) => (
<Link
key={item.href}
href={item.href}
className="group rounded-2xl border border-border p-6 transition-all hover:shadow-lg hover:border-forest-200 hover:-translate-y-0.5"
>
<div className={`inline-flex h-12 w-12 items-center justify-center rounded-xl ${item.color}`}>
<item.icon className="h-6 w-6" />
</div>
<h3 className="mt-4 text-lg font-bold text-foreground group-hover:text-forest-700 transition-colors">
{item.title}
</h3>
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">{item.desc}</p>
<span className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-forest-600">
Перейти <ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
</span>
</Link>
))}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { cn } from '@/lib/utils';
import { EDIBILITY_LABELS, EDIBILITY_COLORS, type Edibility } from '@/lib/types';
import { ShieldCheck, ShieldAlert, ShieldX, Skull } from 'lucide-react';
interface EdibilityBadgeProps {
edibility: Edibility;
size?: 'sm' | 'md' | 'lg';
}
const icons: Record<Edibility, typeof ShieldCheck> = {
'edible': ShieldCheck,
'conditionally-edible': ShieldAlert,
'inedible': ShieldX,
'poisonous': Skull,
};
export function EdibilityBadge({ edibility, size = 'md' }: EdibilityBadgeProps) {
const colors = EDIBILITY_COLORS[edibility];
const Icon = icons[edibility];
const label = EDIBILITY_LABELS[edibility];
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border font-medium',
colors.bg,
colors.text,
colors.border,
size === 'sm' && 'px-2 py-0.5 text-xs',
size === 'md' && 'px-3 py-1 text-sm',
size === 'lg' && 'px-4 py-1.5 text-base'
)}
>
<Icon className={cn(
size === 'sm' && 'h-3 w-3',
size === 'md' && 'h-3.5 w-3.5',
size === 'lg' && 'h-4 w-4'
)} />
{label}
</span>
);
}

70
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,70 @@
import Link from 'next/link';
import { TreePine, Heart } from 'lucide-react';
export function Footer() {
return (
<footer className="border-t border-border bg-forest-950 text-white">
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{/* Brand */}
<div>
<div className="flex items-center gap-2.5">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-forest-600">
<TreePine className="h-4.5 w-4.5" />
</div>
<span className="text-lg font-bold">Грибы Крыма</span>
</div>
<p className="mt-3 text-sm text-forest-300 leading-relaxed">
Энциклопедия и справочник грибника. Достоверная информация о грибах
Крымского полуострова с интерактивным календарём сбора.
</p>
</div>
{/* Navigation */}
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-forest-400">
Разделы
</h3>
<ul className="mt-3 space-y-2">
{[
{ name: 'Энциклопедия', href: '/encyclopedia' },
{ name: 'Календарь грибника', href: '/calendar' },
{ name: 'Справочник', href: '/guide' },
].map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-sm text-forest-300 transition-colors hover:text-white"
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
{/* Disclaimer */}
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-forest-400">
Важно
</h3>
<p className="mt-3 text-sm text-forest-300 leading-relaxed">
Информация на сайте носит справочный характер. При сборе грибов
всегда руководствуйтесь правилом: не уверен не бери. При отравлении
немедленно обратитесь к врачу.
</p>
</div>
</div>
<div className="mt-10 border-t border-forest-800 pt-6 flex flex-col sm:flex-row items-center justify-between gap-2">
<p className="text-xs text-forest-400">
&copy; {new Date().getFullYear()} Грибы Крыма. Все права защищены.
</p>
<p className="text-xs text-forest-400 flex items-center gap-1">
Сделано с <Heart className="h-3 w-3 text-red-400 fill-red-400" /> для грибников Крыма
</p>
</div>
</div>
</footer>
);
}

98
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,98 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Menu, X, TreePine } from 'lucide-react';
import { cn } from '@/lib/utils';
const navigation = [
{ name: 'Главная', href: '/' },
{ name: 'Энциклопедия', href: '/encyclopedia' },
{ name: 'Календарь', href: '/calendar' },
{ name: 'Справочник', href: '/guide' },
];
export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const pathname = usePathname();
return (
<header className="sticky top-0 z-50 border-b border-border bg-white/80 backdrop-blur-md">
<nav className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<Link href="/" className="flex items-center gap-2.5 group">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-forest-600 text-white shadow-sm transition-transform group-hover:scale-105">
<TreePine className="h-5 w-5" />
</div>
<div>
<span className="block text-lg font-bold leading-tight text-forest-900">
Грибы Крыма
</span>
<span className="block text-[11px] font-medium leading-tight text-muted-foreground tracking-wide">
Энциклопедия грибника
</span>
</div>
</Link>
{/* Desktop nav */}
<div className="hidden md:flex md:items-center md:gap-1">
{navigation.map((item) => {
const isActive = pathname === item.href ||
(item.href !== '/' && pathname.startsWith(item.href));
return (
<Link
key={item.name}
href={item.href}
className={cn(
'rounded-lg px-4 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-forest-50 text-forest-700'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
{item.name}
</Link>
);
})}
</div>
{/* Mobile menu button */}
<button
type="button"
className="md:hidden rounded-lg p-2 text-muted-foreground hover:bg-muted"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Toggle menu"
>
{mobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>
</nav>
{/* Mobile menu */}
{mobileMenuOpen && (
<div className="md:hidden border-t border-border bg-white animate-fade-in">
<div className="space-y-1 px-4 py-3">
{navigation.map((item) => {
const isActive = pathname === item.href ||
(item.href !== '/' && pathname.startsWith(item.href));
return (
<Link
key={item.name}
href={item.href}
onClick={() => setMobileMenuOpen(false)}
className={cn(
'block rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
isActive
? 'bg-forest-50 text-forest-700'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
{item.name}
</Link>
);
})}
</div>
</div>
)}
</header>
);
}

View File

@@ -0,0 +1,58 @@
import Link from 'next/link';
import { MapPin } from 'lucide-react';
import type { Mushroom } from '@/lib/types';
import { EdibilityBadge } from './EdibilityBadge';
import { SeasonBar } from './SeasonBar';
import { getActiveSeasonMonths, getSeasonLabel } from '@/lib/utils';
interface MushroomCardProps {
mushroom: Mushroom;
}
export function MushroomCard({ mushroom }: MushroomCardProps) {
const activeMonths = getActiveSeasonMonths(mushroom);
return (
<Link
href={`/encyclopedia/${mushroom.slug}`}
className="group block overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-all hover:shadow-lg hover:border-forest-200 hover:-translate-y-0.5"
>
{/* Image */}
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
<img
src={mushroom.imageUrl}
alt={mushroom.name}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
<div className="absolute top-3 left-3">
<EdibilityBadge edibility={mushroom.edibility} size="sm" />
</div>
</div>
{/* Content */}
<div className="p-4">
<h3 className="text-lg font-bold text-foreground group-hover:text-forest-700 transition-colors">
{mushroom.name}
</h3>
<p className="mt-0.5 text-sm italic text-muted-foreground">
{mushroom.scientificName}
</p>
<p className="mt-2 text-sm text-muted-foreground line-clamp-2 leading-relaxed">
{mushroom.description}
</p>
{/* Season */}
<div className="mt-3">
<SeasonBar season={mushroom.season} compact />
</div>
{/* Meta */}
<div className="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3 w-3" />
<span className="truncate">{mushroom.locations.slice(0, 2).join(', ')}</span>
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,67 @@
import { cn } from '@/lib/utils';
import { MONTH_NAMES_SHORT, type Month, type SeasonMonth } from '@/lib/types';
import { getCurrentMonth } from '@/lib/utils';
interface SeasonBarProps {
season: SeasonMonth[];
compact?: boolean;
}
const abundanceColors: Record<string, string> = {
none: 'bg-gray-100',
rare: 'bg-amber-200',
moderate: 'bg-forest-300',
abundant: 'bg-forest-600',
};
const abundanceLabels: Record<string, string> = {
none: 'Не растёт',
rare: 'Редко',
moderate: 'Умеренно',
abundant: 'Обильно',
};
export function SeasonBar({ season, compact = false }: SeasonBarProps) {
const currentMonth = getCurrentMonth();
return (
<div className="w-full">
<div className={cn('grid grid-cols-12 gap-0.5', compact ? 'gap-px' : 'gap-1')}>
{Array.from({ length: 12 }, (_, i) => {
const month = (i + 1) as Month;
const entry = season.find((s) => s.month === month);
const abundance = entry?.abundance ?? 'none';
const isCurrent = month === currentMonth;
return (
<div key={month} className="flex flex-col items-center gap-1" title={`${MONTH_NAMES_SHORT[month]}: ${abundanceLabels[abundance]}`}>
<div
className={cn(
'w-full rounded-sm transition-all',
compact ? 'h-2' : 'h-3',
abundanceColors[abundance],
isCurrent && 'ring-2 ring-forest-500 ring-offset-1'
)}
/>
{!compact && (
<span className={cn(
'text-[10px] leading-none',
isCurrent ? 'font-bold text-forest-700' : 'text-muted-foreground'
)}>
{MONTH_NAMES_SHORT[month]}
</span>
)}
</div>
);
})}
</div>
{!compact && (
<div className="mt-2 flex items-center gap-3 text-[10px] text-muted-foreground">
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-amber-200" /> Редко</span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-forest-300" /> Умеренно</span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-forest-600" /> Обильно</span>
</div>
)}
</div>
);
}

112
src/data/guide.ts Normal file
View File

@@ -0,0 +1,112 @@
import type { GuideSection } from '@/lib/types';
export const guideSections: GuideSection[] = [
{
id: 'safety',
title: 'Правила безопасного сбора',
icon: 'Shield',
content: [
'Собирайте только те грибы, которые вы знаете на 100%. Малейшее сомнение — оставьте гриб в лесу.',
'Берите с собой корзину или сетчатую сумку — в полиэтиленовом пакете грибы быстро портятся и могут стать ядовитыми.',
'Не собирайте грибы вдоль автомобильных дорог, вблизи промышленных предприятий и на свалках — они накапливают тяжёлые металлы.',
'Срезайте грибы ножом у основания ножки, не вырывайте с грибницей — это позволит грибнице продолжить плодоносить.',
'Не собирайте старые, перезрелые, червивые грибы — они могут содержать продукты разложения.',
'Перерабатывайте собранные грибы в день сбора — не храните их сырыми более 46 часов.',
],
tips: [
'Лучшее время для сбора — раннее утро, до жары',
'После дождя подождите 23 дня для максимального урожая',
'Используйте палку для раздвигания листвы',
'Запоминайте грибные места — грибница плодоносит годами',
],
},
{
id: 'identification',
title: 'Как отличить ядовитые грибы',
icon: 'AlertTriangle',
content: [
'Не существует универсального народного способа определить ядовитость гриба. Все «бабушкины» методы (серебряная ложка, лук, чеснок) — МИФЫ и не работают!',
'Единственный надёжный способ — точное определение вида по совокупности признаков: форма, цвет, запах, место роста, споровый порошок.',
'Обращайте внимание на наличие вольвы (мешочка у основания ножки) — это признак мухоморовых, среди которых смертельно ядовитая бледная поганка.',
'Проверяйте наличие кольца на ножке — многие ядовитые виды имеют характерное кольцо.',
'Изучайте цвет пластинок: у молодых шампиньонов они розовые, у бледной поганки — белые.',
'При сборе трубчатых грибов обращайте внимание на цвет трубчатого слоя и изменение цвета мякоти на срезе.',
],
warning: 'Никогда не пробуйте незнакомые грибы на вкус! Некоторые смертельно ядовитые грибы (бледная поганка) имеют приятный вкус.',
},
{
id: 'firstaid',
title: 'Первая помощь при отравлении',
icon: 'Heart',
content: [
'При первых признаках отравления (тошнота, рвота, боль в животе, диарея) НЕМЕДЛЕННО вызовите скорую помощь (103 или 112).',
'До приезда скорой: дайте пострадавшему выпить 45 стаканов воды и вызовите рвоту (нажатие на корень языка).',
'Дайте активированный уголь — 1 таблетка на 10 кг массы тела.',
'Уложите пострадавшего, к ногам и животу приложите грелки.',
'СОХРАНИТЕ остатки грибов или рвотные массы для анализа — это поможет врачам определить вид гриба и назначить правильное лечение.',
'НЕ давайте алкоголь, молоко и не применяйте средства от диареи — это усугубит отравление.',
],
warning: 'Отравление бледной поганкой проявляется через 624 часа, когда яд уже поражает печень. При любом подозрении — немедленно в больницу!',
tips: [
'Запишите время появления симптомов',
'Сообщите врачу, какие грибы ели и когда',
'Помните: симптомы отравления могут появиться через 624 часа',
],
},
{
id: 'cooking',
title: 'Способы приготовления',
icon: 'ChefHat',
content: [
'Жарка — самый популярный способ. Нарежьте грибы, обжарьте с луком на сливочном или растительном масле 1520 минут.',
'Маринование — классический способ заготовки. Маринад: вода, уксус, соль, сахар, перец, лавровый лист, гвоздика.',
'Засолка — холодная (для груздей, рыжиков) и горячая (для остальных). Солёные грибы готовы через 3040 дней.',
'Сушка — лучший способ для белых грибов. Нарежьте ломтиками и сушите при 5060°C в духовке или электросушилке.',
'Заморозка — быстрый способ заготовки. Отварите грибы 10 минут, остудите, разложите по пакетам и заморозьте.',
'Грибной суп — отличный вариант для любых съедобных грибов. Особенно ароматен из сушёных белых грибов.',
],
tips: [
'Условно-съедобные грибы (грузди, свинушки) ОБЯЗАТЕЛЬНО вымачивайте и отваривайте',
'Никогда не смешивайте в одном блюде грибы разных видов до полной уверенности в каждом',
'Не храните приготовленные грибы более 24 часов при комнатной температуре',
],
},
{
id: 'equipment',
title: 'Снаряжение грибника',
icon: 'Backpack',
content: [
'Корзина — главный инструмент грибника. Плетёная корзина обеспечивает вентиляцию и предохраняет грибы от повреждений.',
'Нож — для срезания грибов и очистки от грязи прямо в лесу. Лучше складной, с фиксатором лезвия.',
'Одежда — длинные брюки, закрытая обувь (резиновые сапоги идеальны), головной убор, ветровка.',
'Компас или навигатор — в горных лесах Крыма легко заблудиться, особенно в тумане.',
'Вода и перекус — грибная охота может занять несколько часов, не забывайте о гидратации.',
'Справочник грибника — книга или приложение с описанием местных видов.',
],
tips: [
'Обработайте одежду репеллентом от клещей',
'Возьмите заряженный телефон',
'Сообщите кому-нибудь маршрут и время возвращения',
'В крымских горах погода меняется быстро — берите дождевик',
],
},
{
id: 'crimea-spots',
title: 'Лучшие грибные места Крыма',
icon: 'MapPin',
content: [
'Ай-Петринская яйла — сосновые и буковые леса на высоте 8001200 м. Белые грибы, маслята, мышата, рыжики.',
'Ангарский перевал — один из самых популярных грибных маршрутов. Опята, лисички, маслята, мышата.',
'Демерджи — живописные буковые леса с богатым грибным разнообразием. Белые грибы, сыроежки, моховики.',
'Чатыр-Даг — горный массив с обширными лесами. Отличное место для белых грибов и груздей.',
'Большой каньон Крыма — влажные буковые леса. Лисички, дождевики, вёшенки.',
'Бахчисарайский район — дубовые рощи с трюфелями и шампиньонами. Одно из лучших мест для осеннего сбора.',
],
tips: [
'Не заходите на территорию заповедников без разрешения',
'Лучший сезон в Крыму — октябрь-ноябрь, после осенних дождей',
'В горах часты туманы — будьте осторожны на тропах',
'Многие грибные места доступны на общественном транспорте из Симферополя',
],
},
];

475
src/data/mushrooms.ts Normal file
View File

@@ -0,0 +1,475 @@
import type { Mushroom, SeasonMonth } from '@/lib/types';
function s(months: Record<number, 'r' | 'm' | 'a'>): SeasonMonth[] {
const all: SeasonMonth[] = [];
for (let i = 1; i <= 12; i++) {
const code = months[i];
all.push({
month: i as SeasonMonth['month'],
abundance: code === 'a' ? 'abundant' : code === 'm' ? 'moderate' : code === 'r' ? 'rare' : 'none',
});
}
return all;
}
export const mushrooms: Mushroom[] = [
{
id: '1',
slug: 'beliy-grib',
name: 'Белый гриб',
nameAlt: ['Боровик', 'Царь грибов'],
scientificName: 'Boletus edulis',
family: 'Болетовые (Boletaceae)',
edibility: 'edible',
description: 'Белый гриб — самый ценный и знаменитый съедобный гриб. В горных лесах Крыма встречается преимущественно в буковых и дубовых рощах на высоте 6001200 м. Это крупный, мясистый гриб с превосходными вкусовыми качествами, который высоко ценится в кулинарии. В Крыму его можно найти на склонах Чатыр-Дага, Демерджи, Ай-Петри и в других горных массивах.',
cap: 'Диаметр 730 см, подушковидная, гладкая. Окраска от светло-коричневой до тёмно-бурой, зависит от условий произрастания. Поверхность сухая, в сырую погоду слегка слизистая.',
stem: 'Высота 825 см, толщина 310 см. Бочонковидная или булавовидная, плотная. Цвет белый или светло-коричневый с характерным белым сетчатым рисунком в верхней части.',
flesh: 'Белая, плотная, не меняет цвет на срезе. Имеет приятный грибной аромат и мягкий ореховый вкус.',
sporePrint: 'Оливково-коричневый',
habitat: 'Буковые и дубовые леса горного Крыма, реже под соснами. Образует микоризу с лиственными и хвойными деревьями. Предпочитает хорошо дренированные почвы на склонах.',
locations: ['Чатыр-Даг', 'Демерджи', 'Ай-Петри', 'Бабуган-яйла', 'леса над Алуштой', 'окрестности Ялты'],
season: s({ 6: 'r', 7: 'm', 8: 'm', 9: 'a', 10: 'a', 11: 'm' }),
lookalikes: [
{ name: 'Жёлчный гриб', difference: 'Мякоть розовеет на срезе, горький вкус, розоватый трубчатый слой', dangerous: false },
{ name: 'Сатанинский гриб', slug: 'sataninskiy-grib', difference: 'Шляпка беловатая, ножка красно-жёлтая, мякоть синеет на срезе', dangerous: true },
],
cookingTips: 'Универсален в кулинарии. Превосходен жареный, в супах, сушёный. Сушёные белые грибы имеют наиболее интенсивный аромат. Можно мариновать и солить.',
imageUrl: 'https://images.unsplash.com/photo-1504198266287-1659872e6590?w=800&q=80',
galleryUrls: [
'https://images.unsplash.com/photo-1504198266287-1659872e6590?w=1200&q=80',
'https://images.unsplash.com/photo-1571048953845-40ee89fade10?w=1200&q=80',
],
funFact: 'Белый гриб назван так потому, что его мякоть не темнеет при сушке, в отличие от многих других трубчатых грибов.',
},
{
id: '2',
slug: 'lisichka',
name: 'Лисичка обыкновенная',
nameAlt: ['Лисичка настоящая', 'Петушок'],
scientificName: 'Cantharellus cibarius',
family: 'Лисичковые (Cantharellaceae)',
edibility: 'edible',
description: 'Лисичка — один из самых узнаваемых и безопасных грибов для сбора. В Крыму встречается повсеместно в лиственных и смешанных лесах. Отличительная особенность — лисички практически не бывают червивыми благодаря содержанию хиноманнозы, природного антигельминтика.',
cap: 'Диаметр 210 см, вначале выпуклая, затем воронковидная с волнистым краем. Яркий яично-жёлтый или оранжево-жёлтый цвет. Поверхность гладкая, матовая.',
stem: 'Высота 37 см, толщина 12 см. Сросшаяся со шляпкой, сужается книзу. Одного цвета со шляпкой, плотная.',
flesh: 'Плотная, мясистая, жёлтая на периферии, беловатая внутри. Запах приятный, слегка фруктовый, напоминает абрикос.',
sporePrint: 'Бледно-жёлтый',
habitat: 'Буковые, дубовые и смешанные леса. Растёт группами на мшистых участках, в траве. Предпочитает влажные, затенённые места.',
locations: ['Ангарский перевал', 'леса Бахчисарайского района', 'Большой каньон Крыма', 'Крымский заповедник', 'окрестности Симферополя'],
season: s({ 6: 'm', 7: 'a', 8: 'a', 9: 'a', 10: 'm', 11: 'r' }),
lookalikes: [
{ name: 'Ложная лисичка', difference: 'Более яркий оранжевый цвет, ровные (не волнистые) пластинки, растёт на гниющей древесине', dangerous: false },
],
cookingTips: 'Отлично подходит для жарки со сметаной — классическое блюдо. Можно мариновать, сушить, замораживать. Не рекомендуется варить долго — теряет вкус.',
imageUrl: 'https://images.unsplash.com/photo-1571260899304-425eee4c7efc?w=800&q=80',
funFact: 'Лисички содержат хиноманнозу — вещество, которое отпугивает насекомых и паразитов. Поэтому эти грибы почти никогда не бывают червивыми.',
},
{
id: '3',
slug: 'opyonok-osenniy',
name: 'Опёнок осенний',
nameAlt: ['Опёнок настоящий'],
scientificName: 'Armillaria mellea',
family: 'Физалакриевые (Physalacriaceae)',
edibility: 'edible',
description: 'Опёнок осенний — один из самых массовых грибов Крыма. Растёт огромными колониями на пнях, стволах и корнях деревьев. Является паразитом и сапрофитом. В урожайные годы в Крыму можно собрать несколько вёдер за один поход.',
cap: 'Диаметр 315 см, вначале полушаровидная, затем плоская с бугорком в центре. Окраска медово-жёлтая, охристо-коричневая. Покрыта мелкими тёмными чешуйками.',
stem: 'Высота 618 см, толщина 12 см. Плотная, волокнистая, с характерным плёнчатым кольцом. Внизу часто утолщена.',
flesh: 'Белая, плотная в шляпке, волокнистая в ножке. Запах приятный грибной, вкус слегка вяжущий.',
sporePrint: 'Белый или кремовый',
habitat: 'На пнях, стволах и у основания живых и мёртвых деревьев — дубов, буков, грабов. Растёт большими сростками.',
locations: ['все горные леса Крыма', 'Ангарский перевал', 'Демерджи', 'Чатыр-Даг', 'леса Бахчисарая', 'окрестности Севастополя'],
season: s({ 9: 'm', 10: 'a', 11: 'a', 12: 'r' }),
lookalikes: [
{ name: 'Ложноопёнок серно-жёлтый', slug: 'lozhnoopenok', difference: 'Не имеет кольца на ножке, пластинки зеленовато-жёлтые, горький вкус', dangerous: true },
{ name: 'Галерина окаймлённая', difference: 'Меньше размером, растёт на хвойной древесине, без чешуек на шляпке, смертельно ядовита', dangerous: true },
],
cookingTips: 'Требует обязательного отваривания 1520 минут перед приготовлением. Отлично подходит для маринования, жарки, супов. Ножки жёсткие — лучше использовать только шляпки.',
warnings: 'Сырые и недоваренные опята вызывают расстройство желудка. Обязательно отваривайте перед приготовлением!',
imageUrl: 'https://images.unsplash.com/photo-1563201515-adbe35c5e3c0?w=800&q=80',
funFact: 'Грибница опёнка тёмного в Орегоне (США) занимает площадь около 9 км² и считается самым большим живым организмом на Земле.',
},
{
id: '4',
slug: 'maslyonok',
name: 'Маслёнок обыкновенный',
nameAlt: ['Маслёнок поздний', 'Маслюк'],
scientificName: 'Suillus luteus',
family: 'Маслёнковые (Suillaceae)',
edibility: 'edible',
description: 'Маслёнок — очень распространённый гриб крымских сосновых лесов. Назван за характерную маслянистую, скользкую шляпку. В Крыму встречается преимущественно в посадках крымской сосны на южном берегу и в горных сосняках.',
cap: 'Диаметр 312 см, полушаровидная, затем подушковидная. Шоколадно-коричневая, покрыта слизистой плёнкой. Кожица легко снимается.',
stem: 'Высота 410 см, толщина 13 см. Цилиндрическая, плотная, бледно-жёлтая. Имеет белое или лиловатое плёнчатое кольцо.',
flesh: 'Мягкая, сочная, беловатая или желтоватая, не меняет цвет. Запах приятный, вкус мягкий.',
sporePrint: 'Охряно-коричневый',
habitat: 'Сосновые леса и посадки. Образует микоризу исключительно с соснами. Растёт группами на открытых местах, полянах, просеках.',
locations: ['Ялтинский горно-лесной заповедник', 'сосновые леса ЮБК', 'Ангарский перевал', 'Долгоруковская яйла', 'леса над Алуштой'],
season: s({ 5: 'r', 6: 'm', 7: 'm', 8: 'm', 9: 'a', 10: 'a', 11: 'm' }),
lookalikes: [
{ name: 'Маслёнок жёлто-бурый', difference: 'Шляпка жёлто-бурая без слизи, без кольца на ножке, тоже съедобный', dangerous: false },
],
cookingTips: 'Перед приготовлением рекомендуется снять слизистую кожицу со шляпки. Отлично подходит для маринования, жарки, супов. Один из лучших грибов для маринада.',
imageUrl: 'https://images.unsplash.com/photo-1604229455805-3d889a159b84?w=800&q=80',
funFact: 'Маслёнки — одни из первых грибов, которые появляются после дождя. В Крыму их можно найти уже через 23 дня после хорошего ливня.',
},
{
id: '5',
slug: 'ryzhik',
name: 'Рыжик',
nameAlt: ['Рыжик настоящий', 'Рыжик сосновый'],
scientificName: 'Lactarius deliciosus',
family: 'Сыроежковые (Russulaceae)',
edibility: 'edible',
description: 'Рыжик — изысканный гриб первой категории, считающийся деликатесом. В Крыму встречается в сосновых лесах горной части полуострова. Характерная особенность — выделение оранжевого млечного сока на срезе.',
cap: 'Диаметр 415 см, вначале выпуклая, затем воронковидная. Рыжевато-оранжевая с концентрическими тёмными зонами. Поверхность гладкая, во влажную погоду слизистая.',
stem: 'Высота 38 см, толщина 1,53 см. Цилиндрическая, полая, ломкая. Одного цвета со шляпкой, с характерными ямками (лакунами).',
flesh: 'Оранжевая, ломкая, выделяет оранжево-красный млечный сок, который на воздухе зеленеет. Запах приятный, фруктовый.',
sporePrint: 'Бледно-охристый',
habitat: 'Сосновые леса, особенно молодые посадки. Образует микоризу с сосной. Предпочитает песчаные почвы, освещённые места.',
locations: ['сосновые леса ЮБК', 'Ай-Петри', 'Ангарский перевал', 'леса Белогорского района'],
season: s({ 7: 'r', 8: 'm', 9: 'a', 10: 'a', 11: 'm' }),
cookingTips: 'Лучший гриб для засолки — готов к употреблению через 2 недели. Можно жарить без предварительного отваривания. При засолке приобретает зеленоватый оттенок — это нормально.',
imageUrl: 'https://images.unsplash.com/photo-1602083920685-amateur-mushroom?w=800&q=80',
funFact: 'Рыжики — единственные грибы, которые можно есть сырыми, лишь присыпав солью. Этот способ употребления практикуется грибниками веками.',
},
{
id: '6',
slug: 'myshata',
name: 'Рядовка серая',
nameAlt: ['Мышата', 'Мышонок', 'Серушка'],
scientificName: 'Tricholoma terreum',
family: 'Рядовковые (Tricholomataceae)',
edibility: 'edible',
description: 'Мышата — пожалуй, самый популярный и массовый гриб Крыма. Местное название «мышата» закрепилось из-за серой окраски, напоминающей мышиную шкурку. Растёт огромными группами в сосновых лесах и является символом крымской «тихой охоты». Каждую осень сотни грибников выходят на сбор мышат.',
cap: 'Диаметр 410 см, коническая с подвёрнутым краем, затем распростёртая с бугорком. Серая, мышино-серая или тёмно-серая. Покрыта радиальными волокнами, слегка шерстистая.',
stem: 'Высота 510 см, толщина 12 см. Белая или сероватая, цилиндрическая, плотная, волокнистая.',
flesh: 'Белая, ломкая, под кожицей сероватая. Запах слабый, мучнистый. Вкус мягкий.',
sporePrint: 'Белый',
habitat: 'Сосновые леса, особенно молодые посадки крымской сосны. Растёт группами и рядами в хвойной подстилке.',
locations: ['все сосновые леса Крыма', 'Ай-Петри', 'Ангарский перевал', 'Алушта', 'Ялта', 'Симферопольский район'],
season: s({ 9: 'r', 10: 'a', 11: 'a', 12: 'm' }),
lookalikes: [
{ name: 'Рядовка тигровая', slug: 'ryadovka-tigrovaya', difference: 'Шляпка с чёрными чешуйками на светлом фоне, более крупная. Смертельно ядовита!', dangerous: true },
{ name: 'Рядовка землистая', difference: 'Очень похожа, но мельче. Тоже съедобна, различие непринципиально', dangerous: false },
],
cookingTips: 'Великолепны жареные с луком и сметаной. Можно мариновать, солить, замораживать. Предварительное отваривание необязательно, но рекомендуется 10 минут.',
imageUrl: 'https://images.unsplash.com/photo-1611243017008-b47e7b29f39c?w=800&q=80',
funFact: 'В Крыму мышата настолько популярны, что многие местные жители не собирают никакие другие грибы. На рынках Симферополя осенью мышата — самый ходовой товар.',
},
{
id: '7',
slug: 'veshenka',
name: 'Вёшенка обыкновенная',
nameAlt: ['Вёшенка устричная', 'Устричный гриб'],
scientificName: 'Pleurotus ostreatus',
family: 'Вёшенковые (Pleurotaceae)',
edibility: 'edible',
description: 'Вёшенка — крупный вкусный гриб, растущий на стволах и пнях лиственных деревьев. В Крыму встречается повсеместно, особенно в буковых лесах. Уникальна тем, что плодоносит в самое холодное время года, когда других грибов нет.',
cap: 'Диаметр 525 см, раковинообразная или веерообразная. Серо-голубая, серая, коричневатая. Поверхность гладкая, во влажную погоду слегка маслянистая.',
stem: 'Короткая (14 см), боковая, плотная, белая. Часто практически отсутствует — шляпка прикреплена к субстрату боком.',
flesh: 'Белая, плотная, мясистая. Запах приятный, анисовый. Вкус мягкий, нежный.',
sporePrint: 'Белый или бледно-лиловый',
habitat: 'На стволах и пнях лиственных деревьев — буков, дубов, тополей, ив. Растёт черепитчатыми сростками, иногда очень крупными.',
locations: ['буковые леса горного Крыма', 'парки Симферополя', 'Бахчисарайский район', 'долины рек'],
season: s({ 1: 'r', 2: 'r', 3: 'm', 4: 'm', 5: 'r', 9: 'r', 10: 'm', 11: 'a', 12: 'a' }),
cookingTips: 'Используйте только молодые грибы — старые становятся жёсткими. Превосходны в жарке, супах, рагу. Ножки жёсткие — лучше удалять.',
imageUrl: 'https://images.unsplash.com/photo-1504545102780-26774c1bb073?w=800&q=80',
funFact: 'Вёшенка — один из немногих грибов-хищников. Её грибница способна парализовать и переваривать микроскопических червей-нематод для получения азота.',
},
{
id: '8',
slug: 'shampinion',
name: 'Шампиньон обыкновенный',
nameAlt: ['Шампиньон полевой', 'Печерица'],
scientificName: 'Agaricus campestris',
family: 'Шампиньоновые (Agaricaceae)',
edibility: 'edible',
description: 'Шампиньон — наиболее известный съедобный гриб в мире. В Крыму дикие шампиньоны обильно растут на лугах, пастбищах и в степной зоне полуострова, особенно после осенних дождей.',
cap: 'Диаметр 515 см, полушаровидная, затем распростёртая. Белая или слегка буроватая, шелковисто-волокнистая. При надавливании может слегка розоветь.',
stem: 'Высота 410 см, толщина 12,5 см. Белая, плотная, с тонким кольцом. Ровная, слегка утолщена у основания.',
flesh: 'Белая, на срезе розовеет. Запах приятный, анисовый. Вкус нежный, сладковатый.',
sporePrint: 'Тёмно-коричневый, шоколадный',
habitat: 'Луга, пастбища, парки, степные участки. Растёт на унавоженных и гумусных почвах. Часто встречается вдоль дорог и на выгонах.',
locations: ['степной Крым', 'окрестности Симферополя', 'Бахчисарайский район', 'предгорья', 'парки городов'],
season: s({ 5: 'r', 6: 'm', 7: 'r', 8: 'r', 9: 'm', 10: 'a', 11: 'm' }),
lookalikes: [
{ name: 'Бледная поганка', slug: 'blednaya-poganka', difference: 'Имеет вольву (мешочек) у основания ножки, пластинки белые (не розовые/коричневые), без запаха аниса', dangerous: true },
],
cookingTips: 'Универсален — жарка, супы, соусы, салаты, пицца. Молодые шампиньоны можно употреблять сырыми. Отваривание необязательно.',
warnings: 'Будьте крайне внимательны — молодые бледные поганки похожи на шампиньоны! Всегда проверяйте основание ножки на наличие вольвы.',
imageUrl: 'https://images.unsplash.com/photo-1552825897-bb6e04076bfa?w=800&q=80',
funFact: 'Шампиньон — единственный гриб, который человечество научилось выращивать промышленно ещё в XVII веке. Первые грибные фермы появились в катакомбах Парижа.',
},
{
id: '9',
slug: 'gruzd',
name: 'Груздь обыкновенный',
nameAlt: ['Груздь настоящий', 'Груздь белый'],
scientificName: 'Lactarius resimus',
family: 'Сыроежковые (Russulaceae)',
edibility: 'conditionally-edible',
description: 'Груздь — крупный пластинчатый гриб, традиционно ценимый в русской кухне для засолки. В Крыму встречается в лиственных лесах горной части полуострова, хотя и не так массово, как в средней полосе России.',
cap: 'Диаметр 720 см, плоская с вдавлением в центре, затем воронковидная. Белая или желтоватая, с опущенным мохнатым краем. Поверхность влажная.',
stem: 'Высота 37 см, толщина 25 см. Белая, полая, гладкая, цилиндрическая.',
flesh: 'Белая, плотная, ломкая. Выделяет обильный белый едкий млечный сок, желтеющий на воздухе. Запах фруктовый.',
sporePrint: 'Желтоватый',
habitat: 'Лиственные и смешанные леса, под берёзами, дубами, буками. Растёт группами, часто прячется в подстилке.',
locations: ['горные леса Крыма', 'Бабуган-яйла', 'Чатыр-Даг', 'Крымский заповедник'],
season: s({ 7: 'r', 8: 'm', 9: 'a', 10: 'm' }),
cookingTips: 'Требует обязательного вымачивания 23 дня со сменой воды для удаления горечи. Идеален для холодной засолки. Солёные грузди — классический русский деликатес.',
warnings: 'Нельзя употреблять без предварительной обработки! Млечный сок вызывает раздражение ЖКТ.',
imageUrl: 'https://images.unsplash.com/photo-1573497161079-f3fd25cc6b90?w=800&q=80',
funFact: 'Название «груздь» предположительно происходит от слова «груда» — эти грибы всегда растут скоплениями. По другой версии — от «грузный», из-за массивного вида.',
},
{
id: '10',
slug: 'syrozhka',
name: 'Сыроежка',
nameAlt: ['Сыроежка пищевая'],
scientificName: 'Russula vesca',
family: 'Сыроежковые (Russulaceae)',
edibility: 'edible',
description: 'Сыроежки — обширный род грибов, насчитывающий десятки видов. В Крыму встречается множество видов сыроежек в самых разных лесах. Сыроежка пищевая — один из наиболее вкусных и распространённых видов.',
cap: 'Диаметр 512 см, полушаровидная, затем распростёртая. Окраска разнообразна — розовая, красная, зелёная, фиолетовая, жёлтая. Кожица легко снимается.',
stem: 'Высота 48 см, толщина 1,53 см. Белая, плотная, хрупкая, цилиндрическая.',
flesh: 'Белая, хрупкая, губчатая. Запах слабый, вкус мягкий (у пищевой сыроежки). Некоторые виды имеют жгучий вкус.',
sporePrint: 'Белый или кремовый',
habitat: 'Все типы лесов — лиственные, хвойные, смешанные. Образует микоризу с различными деревьями.',
locations: ['все лесные массивы Крыма', 'Ангарский перевал', 'Чатыр-Даг', 'Демерджи', 'пригороды Симферополя'],
season: s({ 6: 'r', 7: 'm', 8: 'm', 9: 'a', 10: 'a', 11: 'm' }),
cookingTips: 'Можно жарить, солить, мариновать. Жгучие виды требуют предварительного отваривания. Снимайте кожицу со шляпки для лучшего вкуса.',
warnings: 'Некоторые виды сыроежек (например, жгучеедкая) имеют едкий вкус. Попробуйте кончик мякоти на вкус — если жжёт, отварите 15 минут.',
imageUrl: 'https://images.unsplash.com/photo-1600411833196-7c1f6b1a8b90?w=800&q=80',
funFact: 'Название «сыроежка» обманчиво — не все виды можно есть сырыми. Оно скорее говорит о лёгкости приготовления: достаточно минимальной обработки.',
},
{
id: '11',
slug: 'dozhdevik',
name: 'Дождевик грушевидный',
nameAlt: ['Дождевик', 'Пылевик', 'Дедушкин табак'],
scientificName: 'Lycoperdon pyriforme',
family: 'Шампиньоновые (Agaricaceae)',
edibility: 'edible',
description: 'Дождевик — необычный гриб шаровидной формы без шляпки и ножки в привычном понимании. В Крыму встречается на гниющей древесине в лиственных лесах. Съедобен только в молодом возрасте, пока мякоть белая.',
cap: 'Грушевидное или яйцевидное плодовое тело 27 см в диаметре. Белое или кремовое, поверхность мелкобородавчатая. С возрастом буреет.',
stem: 'Ложная ножка — суженная нижняя часть плодового тела, служит для крепления к субстрату.',
flesh: 'В молодости белая, упругая, однородная. С возрастом желтеет, затем становится оливково-бурой порошистой массой спор.',
sporePrint: 'Оливково-бурый (зрелые споры)',
habitat: 'На гниющей древесине, пнях, у оснований деревьев. Лиственные и смешанные леса. Растёт группами.',
locations: ['все лесные массивы Крыма', 'Большой каньон', 'Ай-Петри', 'Крымский заповедник'],
season: s({ 6: 'r', 7: 'm', 8: 'a', 9: 'a', 10: 'm', 11: 'r' }),
cookingTips: 'Собирайте только молодые экземпляры с белой мякотью. Нарезать ломтиками и обжарить — по вкусу напоминает тофу или куриное филе. Можно сушить.',
warnings: 'Не собирайте грибы с пожелтевшей мякотью — они уже несъедобны.',
imageUrl: 'https://images.unsplash.com/photo-1630400723562-61ea64a91185?w=800&q=80',
funFact: 'Зрелый дождевик при нажатии «выстреливает» облаком спор — отсюда народное название «дедушкин табак». Одно плодовое тело может содержать до 7 триллионов спор.',
},
{
id: '12',
slug: 'mohovik',
name: 'Моховик зелёный',
nameAlt: ['Моховик', 'Козляк'],
scientificName: 'Xerocomus subtomentosus',
family: 'Болетовые (Boletaceae)',
edibility: 'edible',
description: 'Моховик зелёный — часто встречающийся трубчатый гриб крымских лесов. Растёт как в хвойных, так и в лиственных лесах на мшистых участках, за что и получил своё название.',
cap: 'Диаметр 412 см, подушковидная, бархатистая. Оливково-бурая или жёлто-коричневая, сухая, иногда с трещинами.',
stem: 'Высота 410 см, толщина 12 см. Цилиндрическая, к основанию суженная. Желтоватая, часто с бурыми продольными волокнами.',
flesh: 'Белая или желтоватая, на срезе слегка синеет. Запах приятный, вкус мягкий.',
sporePrint: 'Оливково-коричневый',
habitat: 'Лиственные и хвойные леса, на мшистых участках, вдоль тропинок. Образует микоризу с дубом, буком, сосной.',
locations: ['Чатыр-Даг', 'Демерджи', 'горные леса над Ялтой и Алуштой', 'Бахчисарайский район'],
season: s({ 6: 'r', 7: 'm', 8: 'm', 9: 'a', 10: 'm', 11: 'r' }),
cookingTips: 'Хорош в жарке и супах. Перед приготовлением не требует отваривания. При сушке и мариновании может темнеть — это нормально.',
imageUrl: 'https://images.unsplash.com/photo-1567360425618-1594206637d2?w=800&q=80',
funFact: 'Моховики растут преимущественно на замшелых участках леса, образуя тесную связь с мхами. Мох удерживает влагу, создавая идеальные условия.',
},
{
id: '13',
slug: 'gornyj-belyj',
name: 'Говорушка гигантская',
nameAlt: ['Горный белый гриб', 'Свинуха гигантская'],
scientificName: 'Leucopaxillus giganteus',
family: 'Рядовковые (Tricholomataceae)',
edibility: 'edible',
description: 'В Крыму этот гриб часто называют «горный белый» из-за крупных размеров и белой окраски, хотя к настоящим белым грибам он отношения не имеет. Это крупный, мясистый пластинчатый гриб, растущий на лесных полянах горного Крыма.',
cap: 'Диаметр 1030 см (иногда до 40), вначале выпуклая, затем воронковидная. Белая или кремовая, гладкая.',
stem: 'Высота 510 см, толщина 24 см. Плотная, белая, цилиндрическая, иногда эксцентрическая.',
flesh: 'Белая, плотная, толстая. Запах мучнистый, приятный. Вкус мягкий.',
sporePrint: 'Белый',
habitat: 'Лесные поляны, опушки, просеки в горных лесах. Растёт группами, часто образуя «ведьмины кольца».',
locations: ['горные поляны Ай-Петри', 'Чатыр-Даг', 'Бабуган-яйла', 'горные луга над Ялтой'],
season: s({ 7: 'r', 8: 'm', 9: 'a', 10: 'a', 11: 'm' }),
cookingTips: 'Хорош в любом виде — жарка, тушение, супы. Мякоть плотная и хорошо держит форму при готовке. Можно сушить.',
imageUrl: 'https://images.unsplash.com/photo-1590080876062-58baea77beb0?w=800&q=80',
funFact: 'Говорушка гигантская может образовывать «ведьмины кольца» диаметром до 15 метров. В средневековье считалось, что внутри таких колец танцуют ведьмы.',
},
{
id: '14',
slug: 'trufel-letnyj',
name: 'Трюфель летний',
nameAlt: ['Чёрный русский трюфель', 'Трюфель крымский'],
scientificName: 'Tuber aestivum',
family: 'Трюфелевые (Tuberaceae)',
edibility: 'edible',
description: 'Настоящий трюфель, растущий в Крыму! Летний трюфель — подземный гриб, который можно найти в дубовых и буковых лесах горного Крыма. Менее ароматный, чем знаменитый перигорский трюфель, но всё равно является деликатесом.',
cap: 'Клубневидное подземное плодовое тело 28 см в диаметре. Поверхность чёрно-коричневая, бородавчатая, с пирамидальными выростами.',
stem: 'Отсутствует — гриб полностью подземный.',
flesh: 'Бежево-коричневая с белыми мраморными прожилками. Плотная, с характерным ореховым ароматом.',
sporePrint: 'Тёмно-коричневый',
habitat: 'Под дубами и буками, на глубине 515 см в известковых почвах. Образует микоризу с лиственными деревьями.',
locations: ['дубовые леса горного Крыма', 'Бахчисарайский район', 'окрестности Белогорска', 'Предгорный Крым'],
season: s({ 6: 'r', 7: 'm', 8: 'a', 9: 'a', 10: 'm' }),
cookingTips: 'Деликатесный гриб. Используют в сыром виде, тонко нарезая на готовые блюда. Не подвергайте длительной термической обработке — теряется аромат.',
imageUrl: 'https://images.unsplash.com/photo-1616489953498-a6e1a3e2e31a?w=800&q=80',
funFact: 'В Крыму трюфели находили ещё в XIX веке. Известный ботаник Христиан Стевен описывал их сбор в окрестностях Симферополя. Для поиска использовали специально обученных собак.',
},
// --- ЯДОВИТЫЕ ---
{
id: '15',
slug: 'blednaya-poganka',
name: 'Бледная поганка',
nameAlt: ['Мухомор зелёный'],
scientificName: 'Amanita phalloides',
family: 'Мухоморовые (Amanitaceae)',
edibility: 'poisonous',
description: 'Бледная поганка — самый опасный ядовитый гриб Крыма и мира. Содержит аматоксины, разрушающие печень. Даже небольшой кусочек может вызвать смертельное отравление. Противоядия не существует. Симптомы отравления проявляются через 624 часа, когда токсины уже нанесли необратимый вред.',
cap: 'Диаметр 515 см, вначале яйцевидная (в общей обёртке), затем плосковыпуклая. Бледно-зелёная, оливковая, реже белая или желтоватая. Гладкая, шелковистая.',
stem: 'Высота 816 см, толщина 12,5 см. Белая с зеленоватым муаровым рисунком. Имеет плёнчатое кольцо и мешковидную вольву у основания.',
flesh: 'Белая, не меняет цвет. Запах слабый, у старых грибов неприятный, сладковатый. Вкус мягкий (НЕ ПРОБОВАТЬ!).',
sporePrint: 'Белый',
habitat: 'Лиственные и смешанные леса, под дубами, буками. Предпочитает плодородные почвы, освещённые места.',
locations: ['лиственные леса горного Крыма', 'дубовые рощи', 'Бахчисарайский район', 'леса над Алуштой'],
season: s({ 7: 'r', 8: 'm', 9: 'a', 10: 'm' }),
lookalikes: [
{ name: 'Шампиньон', slug: 'shampinion', difference: 'У шампиньона пластинки розовые/коричневые (не белые), нет вольвы, мякоть розовеет', dangerous: false },
{ name: 'Сыроежка зелёная', difference: 'У сыроежки нет кольца и вольвы, ножка без рисунка', dangerous: false },
],
warnings: '☠️ СМЕРТЕЛЬНО ЯДОВИТ! Даже 1/3 шляпки достаточно для смертельного отравления. Термическая обработка НЕ разрушает яд. При подозрении на отравление НЕМЕДЛЕННО вызывайте скорую помощь!',
imageUrl: 'https://images.unsplash.com/photo-1572981779307-38b8cabb2407?w=800&q=80',
funFact: 'По одной из версий, римский император Клавдий был отравлен именно бледной поганкой, добавленной в блюдо из грибов.',
},
{
id: '16',
slug: 'muhomor-krasnyj',
name: 'Мухомор красный',
nameAlt: ['Мухомор'],
scientificName: 'Amanita muscaria',
family: 'Мухоморовые (Amanitaceae)',
edibility: 'poisonous',
description: 'Мухомор красный — самый узнаваемый гриб в мире благодаря яркой красной шляпке с белыми точками. В Крыму встречается в горных лесах. Содержит мусцимол и иботеновую кислоту — психоактивные вещества, вызывающие галлюцинации и отравление.',
cap: 'Диаметр 820 см, полушаровидная, затем распростёртая. Ярко-красная или оранжево-красная, покрытая белыми хлопьевидными бородавками (остатки покрывала). Бородавки могут смываться дождём.',
stem: 'Высота 820 см, толщина 23,5 см. Белая, цилиндрическая, с клубневидным основанием. Кольцо широкое, свисающее, белое.',
flesh: 'Белая, под кожицей оранжеватая. Запах невыраженный. Без особого вкуса.',
sporePrint: 'Белый',
habitat: 'Хвойные и смешанные леса, под берёзами, соснами, елями. Предпочитает кислые почвы.',
locations: ['сосновые леса горного Крыма', 'Ай-Петри', 'Чатыр-Даг', 'Ангарский перевал'],
season: s({ 7: 'r', 8: 'm', 9: 'a', 10: 'm', 11: 'r' }),
warnings: '⚠️ ЯДОВИТ! Вызывает тошноту, рвоту, галлюцинации, судороги. Смертельные случаи редки, но возможны, особенно у детей.',
imageUrl: 'https://images.unsplash.com/photo-1604511878928-1e27e8e2c1e9?w=800&q=80',
funFact: 'Название «мухомор» связано с использованием этого гриба для уничтожения мух — кусочки шляпки замачивали в молоке, и мухи, полакомившись, погибали.',
},
{
id: '17',
slug: 'muhomor-panternyj',
name: 'Мухомор пантерный',
nameAlt: ['Мухомор серый'],
scientificName: 'Amanita pantherina',
family: 'Мухоморовые (Amanitaceae)',
edibility: 'poisonous',
description: 'Мухомор пантерный значительно опаснее красного. Содержит те же токсины, но в бо́льших концентрациях, а также дополнительные ядовитые соединения. В Крыму встречается в горных лесах и является частой причиной отравлений, так как менее узнаваем, чем красный мухомор.',
cap: 'Диаметр 512 см, полушаровидная, затем распростёртая. Оливково-бурая или серо-коричневая с мелкими белыми хлопьями. Край с рубчатой полоской.',
stem: 'Высота 613 см, толщина 12 см. Белая, с клубневидным основанием, окружённым кольцевидной вольвой. Кольцо гладкое, без бороздок.',
flesh: 'Белая, не меняет цвет. Запах неприятный. Вкус сладковатый (НЕ ПРОБОВАТЬ!).',
sporePrint: 'Белый',
habitat: 'Хвойные и лиственные леса, часто под соснами и дубами. Предпочитает щелочные почвы.',
locations: ['все горные леса Крыма', 'Демерджи', 'Чатыр-Даг', 'Ай-Петри'],
season: s({ 7: 'm', 8: 'a', 9: 'a', 10: 'm' }),
lookalikes: [
{ name: 'Рядовка серая (мышата)', slug: 'myshata', difference: 'У мышат нет вольвы и кольца, другая текстура шляпки (волокнистая, а не с хлопьями)', dangerous: false },
],
warnings: '⚠️ СИЛЬНО ЯДОВИТ! Опаснее красного мухомора. Отравление наступает через 12 часа. Может вызвать кому и смерть.',
imageUrl: 'https://images.unsplash.com/photo-1600426993655-a1429577f934?w=800&q=80',
funFact: 'Пантерный мухомор получил название из-за «пятнистой» шляпки, напоминающей шкуру пантеры. Его токсичность в 34 раза выше, чем у красного мухомора.',
},
{
id: '18',
slug: 'lozhnoopenok',
name: 'Ложноопёнок серно-жёлтый',
nameAlt: ['Ложный опёнок'],
scientificName: 'Hypholoma fasciculare',
family: 'Строфариевые (Strophariaceae)',
edibility: 'poisonous',
description: 'Ложноопёнок серно-жёлтый — ядовитый двойник осеннего опёнка. В Крыму растёт на тех же пнях и деревьях, что и настоящие опята, что делает его особенно коварным. Вызывает серьёзное желудочно-кишечное отравление.',
cap: 'Диаметр 27 см, колокольчатая, затем распростёртая. Серно-жёлтая в центре, зеленоватая или оранжеватая по краям. Гладкая.',
stem: 'Высота 410 см, толщина 0,51 см. Тонкая, волокнистая, жёлтая, внизу буроватая. Без кольца!',
flesh: 'Жёлтая, тонкая. Запах неприятный. Вкус горький.',
sporePrint: 'Фиолетово-коричневый',
habitat: 'На пнях и мёртвой древесине лиственных и хвойных деревьев. Растёт пучками, как и настоящие опята.',
locations: ['все лесные массивы Крыма', 'места произрастания настоящих опят'],
season: s({ 5: 'r', 6: 'm', 7: 'm', 8: 'm', 9: 'a', 10: 'a', 11: 'm' }),
lookalikes: [
{ name: 'Опёнок осенний', slug: 'opyonok-osenniy', difference: 'У настоящего опёнка есть кольцо на ножке, пластинки белые/кремовые, на шляпке чешуйки', dangerous: false },
],
warnings: '⚠️ ЯДОВИТ! Вызывает тошноту, рвоту, диарею через 16 часов. Главное отличие от опят — отсутствие кольца и горький вкус.',
imageUrl: 'https://images.unsplash.com/photo-1571260899304-425eee4c7efc?w=800&q=80',
funFact: 'Ложноопёнки серно-жёлтые часто растут рядом с настоящими опятами на одном пне. Поэтому при сборе опят нужно проверять каждый гриб.',
},
{
id: '19',
slug: 'sataninskiy-grib',
name: 'Сатанинский гриб',
nameAlt: ['Болет сатанинский'],
scientificName: 'Rubroboletus satanas',
family: 'Болетовые (Boletaceae)',
edibility: 'poisonous',
description: 'Сатанинский гриб — крупный и коварный ядовитый трубчатый гриб. В Крыму встречается в дубовых и буковых лесах. Опасен тем, что похож на съедобные трубчатые грибы (белые, дубовики). Имеет крайне неприятный запах у старых экземпляров.',
cap: 'Диаметр 825 см, подушковидная, серовато-белая, оливково-серая или желтоватая. Бархатистая, сухая.',
stem: 'Высота 615 см, толщина 310 см. Бочонковидная, с ярко-красной или карминно-красной сеточкой на жёлтом фоне. Вверху жёлтая.',
flesh: 'Белая или желтоватая, на срезе сначала краснеет, затем синеет. У старых грибов запах падали.',
sporePrint: 'Оливково-коричневый',
habitat: 'Лиственные леса на известковых почвах, под дубами и буками. Теплолюбивый вид.',
locations: ['дубовые леса южного Крыма', 'Бахчисарайский район', 'лес над Алуштой'],
season: s({ 7: 'm', 8: 'a', 9: 'a', 10: 'm' }),
lookalikes: [
{ name: 'Белый гриб', slug: 'beliy-grib', difference: 'У белого гриба ножка с белым сетчатым рисунком (не красным), мякоть не меняет цвет', dangerous: false },
],
warnings: '⚠️ ЯДОВИТ! Вызывает тяжёлое желудочно-кишечное отравление. Главные отличия: красная сетка на ножке и посинение мякоти.',
imageUrl: 'https://images.unsplash.com/photo-1600426993655-a1429577f934?w=800&q=80',
funFact: 'Название «сатанинский» гриб получил за зловещую окраску ножки — красную, как «адское пламя», и за неприятный трупный запах старых экземпляров.',
},
{
id: '20',
slug: 'ryadovka-tigrovaya',
name: 'Рядовка тигровая',
nameAlt: ['Рядовка ядовитая', 'Рядовка леопардовая'],
scientificName: 'Tricholoma pardinum',
family: 'Рядовковые (Tricholomataceae)',
edibility: 'poisonous',
description: 'Рядовка тигровая — опасный двойник съедобных рядовок, особенно популярных в Крыму мышат. Её коварство в том, что она имеет приятный запах и вкус, не вызывая подозрений. Отравление наступает быстро и протекает тяжело.',
cap: 'Диаметр 515 см, полушаровидная, затем распростёртая. Серовато-белая с концентрическими тёмно-серыми или чёрными чешуйками, создающими «тигровый» рисунок.',
stem: 'Высота 510 см, толщина 23 см. Белая или слегка охристая, плотная, мучнистая.',
flesh: 'Белая, плотная, не меняет цвет. Запах мучнистый, приятный. Вкус мягкий, не горький!',
sporePrint: 'Белый',
habitat: 'Лиственные и хвойные леса, на известковых почвах. Растёт группами, часто в тех же местах, что и мышата.',
locations: ['горные леса Крыма', 'те же места, что и мышата — будьте осторожны!'],
season: s({ 8: 'r', 9: 'm', 10: 'a', 11: 'm' }),
lookalikes: [
{ name: 'Рядовка серая (мышата)', slug: 'myshata', difference: 'У мышат шляпка однородно-серая с радиальными волокнами, без чешуйчатого «тигрового» рисунка', dangerous: false },
],
warnings: '⚠️ ЯДОВИТ! Вызывает сильнейшее отравление ЖКТ через 1560 минут. Особенно опасна из-за приятного вкуса и сходства с мышатами.',
imageUrl: 'https://images.unsplash.com/photo-1611243017008-b47e7b29f39c?w=800&q=80',
funFact: 'Рядовка тигровая — один из самых коварных грибов: она приятна на вкус и запах, не горчит, что создаёт ложное чувство безопасности. При этом вызывает тяжелейшее отравление.',
},
];
export function getMushroomBySlug(slug: string): Mushroom | undefined {
return mushrooms.find((m) => m.slug === slug);
}
export function getEdibleMushrooms(): Mushroom[] {
return mushrooms.filter((m) => m.edibility === 'edible');
}
export function getPoisonousMushrooms(): Mushroom[] {
return mushrooms.filter((m) => m.edibility === 'poisonous');
}

94
src/lib/types.ts Normal file
View File

@@ -0,0 +1,94 @@
export type Edibility = 'edible' | 'conditionally-edible' | 'inedible' | 'poisonous';
export type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
export type Abundance = 'none' | 'rare' | 'moderate' | 'abundant';
export interface SeasonMonth {
month: Month;
abundance: Abundance;
}
export interface Mushroom {
id: string;
slug: string;
name: string;
nameAlt?: string[];
scientificName: string;
family: string;
edibility: Edibility;
description: string;
cap: string;
stem: string;
flesh: string;
sporePrint: string;
habitat: string;
locations: string[];
season: SeasonMonth[];
lookalikes?: Lookalike[];
cookingTips?: string;
warnings?: string;
imageUrl: string;
galleryUrls?: string[];
funFact?: string;
}
export interface Lookalike {
name: string;
slug?: string;
difference: string;
dangerous: boolean;
}
export interface GuideSection {
id: string;
title: string;
icon: string;
content: string[];
tips?: string[];
warning?: string;
}
export const MONTH_NAMES: Record<Month, string> = {
1: 'Январь',
2: 'Февраль',
3: 'Март',
4: 'Апрель',
5: 'Май',
6: 'Июнь',
7: 'Июль',
8: 'Август',
9: 'Сентябрь',
10: 'Октябрь',
11: 'Ноябрь',
12: 'Декабрь',
};
export const MONTH_NAMES_SHORT: Record<Month, string> = {
1: 'Янв',
2: 'Фев',
3: 'Мар',
4: 'Апр',
5: 'Май',
6: 'Июн',
7: 'Июл',
8: 'Авг',
9: 'Сен',
10: 'Окт',
11: 'Ноя',
12: 'Дек',
};
export const EDIBILITY_LABELS: Record<Edibility, string> = {
'edible': 'Съедобный',
'conditionally-edible': 'Условно-съедобный',
'inedible': 'Несъедобный',
'poisonous': 'Ядовитый',
};
export const EDIBILITY_COLORS: Record<Edibility, { bg: string; text: string; border: string }> = {
'edible': { bg: 'bg-emerald-50', text: 'text-emerald-700', border: 'border-emerald-200' },
'conditionally-edible': { bg: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-200' },
'inedible': { bg: 'bg-slate-50', text: 'text-slate-600', border: 'border-slate-200' },
'poisonous': { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200' },
};

92
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,92 @@
import { clsx, type ClassValue } from 'clsx';
import type { Month, Mushroom, Abundance } from './types';
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
export function getMushroomsByMonth(mushrooms: Mushroom[], month: Month): Mushroom[] {
return mushrooms.filter((m) =>
m.season.some((s) => s.month === month && s.abundance !== 'none')
);
}
export function getMushroomAbundance(mushroom: Mushroom, month: Month): Abundance {
const seasonEntry = mushroom.season.find((s) => s.month === month);
return seasonEntry?.abundance ?? 'none';
}
export function getCurrentMonth(): Month {
return (new Date().getMonth() + 1) as Month;
}
export function getSeasonLabel(months: Month[]): string {
if (months.length === 0) return 'Не определён';
if (months.length === 12) return 'Круглый год';
const ranges: string[] = [];
let start = months[0];
let prev = months[0];
for (let i = 1; i <= months.length; i++) {
if (i < months.length && months[i] === prev + 1) {
prev = months[i];
} else {
const monthNames: Record<number, string> = {
1: 'янв', 2: 'фев', 3: 'мар', 4: 'апр',
5: 'май', 6: 'июн', 7: 'июл', 8: 'авг',
9: 'сен', 10: 'окт', 11: 'ноя', 12: 'дек',
};
if (start === prev) {
ranges.push(monthNames[start]);
} else {
ranges.push(`${monthNames[start]}${monthNames[prev]}`);
}
if (i < months.length) {
start = months[i];
prev = months[i];
}
}
}
return ranges.join(', ');
}
export function getActiveSeasonMonths(mushroom: Mushroom): Month[] {
return mushroom.season
.filter((s) => s.abundance !== 'none')
.map((s) => s.month)
.sort((a, b) => a - b);
}
export function filterMushrooms(
mushrooms: Mushroom[],
query: string,
edibility?: string,
month?: Month
): Mushroom[] {
let filtered = mushrooms;
if (query) {
const q = query.toLowerCase();
filtered = filtered.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.scientificName.toLowerCase().includes(q) ||
m.nameAlt?.some((n) => n.toLowerCase().includes(q)) ||
m.family.toLowerCase().includes(q)
);
}
if (edibility) {
filtered = filtered.filter((m) => m.edibility === edibility);
}
if (month) {
filtered = filtered.filter((m) =>
m.season.some((s) => s.month === month && s.abundance !== 'none')
);
}
return filtered;
}