# СО ИМИТ ВолГУ - Блог Сайт-блог для Совета обучающихся Института математики и информационных технологий Волгоградского государственного университета. ## Требования проекта ### Основные требования - Сайт-блог для СО ИМИТ ВолГУ - Чёрно-жёлтая пиксельная стилистика (см. 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 } ``` ## Дизайн-система ### 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)