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

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>
);
}