sno-blog/CLAUDE.md
2026-02-02 22:53:36 +03:00

687 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# СО ИМИТ ВолГУ - Блог
Сайт-блог для Совета обучающихся Института математики и информационных технологий Волгоградского государственного университета.
## Требования проекта
### Основные требования
- Сайт-блог для СО ИМИТ ВолГУ
- Чёрно-жёлтая пиксельная стилистика (см. image.png)
- Чистая FSD (Feature-Sliced Design) архитектура
- Sanity CMS как self-hosted решение (embedded Studio)
- Использование плагина `frontend-design` для создания UI
### Стилистика (на основе референса image.png)
- **Цветовая схема**: чёрный фон (#000000) + жёлтый/золотой (#FFD700)
- **3D изометрические элементы**: буквы "ИМИТ" в изометрии
- **Пиксельные границы и блоки**: квадратные элементы с чёткими краями
- **Глитч-эффекты**: анимированные искажения текста
- **Пиксельный шрифт**: Press Start 2P для заголовков
- **Высококонтрастный дизайн**: минимум цветов, максимум контраста
## Технологический стек
| Категория | Технология |
|-----------|------------|
| **Frontend** | Next.js 15 (App Router) + TypeScript |
| **CMS** | Sanity (embedded Studio в /studio) |
| **Архитектура** | Feature-Sliced Design (FSD) |
| **Стилизация** | Tailwind CSS + CSS Variables |
| **Sanity интеграция** | next-sanity + @sanity/image-url |
| **Package Manager** | pnpm |
| **Контейнеризация** | Docker Compose |
## Docker
### Запуск через Docker Compose
```bash
docker compose up -d # Запуск в фоне
docker compose up # Запуск с логами
docker compose down # Остановка
docker compose build --no-cache # Пересборка
```
### docker-compose.yml
```yaml
services:
web:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_SANITY_PROJECT_ID=${NEXT_PUBLIC_SANITY_PROJECT_ID}
- NEXT_PUBLIC_SANITY_DATASET=${NEXT_PUBLIC_SANITY_DATASET}
- NEXT_PUBLIC_SANITY_API_VERSION=${NEXT_PUBLIC_SANITY_API_VERSION}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
env_file:
- .env.local
volumes:
- .:/app
- /app/node_modules
- /app/.next
restart: unless-stopped
```
### Dockerfile
```dockerfile
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS dev
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD ["pnpm", "dev"]
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM base AS production
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
```
### Доступ к сервисам
| Сервис | URL | Описание |
|--------|-----|----------|
| Frontend | http://localhost:3000 | Основной сайт |
| Sanity Studio | http://localhost:3000/studio | CMS редактор (embedded) |
## Команды
```bash
pnpm dev # Запуск dev-сервера (Next.js + Sanity Studio)
pnpm build # Production сборка
pnpm start # Запуск production сервера
pnpm lint # Линтинг кода
```
## Структура проекта (FSD + Next.js App Router)
```
sno-blog/
├── src/
│ ├── app/ # Next.js App Router (НЕ FSD слой)
│ │ ├── (site)/ # Route group для основного сайта
│ │ │ ├── layout.tsx # Layout с Header/Footer
│ │ │ ├── page.tsx # Главная → HomePage
│ │ │ ├── posts/
│ │ │ │ ├── page.tsx # Список постов
│ │ │ │ └── [slug]/page.tsx # Страница поста
│ │ │ ├── categories/
│ │ │ │ └── [slug]/page.tsx # Посты категории
│ │ │ ├── events/
│ │ │ │ ├── page.tsx # Список событий
│ │ │ │ └── [slug]/page.tsx # Страница события
│ │ │ ├── about/page.tsx # О нас
│ │ │ └── contacts/page.tsx # Контакты
│ │ ├── studio/[[...tool]]/ # Sanity Studio (embedded)
│ │ │ └── page.tsx
│ │ ├── api/ # API routes (если нужны)
│ │ ├── layout.tsx # Root layout
│ │ ├── globals.css # Глобальные стили
│ │ └── not-found.tsx # 404 страница
│ │
│ ├── pages-fsd/ # FSD: Pages layer (композиция)
│ │ ├── home/
│ │ │ ├── ui/HomePage.tsx
│ │ │ └── index.ts
│ │ ├── post/
│ │ │ ├── ui/PostPage.tsx
│ │ │ └── index.ts
│ │ ├── posts-list/
│ │ │ ├── ui/PostsListPage.tsx
│ │ │ └── index.ts
│ │ ├── events/
│ │ │ ├── ui/EventsPage.tsx
│ │ │ └── index.ts
│ │ ├── about/
│ │ │ ├── ui/AboutPage.tsx
│ │ │ └── index.ts
│ │ └── contacts/
│ │ ├── ui/ContactsPage.tsx
│ │ └── index.ts
│ │
│ ├── widgets/ # FSD: Widgets layer
│ │ ├── header/
│ │ │ ├── ui/Header.tsx
│ │ │ └── index.ts
│ │ ├── footer/
│ │ │ ├── ui/Footer.tsx
│ │ │ └── index.ts
│ │ ├── hero-section/
│ │ │ ├── ui/HeroSection.tsx
│ │ │ └── index.ts
│ │ ├── posts-grid/
│ │ │ ├── ui/PostsGrid.tsx
│ │ │ └── index.ts
│ │ └── events-timeline/
│ │ ├── ui/EventsTimeline.tsx
│ │ └── index.ts
│ │
│ ├── features/ # FSD: Features layer
│ │ ├── category-filter/
│ │ │ ├── ui/CategoryFilter.tsx
│ │ │ └── index.ts
│ │ ├── search-posts/
│ │ │ ├── ui/SearchPosts.tsx
│ │ │ ├── model/useSearch.ts
│ │ │ └── index.ts
│ │ └── share-post/
│ │ ├── ui/SharePost.tsx
│ │ └── index.ts
│ │
│ ├── entities/ # FSD: Entities layer
│ │ ├── post/
│ │ │ ├── ui/PostCard.tsx
│ │ │ ├── ui/PostContent.tsx
│ │ │ ├── model/types.ts
│ │ │ ├── api/queries.ts
│ │ │ └── index.ts
│ │ ├── author/
│ │ │ ├── ui/AuthorCard.tsx
│ │ │ ├── model/types.ts
│ │ │ └── index.ts
│ │ ├── category/
│ │ │ ├── ui/CategoryBadge.tsx
│ │ │ ├── model/types.ts
│ │ │ └── index.ts
│ │ └── event/
│ │ ├── ui/EventCard.tsx
│ │ ├── model/types.ts
│ │ ├── api/queries.ts
│ │ └── index.ts
│ │
│ └── shared/ # FSD: Shared layer
│ ├── ui/ # UI Kit компоненты
│ │ ├── button/
│ │ │ ├── Button.tsx
│ │ │ └── index.ts
│ │ ├── card/
│ │ │ ├── Card.tsx
│ │ │ └── index.ts
│ │ ├── glitch-text/
│ │ │ ├── GlitchText.tsx
│ │ │ └── index.ts
│ │ ├── pixel-border/
│ │ │ ├── PixelBorder.tsx
│ │ │ └── index.ts
│ │ ├── container/
│ │ │ ├── Container.tsx
│ │ │ └── index.ts
│ │ ├── pixel-logo/
│ │ │ ├── PixelLogo.tsx # 3D изометрический логотип ИМИТ
│ │ │ └── index.ts
│ │ └── index.ts # Public API
│ │
│ ├── lib/
│ │ └── sanity/
│ │ ├── client.ts # Sanity client
│ │ ├── queries.ts # GROQ queries
│ │ ├── image.ts # Image URL builder
│ │ └── index.ts
│ │
│ ├── config/
│ │ ├── site.ts # Конфигурация сайта
│ │ └── navigation.ts # Навигация
│ │
│ └── styles/
│ ├── variables.css # CSS переменные
│ └── animations.css # Анимации (глитч и др.)
├── sanity/ # Sanity конфигурация
│ ├── schemas/
│ │ ├── documents/
│ │ │ ├── post.ts
│ │ │ ├── author.ts
│ │ │ ├── category.ts
│ │ │ └── event.ts
│ │ ├── objects/
│ │ │ └── blockContent.ts # Portable Text
│ │ └── index.ts # Экспорт всех схем
│ └── lib/
│ └── client.ts # Sanity client для Studio
├── public/
│ └── fonts/ # Локальные шрифты (если нужны)
├── sanity.config.ts # Конфигурация Sanity Studio
├── sanity.cli.ts # Sanity CLI конфигурация
├── next.config.ts # Next.js конфигурация
├── tailwind.config.ts # Tailwind конфигурация
├── tsconfig.json
├── package.json
├── .env.local # Переменные окружения (не в git)
├── .env.example # Пример переменных
└── CLAUDE.md # Этот файл
```
## FSD правила импортов
Слои могут импортировать ТОЛЬКО из нижележащих слоёв:
```
app (Next.js) → pages-fsd → widgets → features → entities → shared
```
**Запрещено:**
- Импорт из вышележащих слоёв
- Кросс-импорт внутри одного слоя (slice → slice)
- Импорт минуя public API (index.ts)
**Разрешено:**
- `widgets/header``shared/ui`, `shared/config`
- `features/search``entities/post`, `shared/lib`
- `entities/post``shared/ui`, `shared/lib/sanity`
## Sanity CMS
### Переменные окружения (.env.local)
```env
# Sanity
NEXT_PUBLIC_SANITY_PROJECT_ID=your_project_id
NEXT_PUBLIC_SANITY_DATASET=production
NEXT_PUBLIC_SANITY_API_VERSION=2024-01-01
SANITY_API_TOKEN=your_token_for_preview
# Site
NEXT_PUBLIC_SITE_URL=http://localhost:3000
```
### Схемы документов
#### Post (sanity/schemas/documents/post.ts)
```typescript
import { defineField, defineType } from 'sanity'
export const postType = defineType({
name: 'post',
title: 'Пост',
type: 'document',
fields: [
defineField({ name: 'title', title: 'Заголовок', type: 'string', validation: (Rule) => Rule.required() }),
defineField({ name: 'slug', title: 'Slug', type: 'slug', options: { source: 'title', maxLength: 96 } }),
defineField({ name: 'excerpt', title: 'Краткое описание', type: 'text', rows: 3 }),
defineField({ name: 'mainImage', title: 'Главное изображение', type: 'image', options: { hotspot: true } }),
defineField({ name: 'author', title: 'Автор', type: 'reference', to: { type: 'author' } }),
defineField({ name: 'categories', title: 'Категории', type: 'array', of: [{ type: 'reference', to: { type: 'category' } }] }),
defineField({ name: 'publishedAt', title: 'Дата публикации', type: 'datetime' }),
defineField({ name: 'body', title: 'Содержимое', type: 'blockContent' }),
],
preview: {
select: { title: 'title', author: 'author.name', media: 'mainImage' },
prepare({ title, author, media }) {
return { title, subtitle: author ? `by ${author}` : '', media }
},
},
})
```
#### Author (sanity/schemas/documents/author.ts)
```typescript
export const authorType = defineType({
name: 'author',
title: 'Автор',
type: 'document',
fields: [
defineField({ name: 'name', title: 'Имя', type: 'string', validation: (Rule) => Rule.required() }),
defineField({ name: 'slug', title: 'Slug', type: 'slug', options: { source: 'name' } }),
defineField({ name: 'avatar', title: 'Аватар', type: 'image' }),
defineField({ name: 'role', title: 'Должность', type: 'string' }),
defineField({ name: 'bio', title: 'Биография', type: 'text' }),
],
})
```
#### Category (sanity/schemas/documents/category.ts)
```typescript
export const categoryType = defineType({
name: 'category',
title: 'Категория',
type: 'document',
fields: [
defineField({ name: 'title', title: 'Название', type: 'string', validation: (Rule) => Rule.required() }),
defineField({ name: 'slug', title: 'Slug', type: 'slug', options: { source: 'title' } }),
defineField({ name: 'description', title: 'Описание', type: 'text' }),
defineField({ name: 'color', title: 'Цвет', type: 'string', description: 'HEX цвет для бейджа' }),
],
})
```
#### Event (sanity/schemas/documents/event.ts)
```typescript
export const eventType = defineType({
name: 'event',
title: 'Событие',
type: 'document',
fields: [
defineField({ name: 'title', title: 'Название', type: 'string', validation: (Rule) => Rule.required() }),
defineField({ name: 'slug', title: 'Slug', type: 'slug', options: { source: 'title' } }),
defineField({ name: 'eventType', title: 'Тип события', type: 'string', options: { list: ['meeting', 'workshop', 'conference', 'other'] } }),
defineField({ name: 'date', title: 'Дата начала', type: 'datetime', validation: (Rule) => Rule.required() }),
defineField({ name: 'endDate', title: 'Дата окончания', type: 'datetime' }),
defineField({ name: 'location', title: 'Место', type: 'string' }),
defineField({ name: 'image', title: 'Изображение', type: 'image' }),
defineField({ name: 'description', title: 'Описание', type: 'blockContent' }),
defineField({ name: 'isHighlighted', title: 'Выделить', type: 'boolean', initialValue: false }),
],
})
```
### GROQ Queries (src/shared/lib/sanity/queries.ts)
```typescript
import { defineQuery } from 'next-sanity'
// Все посты
export const POSTS_QUERY = defineQuery(`
*[_type == "post"] | order(publishedAt desc) {
_id,
title,
slug,
excerpt,
mainImage,
publishedAt,
"author": author->{name, avatar},
"categories": categories[]->{title, slug, color}
}
`)
// Пост по slug
export const POST_BY_SLUG_QUERY = defineQuery(`
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
slug,
excerpt,
mainImage,
body,
publishedAt,
"author": author->{name, slug, avatar, bio, role},
"categories": categories[]->{title, slug, color}
}
`)
// Посты по категории
export const POSTS_BY_CATEGORY_QUERY = defineQuery(`
*[_type == "post" && $categorySlug in categories[]->slug.current] | order(publishedAt desc) {
_id,
title,
slug,
excerpt,
mainImage,
publishedAt,
"author": author->{name, avatar},
"categories": categories[]->{title, slug, color}
}
`)
// Все события
export const EVENTS_QUERY = defineQuery(`
*[_type == "event"] | order(date desc) {
_id,
title,
slug,
eventType,
date,
endDate,
location,
image,
isHighlighted
}
`)
// Все категории
export const CATEGORIES_QUERY = defineQuery(`
*[_type == "category"] | order(title asc) {
_id,
title,
slug,
description,
color
}
`)
```
### Sanity Client (src/shared/lib/sanity/client.ts)
```typescript
import { createClient } from 'next-sanity'
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2024-01-01',
useCdn: process.env.NODE_ENV === 'production',
})
```
### Embedded Studio (src/app/studio/[[...tool]]/page.tsx)
```typescript
'use client'
import { NextStudio } from 'next-sanity/studio'
import config from '../../../../sanity.config'
export default function StudioPage() {
return <NextStudio config={config} />
}
```
## Дизайн-система
### CSS переменные (src/shared/styles/variables.css)
```css
:root {
/* Цвета */
--color-background: #000000;
--color-surface: #0a0a0a;
--color-primary: #FFD700;
--color-primary-dark: #B8860B;
--color-primary-light: #FFEC8B;
--color-text: #FFFFFF;
--color-text-muted: #888888;
--color-border: #333333;
--color-error: #FF4444;
/* Шрифты */
--font-pixel: 'Press Start 2P', monospace;
--font-body: system-ui, -apple-system, sans-serif;
/* Размеры */
--pixel-size: 4px;
--border-width: 4px;
--container-max: 1200px;
/* Тени */
--shadow-pixel: 4px 4px 0 var(--color-primary);
--shadow-pixel-hover: 6px 6px 0 var(--color-primary);
/* Анимации */
--transition-fast: 150ms ease;
--transition-normal: 300ms ease;
}
```
### Глитч-анимация (src/shared/styles/animations.css)
```css
@keyframes glitch {
0% { transform: translate(0); }
20% { transform: translate(-2px, 2px); }
40% { transform: translate(-2px, -2px); }
60% { transform: translate(2px, 2px); }
80% { transform: translate(2px, -2px); }
100% { transform: translate(0); }
}
@keyframes glitch-skew {
0% { transform: skew(0deg); }
20% { transform: skew(2deg); }
40% { transform: skew(-2deg); }
60% { transform: skew(1deg); }
80% { transform: skew(-1deg); }
100% { transform: skew(0deg); }
}
.glitch-text {
position: relative;
animation: glitch-skew 1s infinite linear alternate-reverse;
}
.glitch-text::before,
.glitch-text::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.glitch-text::before {
color: var(--color-primary);
animation: glitch 0.3s infinite;
clip-path: polygon(0 0, 100% 0, 100% 35%, 0 35%);
transform: translate(-2px, -2px);
}
.glitch-text::after {
color: var(--color-text);
animation: glitch 0.3s infinite reverse;
clip-path: polygon(0 65%, 100% 65%, 100% 100%, 0 100%);
transform: translate(2px, 2px);
}
```
### UI компоненты
При создании UI компонентов использовать плагин `frontend-design`:
```
/frontend-design
```
Компоненты должны следовать пиксельной стилистике:
- **Button**: пиксельные границы, тень, hover-эффект
- **Card**: чёрный фон, жёлтая граница, пиксельная тень
- **GlitchText**: текст с глитч-анимацией
- **PixelBorder**: декоративная пиксельная рамка
- **Container**: центрированный контейнер с max-width
- **PixelLogo**: 3D изометрический логотип "ИМИТ"
## Этапы реализации
### Этап 1: Инициализация проекта
1. Создать Next.js 15 проект с TypeScript и Tailwind
2. Инициализировать Sanity проект
3. Установить зависимости (next-sanity, @sanity/image-url)
4. Создать FSD структуру папок
5. Настроить Tailwind с CSS переменными
### Этап 2: Shared слой
1. CSS переменные и анимации
2. Подключить Press Start 2P шрифт
3. UI компоненты (Button, Card, GlitchText, PixelBorder, Container)
4. Sanity client и image builder
5. Site config
### Этап 3: Sanity Studio
1. Создать схемы документов (post, author, category, event)
2. Создать blockContent схему
3. Настроить sanity.config.ts
4. Создать embedded Studio page (/studio)
5. Написать GROQ queries
### Этап 4: Entities слой
1. Post entity (PostCard, PostContent, types, queries)
2. Author entity (AuthorCard, types)
3. Category entity (CategoryBadge, types)
4. Event entity (EventCard, types, queries)
### Этап 5: Widgets слой
1. Header с навигацией и логотипом
2. Footer с контактами и ссылками
3. HeroSection с глитч-эффектом
4. PostsGrid для отображения постов
5. EventsTimeline для событий
### Этап 6: Features слой
1. CategoryFilter для фильтрации постов
2. SearchPosts с поиском по заголовкам
3. SharePost для шаринга в соцсети
### Этап 7: Pages и роутинг
1. Главная страница (/)
2. Список постов (/posts)
3. Страница поста (/posts/[slug])
4. Страницы категорий (/categories/[slug])
5. События (/events, /events/[slug])
6. О нас (/about)
7. Контакты (/contacts)
8. 404 страница
### Этап 8: Финализация
1. SEO метаданные (metadata API)
2. Open Graph изображения
3. Sitemap
4. Оптимизация изображений
5. Проверка производительности
## Верификация
1. `pnpm dev` - проверить запуск dev-сервера
2. Открыть http://localhost:3000 - главная страница
3. Открыть http://localhost:3000/studio - Sanity Studio
4. Создать тестовый пост в Studio
5. Проверить отображение поста на сайте
6. Проверить адаптивность (mobile/tablet/desktop)
7. Проверить глитч-эффекты и анимации
8. `pnpm build` - проверить production сборку
## Правила разработки
### Перед использованием библиотек
**ОБЯЗАТЕЛЬНО** читать документацию через Context7:
```
mcp__context7__resolve-library-id → mcp__context7__query-docs
```
### При создании UI
Использовать плагин `frontend-design` для качественных интерфейсов.
### Код
- TypeScript strict mode
- Именование: PascalCase для компонентов, camelCase для функций
- Файлы компонентов: PascalCase.tsx
- Экспорт через index.ts (public API)
## Полезные ссылки
- [Feature-Sliced Design](https://feature-sliced.design/)
- [Sanity Documentation](https://www.sanity.io/docs)
- [next-sanity](https://github.com/sanity-io/next-sanity)
- [Next.js App Router](https://nextjs.org/docs/app)
- [Press Start 2P Font](https://fonts.google.com/specimen/Press+Start+2P)
- [Tailwind CSS](https://tailwindcss.com/docs)