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:
233
src/app/calendar/page.tsx
Normal file
233
src/app/calendar/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user