feat: Init commit

This commit is contained in:
NikitolProject 2026-02-02 22:53:36 +03:00
parent bfa578e9a6
commit 61ef2dee66
96 changed files with 19274 additions and 112 deletions

51
.dockerignore Normal file
View File

@ -0,0 +1,51 @@
# Dependencies
node_modules
.pnpm-store
# Build outputs
.next
out
build
dist
# Git
.git
.gitignore
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# Environment (keep .env.example)
.env
.env.local
.env.*.local
# Docker
Dockerfile*
docker-compose*
.dockerignore
# Documentation
README.md
CLAUDE.md
*.md
# Test
coverage
.nyc_output
# Misc
*.tgz
.turbo

10
.env.example Normal file
View File

@ -0,0 +1,10 @@
# Sanity Configuration
NEXT_PUBLIC_SANITY_PROJECT_ID=your_project_id
NEXT_PUBLIC_SANITY_DATASET=production
NEXT_PUBLIC_SANITY_API_VERSION=2024-01-01
# Optional: Sanity API Token (for preview/mutations)
SANITY_API_TOKEN=your_token_here
# Site Configuration
NEXT_PUBLIC_SITE_URL=http://localhost:3000

7
.gitignore vendored
View File

@ -30,8 +30,11 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# env files
.env
.env.local
.env.*.local
!.env.example
# vercel
.vercel

686
CLAUDE.md Normal file
View File

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

36
Dockerfile Normal file
View File

@ -0,0 +1,36 @@
# Base image with pnpm
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# Dependencies stage
FROM base AS deps
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile || pnpm install
# Development stage
FROM base AS dev
COPY --from=deps /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
ENV NODE_ENV=development
ENV HOSTNAME="0.0.0.0"
CMD ["pnpm", "dev"]
# Builder stage
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# Production stage
FROM base AS production
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

20
docker-compose.yml Normal file
View File

@ -0,0 +1,20 @@
services:
web:
build:
context: .
dockerfile: Dockerfile
target: dev
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:-http://localhost:3000}
env_file:
- .env.local
volumes:
- .:/app
- /app/node_modules
- /app/.next
restart: unless-stopped

BIN
image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

View File

@ -1,7 +1,28 @@
import type { NextConfig } from "next";
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
/* config options here */
};
// Enable standalone output for Docker
output: 'standalone',
export default nextConfig;
// Image optimization for Sanity CDN
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.sanity.io',
pathname: '/images/**',
},
],
},
// TypeScript
typescript: {
// Allow production builds even with type errors (for development)
// Set to true in production
ignoreBuildErrors: false,
},
}
export default nextConfig

View File

@ -9,9 +9,19 @@
"lint": "eslint"
},
"dependencies": {
"@portabletext/react": "^6.0.2",
"@portabletext/types": "^4.0.1",
"@sanity/code-input": "^7.0.7",
"@sanity/icons": "^3.7.4",
"@sanity/image-url": "^1.2.0",
"@sanity/vision": "^4.22.0",
"next": "16.1.6",
"next-sanity": "^11.6.12",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"sanity": "^4.22.0",
"styled-components": "6",
"use-debounce": "^10.1.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

13431
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

8
sanity.cli.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineCliConfig } from 'sanity/cli'
export default defineCliConfig({
api: {
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
},
})

61
sanity.config.ts Normal file
View File

@ -0,0 +1,61 @@
import { defineConfig } from 'sanity'
import { structureTool, type StructureBuilder } from 'sanity/structure'
import { visionTool } from '@sanity/vision'
import { codeInput } from '@sanity/code-input'
import { schemaTypes } from './sanity/schemas'
const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET || 'production'
export default defineConfig({
name: 'so-imit-blog',
title: 'СО ИМИТ ВолГУ - Блог',
projectId,
dataset,
plugins: [
structureTool({
structure: (S: StructureBuilder) =>
S.list()
.title('Контент')
.items([
S.listItem()
.title('Посты')
.icon(() => '📝')
.child(S.documentTypeList('post').title('Посты')),
S.listItem()
.title('События')
.icon(() => '📅')
.child(S.documentTypeList('event').title('События')),
S.divider(),
S.listItem()
.title('Авторы')
.icon(() => '👤')
.child(S.documentTypeList('author').title('Авторы')),
S.listItem()
.title('Категории')
.icon(() => '📁')
.child(S.documentTypeList('category').title('Категории')),
]),
}),
visionTool(),
codeInput(),
],
schema: {
types: schemaTypes,
},
document: {
newDocumentOptions: (prev, { creationContext }) => {
// Filter out certain document types from the "Create new" menu
if (creationContext.type === 'global') {
return prev.filter(
(template) => !['author', 'category'].includes(template.templateId)
)
}
return prev
},
},
})

View File

@ -0,0 +1,63 @@
import { defineField, defineType } from 'sanity'
export const authorType = defineType({
name: 'author',
title: 'Автор',
type: 'document',
icon: () => '👤',
fields: [
defineField({
name: 'name',
title: 'Имя',
type: 'string',
validation: (Rule) => Rule.required().min(2).max(100),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'name',
maxLength: 96,
},
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'avatar',
title: 'Аватар',
type: 'image',
options: {
hotspot: true,
},
}),
defineField({
name: 'role',
title: 'Должность',
type: 'string',
description: 'Например: Председатель СО, Заместитель, Член совета',
}),
defineField({
name: 'bio',
title: 'Биография',
type: 'text',
rows: 4,
}),
defineField({
name: 'socialLinks',
title: 'Социальные сети',
type: 'object',
fields: [
{ name: 'vk', title: 'ВКонтакте', type: 'url' },
{ name: 'telegram', title: 'Telegram', type: 'url' },
{ name: 'email', title: 'Email', type: 'string' },
],
}),
],
preview: {
select: {
title: 'name',
subtitle: 'role',
media: 'avatar',
},
},
})

View File

@ -0,0 +1,55 @@
import { defineField, defineType } from 'sanity'
export const categoryType = defineType({
name: 'category',
title: 'Категория',
type: 'document',
icon: () => '📁',
fields: [
defineField({
name: 'title',
title: 'Название',
type: 'string',
validation: (Rule) => Rule.required().min(2).max(50),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'title',
maxLength: 96,
},
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'description',
title: 'Описание',
type: 'text',
rows: 3,
}),
defineField({
name: 'color',
title: 'Цвет',
type: 'string',
description: 'HEX цвет для бейджа (например: #FFD700)',
validation: (Rule) =>
Rule.regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, {
name: 'hex color',
invert: false,
}).warning('Используйте HEX формат: #RRGGBB'),
}),
],
preview: {
select: {
title: 'title',
subtitle: 'description',
},
prepare({ title, subtitle }) {
return {
title,
subtitle: subtitle ? subtitle.slice(0, 50) + '...' : '',
}
},
},
})

View File

@ -0,0 +1,134 @@
import { defineField, defineType } from 'sanity'
export const eventType = defineType({
name: 'event',
title: 'Событие',
type: 'document',
icon: () => '📅',
fields: [
defineField({
name: 'title',
title: 'Название',
type: 'string',
validation: (Rule) => Rule.required().min(5).max(200),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'title',
maxLength: 96,
},
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'eventType',
title: 'Тип события',
type: 'string',
options: {
list: [
{ title: 'Собрание', value: 'meeting' },
{ title: 'Воркшоп', value: 'workshop' },
{ title: 'Конференция', value: 'conference' },
{ title: 'Конкурс', value: 'contest' },
{ title: 'Праздник', value: 'celebration' },
{ title: 'Другое', value: 'other' },
],
layout: 'dropdown',
},
initialValue: 'other',
}),
defineField({
name: 'date',
title: 'Дата начала',
type: 'datetime',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'endDate',
title: 'Дата окончания',
type: 'datetime',
description: 'Оставьте пустым для однодневных событий',
}),
defineField({
name: 'location',
title: 'Место проведения',
type: 'string',
description: 'Например: Ауд. 4-01, Корпус ИМИТ',
}),
defineField({
name: 'image',
title: 'Изображение',
type: 'image',
options: {
hotspot: true,
},
fields: [
{
name: 'alt',
type: 'string',
title: 'Альтернативный текст',
},
],
}),
defineField({
name: 'description',
title: 'Описание',
type: 'blockContent',
}),
defineField({
name: 'isHighlighted',
title: 'Выделить событие',
type: 'boolean',
description: 'Выделенные события отображаются на главной странице',
initialValue: false,
}),
defineField({
name: 'registrationLink',
title: 'Ссылка на регистрацию',
type: 'url',
description: 'Внешняя ссылка для регистрации на событие',
}),
],
preview: {
select: {
title: 'title',
date: 'date',
eventType: 'eventType',
media: 'image',
isHighlighted: 'isHighlighted',
},
prepare({ title, date, eventType, media, isHighlighted }) {
const eventTypeLabels: Record<string, string> = {
meeting: 'Собрание',
workshop: 'Воркшоп',
conference: 'Конференция',
contest: 'Конкурс',
celebration: 'Праздник',
other: 'Событие',
}
const formattedDate = date
? new Date(date).toLocaleDateString('ru-RU')
: 'Дата не указана'
const highlight = isHighlighted ? '⭐ ' : ''
return {
title: `${highlight}${title}`,
subtitle: `${eventTypeLabels[eventType] || 'Событие'}${formattedDate}`,
media,
}
},
},
orderings: [
{
title: 'Дата (ближайшие)',
name: 'dateAsc',
by: [{ field: 'date', direction: 'asc' }],
},
{
title: 'Дата (прошедшие)',
name: 'dateDesc',
by: [{ field: 'date', direction: 'desc' }],
},
],
})

View File

@ -0,0 +1,139 @@
import { defineField, defineType } from 'sanity'
export const postType = defineType({
name: 'post',
title: 'Пост',
type: 'document',
icon: () => '📝',
fields: [
defineField({
name: 'title',
title: 'Заголовок',
type: 'string',
validation: (Rule) => Rule.required().min(5).max(200),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'title',
maxLength: 96,
},
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'excerpt',
title: 'Краткое описание',
type: 'text',
rows: 3,
description: 'Краткое описание для карточки поста (до 200 символов)',
validation: (Rule) => Rule.max(200),
}),
defineField({
name: 'mainImage',
title: 'Главное изображение',
type: 'image',
options: {
hotspot: true,
},
fields: [
{
name: 'alt',
type: 'string',
title: 'Альтернативный текст',
description: 'Важно для SEO и доступности',
},
],
}),
defineField({
name: 'author',
title: 'Автор',
type: 'reference',
to: { type: 'author' },
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'categories',
title: 'Категории',
type: 'array',
of: [{ type: 'reference', to: { type: 'category' } }],
}),
defineField({
name: 'publishedAt',
title: 'Дата публикации',
type: 'datetime',
initialValue: () => new Date().toISOString(),
}),
defineField({
name: 'body',
title: 'Содержимое',
type: 'blockContent',
}),
defineField({
name: 'seo',
title: 'SEO',
type: 'object',
options: {
collapsible: true,
collapsed: true,
},
fields: [
{
name: 'metaTitle',
title: 'Meta Title',
type: 'string',
description: 'Оставьте пустым для использования заголовка поста',
},
{
name: 'metaDescription',
title: 'Meta Description',
type: 'text',
rows: 3,
description: 'Оставьте пустым для использования краткого описания',
},
{
name: 'ogImage',
title: 'Open Graph изображение',
type: 'image',
description: 'Изображение для социальных сетей (1200x630)',
},
],
}),
],
preview: {
select: {
title: 'title',
author: 'author.name',
media: 'mainImage',
date: 'publishedAt',
},
prepare({ title, author, media, date }) {
const formattedDate = date
? new Date(date).toLocaleDateString('ru-RU')
: 'Не опубликовано'
return {
title,
subtitle: `${author || 'Без автора'}${formattedDate}`,
media,
}
},
},
orderings: [
{
title: 'Дата публикации (новые)',
name: 'publishedAtDesc',
by: [{ field: 'publishedAt', direction: 'desc' }],
},
{
title: 'Дата публикации (старые)',
name: 'publishedAtAsc',
by: [{ field: 'publishedAt', direction: 'asc' }],
},
{
title: 'Заголовок',
name: 'titleAsc',
by: [{ field: 'title', direction: 'asc' }],
},
],
})

17
sanity/schemas/index.ts Normal file
View File

@ -0,0 +1,17 @@
import type { SchemaTypeDefinition } from 'sanity'
import { blockContentType } from './objects/blockContent'
import { authorType } from './documents/author'
import { categoryType } from './documents/category'
import { postType } from './documents/post'
import { eventType } from './documents/event'
export const schemaTypes: SchemaTypeDefinition[] = [
// Objects
blockContentType,
// Documents
authorType,
categoryType,
postType,
eventType,
]

View File

@ -0,0 +1,93 @@
import { defineType, defineArrayMember } from 'sanity'
export const blockContentType = defineType({
name: 'blockContent',
title: 'Контент',
type: 'array',
of: [
defineArrayMember({
type: 'block',
title: 'Блок',
styles: [
{ title: 'Обычный', value: 'normal' },
{ title: 'Заголовок 2', value: 'h2' },
{ title: 'Заголовок 3', value: 'h3' },
{ title: 'Заголовок 4', value: 'h4' },
{ title: 'Цитата', value: 'blockquote' },
],
lists: [
{ title: 'Маркированный', value: 'bullet' },
{ title: 'Нумерованный', value: 'number' },
],
marks: {
decorators: [
{ title: 'Жирный', value: 'strong' },
{ title: 'Курсив', value: 'em' },
{ title: 'Подчёркнутый', value: 'underline' },
{ title: 'Зачёркнутый', value: 'strike-through' },
{ title: 'Код', value: 'code' },
],
annotations: [
{
name: 'link',
type: 'object',
title: 'Ссылка',
fields: [
{
name: 'href',
type: 'url',
title: 'URL',
validation: (Rule) =>
Rule.uri({
allowRelative: true,
scheme: ['http', 'https', 'mailto', 'tel'],
}),
},
{
name: 'blank',
type: 'boolean',
title: 'Открывать в новой вкладке',
initialValue: false,
},
],
},
],
},
}),
defineArrayMember({
type: 'image',
title: 'Изображение',
options: { hotspot: true },
fields: [
{
name: 'alt',
type: 'string',
title: 'Альтернативный текст',
description: 'Важно для SEO и доступности',
},
{
name: 'caption',
type: 'string',
title: 'Подпись',
},
],
}),
defineArrayMember({
type: 'code',
title: 'Код',
options: {
language: 'typescript',
languageAlternatives: [
{ title: 'TypeScript', value: 'typescript' },
{ title: 'JavaScript', value: 'javascript' },
{ title: 'HTML', value: 'html' },
{ title: 'CSS', value: 'css' },
{ title: 'Python', value: 'python' },
{ title: 'Bash', value: 'bash' },
{ title: 'JSON', value: 'json' },
],
withFilename: true,
},
}),
],
})

View File

@ -0,0 +1,114 @@
import type { Metadata } from 'next'
import { Container, GlitchText, Card, PixelBorder } from '@/shared/ui'
import { client } from '@/shared/lib/sanity'
import { AUTHORS_QUERY } from '@/shared/lib/sanity'
import { AuthorCard } from '@/entities/author'
import type { Author } from '@/entities/author'
export const metadata: Metadata = {
title: 'О нас',
description: 'Информация о Совете обучающихся ИМИТ ВолГУ и его участниках',
}
async function getAuthors(): Promise<Author[]> {
try {
return await client.fetch(AUTHORS_QUERY, {}, { next: { revalidate: 300 } })
} catch {
return []
}
}
export default async function AboutPage() {
const authors = await getAuthors()
return (
<Container size="lg" padding="lg" className="py-12">
{/* Header */}
<div className="text-center mb-16">
<GlitchText as="h1" intensity="medium" className="text-3xl sm:text-4xl mb-6">
О нас
</GlitchText>
<p className="text-[var(--color-text-muted)] max-w-2xl mx-auto">
Совет обучающихся ИМИТ ВолГУ это студенческое объединение,
которое представляет интересы студентов и организует мероприятия.
</p>
</div>
{/* Mission section */}
<section className="mb-16">
<Card variant="elevated" padding="lg">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<h2 className="font-pixel text-[var(--color-primary)] text-sm uppercase mb-4">
Наша миссия
</h2>
<p className="text-[var(--color-text)] leading-relaxed">
Мы стремимся создать комфортную и продуктивную среду для студентов ИМИТ,
помогая им раскрыть свой потенциал, развивать soft skills и находить
единомышленников.
</p>
</div>
<div>
<h2 className="font-pixel text-[var(--color-primary)] text-sm uppercase mb-4">
Чем мы занимаемся
</h2>
<ul className="space-y-3 text-[var(--color-text)]">
<li className="flex items-start gap-3">
<span className="w-2 h-2 mt-2 bg-[var(--color-primary)] flex-shrink-0" />
<span>Организация мероприятий и воркшопов</span>
</li>
<li className="flex items-start gap-3">
<span className="w-2 h-2 mt-2 bg-[var(--color-primary)] flex-shrink-0" />
<span>Представление интересов студентов</span>
</li>
<li className="flex items-start gap-3">
<span className="w-2 h-2 mt-2 bg-[var(--color-primary)] flex-shrink-0" />
<span>Помощь в адаптации первокурсников</span>
</li>
<li className="flex items-start gap-3">
<span className="w-2 h-2 mt-2 bg-[var(--color-primary)] flex-shrink-0" />
<span>Развитие студенческого сообщества</span>
</li>
</ul>
</div>
</div>
</Card>
</section>
{/* Stats */}
<section className="mb-16">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ value: '50+', label: 'Мероприятий' },
{ value: '1000+', label: 'Участников' },
{ value: '10+', label: 'Лет работы' },
{ value: '∞', label: 'Идей' },
].map((stat) => (
<PixelBorder key={stat.label} variant="solid" className="p-6 text-center">
<div className="font-pixel text-[var(--color-primary)] text-2xl mb-2">
{stat.value}
</div>
<div className="text-[var(--color-text-muted)] text-sm">
{stat.label}
</div>
</PixelBorder>
))}
</div>
</section>
{/* Team */}
{authors.length > 0 && (
<section>
<h2 className="font-pixel text-[var(--color-primary)] text-sm uppercase mb-8 text-center">
Наша команда
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{authors.map((author) => (
<AuthorCard key={author._id} author={author} variant="full" />
))}
</div>
</section>
)}
</Container>
)
}

View File

@ -0,0 +1,97 @@
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import Link from 'next/link'
import { Container, GlitchText, Button } from '@/shared/ui'
import { client } from '@/shared/lib/sanity'
import { CATEGORY_BY_SLUG_QUERY, POSTS_BY_CATEGORY_QUERY, CATEGORIES_QUERY } from '@/shared/lib/sanity'
import { PostsGrid } from '@/widgets/posts-grid'
import type { Post } from '@/entities/post'
import type { Category } from '@/entities/category'
interface CategoryPageProps {
params: Promise<{ slug: string }>
}
async function getCategory(slug: string): Promise<Category | null> {
return client.fetch(CATEGORY_BY_SLUG_QUERY, { slug }, { next: { revalidate: 300 } })
}
async function getPostsByCategory(categorySlug: string): Promise<Post[]> {
return client.fetch(POSTS_BY_CATEGORY_QUERY, { categorySlug }, { next: { revalidate: 60 } })
}
export async function generateStaticParams() {
try {
const categories = await client.fetch(CATEGORIES_QUERY)
return categories.map((category: Category) => ({ slug: category.slug.current }))
} catch {
return []
}
}
export async function generateMetadata({ params }: CategoryPageProps): Promise<Metadata> {
const { slug } = await params
const category = await getCategory(slug)
if (!category) {
return { title: 'Категория не найдена' }
}
return {
title: category.title,
description: category.description || `Посты в категории "${category.title}"`,
}
}
export default async function CategoryPage({ params }: CategoryPageProps) {
const { slug } = await params
const [category, posts] = await Promise.all([
getCategory(slug),
getPostsByCategory(slug),
])
if (!category) {
notFound()
}
return (
<Container size="xl" padding="lg" className="py-12">
{/* Back link */}
<Link href="/posts" className="inline-block mb-8">
<Button variant="ghost" size="sm">
Все новости
</Button>
</Link>
{/* Header */}
<div className="mb-12">
<div
className="inline-block px-4 py-2 mb-4 font-pixel text-[10px] uppercase"
style={{
backgroundColor: category.color || 'var(--color-primary)',
color: 'var(--color-background)',
}}
>
Категория
</div>
<GlitchText as="h1" intensity="medium" className="text-3xl sm:text-4xl mb-4">
{category.title}
</GlitchText>
{category.description && (
<p className="text-[var(--color-text-muted)] max-w-2xl">
{category.description}
</p>
)}
<p className="text-[var(--color-text-muted)] text-sm mt-4">
{posts.length} {posts.length === 1 ? 'пост' : 'постов'}
</p>
</div>
{/* Posts */}
<PostsGrid posts={posts} columns={3} />
</Container>
)
}

View File

@ -0,0 +1,125 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { Container, GlitchText, Card, Button, PixelBorder } from '@/shared/ui'
import { siteConfig } from '@/shared/config/site'
export const metadata: Metadata = {
title: 'Контакты',
description: 'Свяжитесь с Советом обучающихся ИМИТ ВолГУ',
}
export default function ContactsPage() {
return (
<Container size="lg" padding="lg" className="py-12">
{/* Header */}
<div className="text-center mb-16">
<GlitchText as="h1" intensity="medium" className="text-3xl sm:text-4xl mb-6">
Контакты
</GlitchText>
<p className="text-[var(--color-text-muted)] max-w-2xl mx-auto">
Свяжитесь с нами любым удобным способом мы всегда рады новым идеям и предложениям!
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Contact info */}
<div className="space-y-6">
{/* Address */}
<Card variant="elevated" padding="lg">
<h2 className="font-pixel text-[var(--color-primary)] text-[10px] uppercase mb-4">
Адрес
</h2>
<address className="not-italic text-[var(--color-text)] leading-relaxed">
<p>{siteConfig.contact.address}</p>
<p className="text-[var(--color-text-muted)]">{siteConfig.contact.building}</p>
</address>
</Card>
{/* Social links */}
<Card variant="elevated" padding="lg">
<h2 className="font-pixel text-[var(--color-primary)] text-[10px] uppercase mb-4">
Социальные сети
</h2>
<div className="space-y-4">
<Link
href={siteConfig.social.vk}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 group"
>
<span className="w-12 h-12 flex items-center justify-center border-2 border-[var(--color-border)] group-hover:border-[#4a76a8] transition-colors">
<span className="font-pixel text-[var(--color-text-muted)] group-hover:text-[#4a76a8] text-xs">
VK
</span>
</span>
<span className="text-[var(--color-text)] group-hover:text-[#4a76a8] transition-colors">
ВКонтакте
</span>
</Link>
<Link
href={siteConfig.social.telegram}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 group"
>
<span className="w-12 h-12 flex items-center justify-center border-2 border-[var(--color-border)] group-hover:border-[#0088cc] transition-colors">
<span className="font-pixel text-[var(--color-text-muted)] group-hover:text-[#0088cc] text-xs">
TG
</span>
</span>
<span className="text-[var(--color-text)] group-hover:text-[#0088cc] transition-colors">
Telegram
</span>
</Link>
<Link
href={`mailto:${siteConfig.social.email}`}
className="flex items-center gap-4 group"
>
<span className="w-12 h-12 flex items-center justify-center border-2 border-[var(--color-border)] group-hover:border-[var(--color-primary)] transition-colors">
<span className="font-pixel text-[var(--color-text-muted)] group-hover:text-[var(--color-primary)] text-xs">
@
</span>
</span>
<span className="text-[var(--color-text)] group-hover:text-[var(--color-primary)] transition-colors">
{siteConfig.social.email}
</span>
</Link>
</div>
</Card>
</div>
{/* Info card */}
<div>
<PixelBorder variant="solid" glow className="p-8 h-full">
<h2 className="font-pixel text-[var(--color-primary)] text-sm uppercase mb-6">
Хотите присоединиться?
</h2>
<p className="text-[var(--color-text)] leading-relaxed mb-6">
Мы всегда рады новым участникам! Если вы хотите стать частью команды СО ИМИТ,
у вас есть идеи для мероприятий или вы просто хотите помочь напишите нам.
</p>
<p className="text-[var(--color-text)] leading-relaxed mb-8">
Мы ищем активных и инициативных студентов, готовых развиваться вместе с нами
и делать студенческую жизнь ярче!
</p>
<Link href={`mailto:${siteConfig.social.email}`}>
<Button variant="primary" size="lg" className="w-full">
Написать нам
</Button>
</Link>
{/* Decorative pixels */}
<div className="mt-8 flex justify-center gap-2">
<span className="w-2 h-2 bg-[var(--color-primary)]" />
<span className="w-2 h-2 bg-[var(--color-primary)] opacity-70" />
<span className="w-2 h-2 bg-[var(--color-primary)] opacity-40" />
</div>
</PixelBorder>
</div>
</div>
</Container>
)
}

View File

@ -0,0 +1,191 @@
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { Container, GlitchText, Button, Card } from '@/shared/ui'
import { client, urlForImage } from '@/shared/lib/sanity'
import { EVENT_BY_SLUG_QUERY, EVENT_SLUGS_QUERY } from '@/shared/lib/sanity'
import { PostContent } from '@/entities/post'
import type { Event } from '@/entities/event'
import { eventTypeLabels, eventTypeColors } from '@/entities/event'
import { SharePost } from '@/features/share-post'
import { siteConfig } from '@/shared/config/site'
interface EventPageProps {
params: Promise<{ slug: string }>
}
async function getEvent(slug: string): Promise<Event | null> {
return client.fetch(EVENT_BY_SLUG_QUERY, { slug }, { next: { revalidate: 60 } })
}
export async function generateStaticParams() {
try {
const slugs = await client.fetch(EVENT_SLUGS_QUERY)
return slugs.map((slug: string) => ({ slug }))
} catch {
return []
}
}
export async function generateMetadata({ params }: EventPageProps): Promise<Metadata> {
const { slug } = await params
const event = await getEvent(slug)
if (!event) {
return { title: 'Событие не найдено' }
}
const ogImage = event.image
? urlForImage(event.image)?.width(1200).height(630).url()
: null
return {
title: event.title,
description: `${eventTypeLabels[event.eventType || 'other']} - ${new Date(event.date).toLocaleDateString('ru-RU')}`,
openGraph: {
title: event.title,
type: 'article',
images: ogImage ? [ogImage] : undefined,
},
}
}
export default async function EventPage({ params }: EventPageProps) {
const { slug } = await params
const event = await getEvent(slug)
if (!event) {
notFound()
}
const imageUrl = event.image
? urlForImage(event.image)?.width(1400).height(700).url()
: null
const eventDate = new Date(event.date)
const formattedDate = eventDate.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
const formattedTime = eventDate.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
})
const isPast = eventDate < new Date()
const eventType = event.eventType || 'other'
const eventUrl = `${siteConfig.url}/events/${event.slug.current}`
return (
<article>
{/* Hero image */}
{imageUrl && (
<div className="relative h-[40vh] md:h-[50vh] bg-[var(--color-surface)]">
<Image
src={imageUrl}
alt={event.image?.alt || event.title}
fill
className="object-cover"
priority
/>
<div className="absolute inset-0 bg-gradient-to-t from-[var(--color-background)] to-transparent" />
{isPast && (
<div className="absolute inset-0 bg-[var(--color-background)]/50 flex items-center justify-center">
<span className="font-pixel text-[var(--color-text)] text-xl px-6 py-3 bg-[var(--color-background)] border-4 border-[var(--color-border)]">
Завершено
</span>
</div>
)}
</div>
)}
<Container size="md" padding="lg" className="py-12">
{/* Back link */}
<Link href="/events" className="inline-block mb-8">
<Button variant="ghost" size="sm">
Все события
</Button>
</Link>
{/* Event type badge */}
<div className="mb-6">
<span
className="inline-block px-3 py-1.5 font-pixel text-[10px] uppercase"
style={{
backgroundColor: eventTypeColors[eventType],
color: 'var(--color-background)',
}}
>
{eventTypeLabels[eventType]}
</span>
{event.isHighlighted && (
<span className="inline-block ml-2 px-3 py-1.5 font-pixel text-[10px] uppercase bg-[var(--color-primary)] text-[var(--color-background)]">
Важно
</span>
)}
</div>
{/* Title */}
<GlitchText as="h1" intensity="subtle" className="text-2xl sm:text-3xl md:text-4xl mb-8">
{event.title}
</GlitchText>
{/* Event details card */}
<Card variant="elevated" padding="lg" className="mb-12">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<h3 className="font-pixel text-[var(--color-primary)] text-[10px] uppercase mb-2">
Дата и время
</h3>
<p className="text-[var(--color-text)]">
{formattedDate} в {formattedTime}
</p>
{event.endDate && (
<p className="text-[var(--color-text-muted)] text-sm mt-1">
до {new Date(event.endDate).toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
})}
</p>
)}
</div>
{event.location && (
<div>
<h3 className="font-pixel text-[var(--color-primary)] text-[10px] uppercase mb-2">
Место
</h3>
<p className="text-[var(--color-text)]">{event.location}</p>
</div>
)}
</div>
{event.registrationLink && !isPast && (
<div className="mt-6 pt-6 border-t-2 border-[var(--color-border)]">
<Link href={event.registrationLink} target="_blank" rel="noopener noreferrer">
<Button variant="primary" size="lg" className="w-full sm:w-auto">
Зарегистрироваться
</Button>
</Link>
</div>
)}
</Card>
{/* Description */}
{event.description && (
<div className="mb-12">
<PostContent body={event.description} />
</div>
)}
{/* Share */}
<div className="border-t-4 border-[var(--color-border)] pt-6">
<SharePost title={event.title} url={eventUrl} />
</div>
</Container>
</article>
)
}

View File

@ -0,0 +1,71 @@
import type { Metadata } from 'next'
import { Container, GlitchText } from '@/shared/ui'
import { client } from '@/shared/lib/sanity'
import { EVENTS_QUERY } from '@/shared/lib/sanity'
import { EventsTimeline } from '@/widgets/events-timeline'
import type { Event } from '@/entities/event'
export const metadata: Metadata = {
title: 'События',
description: 'Мероприятия и события Совета обучающихся ИМИТ ВолГУ',
}
async function getEvents(): Promise<Event[]> {
try {
return await client.fetch(EVENTS_QUERY, {}, { next: { revalidate: 60 } })
} catch {
return []
}
}
export default async function EventsPage() {
const events = await getEvents()
// Separate upcoming and past events
const now = new Date()
const upcomingEvents = events.filter((e) => new Date(e.date) >= now)
const pastEvents = events.filter((e) => new Date(e.date) < now)
return (
<Container size="xl" padding="lg" className="py-12">
{/* Header */}
<div className="mb-12">
<GlitchText as="h1" intensity="medium" className="text-3xl sm:text-4xl mb-4">
События
</GlitchText>
<p className="text-[var(--color-text-muted)] max-w-2xl">
Мероприятия, воркшопы, собрания и другие события СО ИМИТ ВолГУ
</p>
</div>
{/* Upcoming events */}
{upcomingEvents.length > 0 && (
<section className="mb-16">
<h2 className="font-pixel text-[var(--color-primary)] text-sm uppercase mb-6">
Ближайшие события
</h2>
<EventsTimeline events={upcomingEvents} variant="featured" />
</section>
)}
{/* Past events */}
{pastEvents.length > 0 && (
<section>
<h2 className="font-pixel text-[var(--color-text-muted)] text-sm uppercase mb-6">
Прошедшие события
</h2>
<EventsTimeline events={pastEvents} variant="grid" />
</section>
)}
{/* No events */}
{events.length === 0 && (
<div className="text-center py-16 border-4 border-dashed border-[var(--color-border)]">
<p className="font-pixel text-[var(--color-text-muted)] text-sm">
Событий пока нет
</p>
</div>
)}
</Container>
)
}

18
src/app/(site)/layout.tsx Normal file
View File

@ -0,0 +1,18 @@
import { Header } from '@/widgets/header'
import { Footer } from '@/widgets/footer'
export default function SiteLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<Header />
<main className="flex-1 pt-[var(--header-height)]">
{children}
</main>
<Footer />
</>
)
}

53
src/app/(site)/page.tsx Normal file
View File

@ -0,0 +1,53 @@
import { Container } from '@/shared/ui'
import { client } from '@/shared/lib/sanity'
import { POSTS_LIMITED_QUERY, UPCOMING_EVENTS_QUERY } from '@/shared/lib/sanity'
import { HeroSection } from '@/widgets/hero-section'
import { PostsGrid } from '@/widgets/posts-grid'
import { EventsTimeline } from '@/widgets/events-timeline'
import type { Post } from '@/entities/post'
import type { Event } from '@/entities/event'
async function getPosts(): Promise<Post[]> {
try {
return await client.fetch(POSTS_LIMITED_QUERY, { limit: 6 }, { next: { revalidate: 60 } })
} catch {
return []
}
}
async function getUpcomingEvents(): Promise<Event[]> {
try {
return await client.fetch(UPCOMING_EVENTS_QUERY, { limit: 3 }, { next: { revalidate: 60 } })
} catch {
return []
}
}
export default async function HomePage() {
const [posts, events] = await Promise.all([getPosts(), getUpcomingEvents()])
return (
<>
<HeroSection />
<Container size="xl" padding="lg">
{/* Latest posts */}
<PostsGrid
posts={posts}
title="Последние новости"
showViewAll
/>
{/* Upcoming events */}
{events.length > 0 && (
<EventsTimeline
events={events}
title="Ближайшие события"
showViewAll
variant="featured"
/>
)}
</Container>
</>
)
}

View File

@ -0,0 +1,172 @@
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { Container, GlitchText, Button, PixelBorder } from '@/shared/ui'
import { client, urlForImage } from '@/shared/lib/sanity'
import { POST_BY_SLUG_QUERY, POST_SLUGS_QUERY } from '@/shared/lib/sanity'
import { PostContent } from '@/entities/post'
import type { PostFull } from '@/entities/post'
import { AuthorCard } from '@/entities/author'
import { CategoryBadge } from '@/entities/category'
import { SharePost } from '@/features/share-post'
import { siteConfig } from '@/shared/config/site'
interface PostPageProps {
params: Promise<{ slug: string }>
}
async function getPost(slug: string): Promise<PostFull | null> {
return client.fetch(POST_BY_SLUG_QUERY, { slug }, { next: { revalidate: 60 } })
}
export async function generateStaticParams() {
try {
const slugs = await client.fetch(POST_SLUGS_QUERY)
return slugs.map((slug: string) => ({ slug }))
} catch {
return []
}
}
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
return { title: 'Пост не найден' }
}
const ogImage = post.mainImage
? urlForImage(post.mainImage)?.width(1200).height(630).url()
: null
return {
title: post.title,
description: post.excerpt || `Читайте "${post.title}" на сайте СО ИМИТ ВолГУ`,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt,
authors: post.author ? [post.author.name] : undefined,
images: ogImage ? [ogImage] : undefined,
},
}
}
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
notFound()
}
const imageUrl = post.mainImage
? urlForImage(post.mainImage)?.width(1400).height(700).url()
: null
const formattedDate = post.publishedAt
? new Date(post.publishedAt).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
: null
const postUrl = `${siteConfig.url}/posts/${post.slug.current}`
return (
<article>
{/* Hero image */}
{imageUrl && (
<div className="relative h-[40vh] md:h-[50vh] bg-[var(--color-surface)]">
<Image
src={imageUrl}
alt={post.mainImage?.alt || post.title}
fill
className="object-cover"
priority
/>
<div className="absolute inset-0 bg-gradient-to-t from-[var(--color-background)] to-transparent" />
</div>
)}
<Container size="md" padding="lg" className="py-12">
{/* Back link */}
<Link href="/posts" className="inline-block mb-8">
<Button variant="ghost" size="sm">
Все новости
</Button>
</Link>
{/* Categories */}
{post.categories && post.categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
{post.categories.map((category) => (
<CategoryBadge key={category.slug.current} category={category} size="md" />
))}
</div>
)}
{/* Title */}
<GlitchText as="h1" intensity="subtle" className="text-2xl sm:text-3xl md:text-4xl mb-6">
{post.title}
</GlitchText>
{/* Meta */}
<div className="flex flex-wrap items-center gap-4 mb-8 text-[var(--color-text-muted)] text-sm">
{formattedDate && <time>{formattedDate}</time>}
{post.author && (
<>
<span className="w-1 h-1 bg-[var(--color-primary)] rounded-full" />
<span>{post.author.name}</span>
</>
)}
</div>
{/* Excerpt */}
{post.excerpt && (
<PixelBorder variant="corner-only" color="primary" className="p-6 mb-12">
<p className="text-[var(--color-text)] text-lg leading-relaxed">
{post.excerpt}
</p>
</PixelBorder>
)}
{/* Content */}
{post.body && (
<div className="mb-12">
<PostContent body={post.body} />
</div>
)}
{/* Share */}
<div className="border-t-4 border-b-4 border-[var(--color-border)] py-6 mb-12">
<SharePost title={post.title} url={postUrl} />
</div>
{/* Author card */}
{post.author && (
<div className="mt-12">
<h3 className="font-pixel text-[var(--color-primary)] text-xs uppercase mb-4">
Об авторе
</h3>
<AuthorCard
author={{
_id: '',
name: post.author.name,
slug: post.author.slug,
avatar: post.author.avatar,
role: post.author.role,
bio: post.author.bio,
}}
variant="full"
/>
</div>
)}
</Container>
</article>
)
}

View File

@ -0,0 +1,95 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { Container, GlitchText } from '@/shared/ui'
import { client } from '@/shared/lib/sanity'
import { POSTS_QUERY, CATEGORIES_QUERY, POSTS_BY_CATEGORY_QUERY } from '@/shared/lib/sanity'
import { PostsGrid } from '@/widgets/posts-grid'
import { CategoryFilter } from '@/features/category-filter'
import { SearchPosts } from '@/features/search-posts'
import type { Post } from '@/entities/post'
import type { Category } from '@/entities/category'
export const metadata: Metadata = {
title: 'Новости',
description: 'Все новости и публикации Совета обучающихся ИМИТ ВолГУ',
}
interface PostsPageProps {
searchParams: Promise<{ category?: string; q?: string }>
}
async function getPosts(categorySlug?: string, searchQuery?: string): Promise<Post[]> {
try {
if (searchQuery) {
// Use raw query for search
const searchPattern = `*${searchQuery}*`
return await client.fetch<Post[]>(
`*[_type == "post" && (title match $searchPattern || excerpt match $searchPattern)] | order(publishedAt desc) {
_id, title, slug, excerpt, mainImage, publishedAt,
"author": author->{name, avatar},
"categories": categories[]->{title, slug, color}
}`,
{ searchPattern },
{ next: { revalidate: 60 } }
)
}
if (categorySlug) {
return await client.fetch(POSTS_BY_CATEGORY_QUERY, { categorySlug }, { next: { revalidate: 60 } })
}
return await client.fetch(POSTS_QUERY, {}, { next: { revalidate: 60 } })
} catch {
return []
}
}
async function getCategories(): Promise<Category[]> {
try {
return await client.fetch(CATEGORIES_QUERY, {}, { next: { revalidate: 300 } })
} catch {
return []
}
}
export default async function PostsPage({ searchParams }: PostsPageProps) {
const params = await searchParams
const [posts, categories] = await Promise.all([
getPosts(params.category, params.q),
getCategories(),
])
return (
<Container size="xl" padding="lg" className="py-12">
{/* Header */}
<div className="mb-12">
<GlitchText as="h1" intensity="medium" className="text-3xl sm:text-4xl mb-4">
Новости
</GlitchText>
<p className="text-[var(--color-text-muted)] max-w-2xl">
Последние новости, события и публикации Совета обучающихся ИМИТ ВолГУ
</p>
</div>
{/* Filters */}
<div className="mb-8 space-y-6">
<Suspense fallback={<div className="h-12 bg-[var(--color-surface)] animate-pulse" />}>
<SearchPosts placeholder="Поиск по новостям..." />
</Suspense>
<Suspense fallback={<div className="h-8 bg-[var(--color-surface)] animate-pulse" />}>
<CategoryFilter categories={categories} activeSlug={params.category} />
</Suspense>
</div>
{/* Results info */}
{(params.category || params.q) && (
<p className="text-[var(--color-text-muted)] text-sm mb-6">
Найдено: {posts.length} {posts.length === 1 ? 'пост' : 'постов'}
{params.q && <span> по запросу &ldquo;{params.q}&rdquo;</span>}
</p>
)}
{/* Posts grid */}
<PostsGrid posts={posts} columns={3} />
</Container>
)
}

View File

@ -1,26 +1,109 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@import "../shared/styles/variables.css";
@import "../shared/styles/animations.css";
@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;
}
/* Colors from design system */
--color-background: var(--color-background);
--color-foreground: var(--color-text);
--color-surface: var(--color-surface);
--color-surface-elevated: var(--color-surface-elevated);
--color-primary: var(--color-primary);
--color-primary-dark: var(--color-primary-dark);
--color-primary-light: var(--color-primary-light);
--color-muted: var(--color-text-muted);
--color-border: var(--color-border);
--color-error: var(--color-error);
--color-success: var(--color-success);
/* Fonts */
--font-pixel: var(--font-pixel);
--font-body: var(--font-body);
/* Shadows */
--shadow-pixel: var(--shadow-pixel);
--shadow-pixel-sm: var(--shadow-pixel-sm);
--shadow-pixel-hover: var(--shadow-pixel-hover);
--shadow-pixel-active: var(--shadow-pixel-active);
}
/* Base styles */
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background: var(--color-background);
color: var(--color-text);
font-family: var(--font-body);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Pixel font utility */
.font-pixel {
font-family: var(--font-pixel);
line-height: 1.4;
letter-spacing: 0.05em;
}
/* Selection styling */
::selection {
background: var(--color-primary);
color: var(--color-background);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: var(--color-surface);
}
::-webkit-scrollbar-thumb {
background: var(--color-primary);
border: 2px solid var(--color-surface);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-primary-light);
}
/* Focus styles */
*:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Pixel grid background pattern */
.pixel-grid {
background-image:
linear-gradient(var(--color-border) 1px, transparent 1px),
linear-gradient(90deg, var(--color-border) 1px, transparent 1px);
background-size: 8px 8px;
}
/* CRT scanline effect overlay */
.crt-overlay {
pointer-events: none;
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.15),
rgba(0, 0, 0, 0.15) 1px,
transparent 1px,
transparent 2px
);
z-index: 9999;
}
/* Noise texture */
.noise-overlay {
pointer-events: none;
position: fixed;
inset: 0;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
z-index: 9998;
}

View File

@ -1,34 +1,44 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import type { Metadata } from 'next'
import { Press_Start_2P } from 'next/font/google'
import './globals.css'
import { siteConfig } from '@/shared/config/site'
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const pressStart2P = Press_Start_2P({
weight: '400',
subsets: ['latin'],
variable: '--font-pixel',
display: 'swap',
})
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
title: {
default: siteConfig.seo.defaultTitle,
template: siteConfig.seo.titleTemplate,
},
description: siteConfig.seo.defaultDescription,
metadataBase: new URL(siteConfig.url),
openGraph: {
type: 'website',
locale: siteConfig.locale,
url: siteConfig.url,
siteName: siteConfig.name,
},
robots: {
index: true,
follow: true,
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<html lang="ru" className={pressStart2P.variable}>
<body className="min-h-screen flex flex-col">
{children}
</body>
</html>
);
)
}

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

@ -0,0 +1,64 @@
import Link from 'next/link'
import { Container, GlitchText, Button } from '@/shared/ui'
import { Header } from '@/widgets/header'
import { Footer } from '@/widgets/footer'
export default function NotFound() {
return (
<>
<Header />
<main className="flex-1 pt-[var(--header-height)] flex items-center">
<Container size="md" padding="lg" className="py-20 text-center">
{/* 404 number */}
<div className="mb-8">
<GlitchText
as="span"
intensity="intense"
className="text-8xl sm:text-9xl md:text-[12rem] leading-none"
>
404
</GlitchText>
</div>
{/* Message */}
<h1 className="font-pixel text-[var(--color-text)] text-lg sm:text-xl mb-4">
Страница не найдена
</h1>
<p className="text-[var(--color-text-muted)] mb-8 max-w-md mx-auto">
К сожалению, запрашиваемая страница не существует или была перемещена.
</p>
{/* Actions */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link href="/">
<Button variant="primary" size="lg">
На главную
</Button>
</Link>
<Link href="/posts">
<Button variant="secondary" size="lg">
Читать новости
</Button>
</Link>
</div>
{/* Decorative glitch lines */}
<div className="mt-16 flex justify-center gap-1">
{[10, 25, 15, 35, 20, 30, 12, 28, 18, 22].map((height, i) => (
<span
key={i}
className="w-1 bg-[var(--color-primary)] animate-glitch"
style={{
height: `${height}px`,
opacity: 0.3 + (i % 3) * 0.2,
animationDelay: `${i * 0.1}s`,
}}
/>
))}
</div>
</Container>
</main>
<Footer />
</>
)
}

View File

@ -1,65 +0,0 @@
import Image from "next/image";
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>
<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>
</main>
</div>
);
}

15
src/app/robots.ts Normal file
View File

@ -0,0 +1,15 @@
import type { MetadataRoute } from 'next'
import { siteConfig } from '@/shared/config/site'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/studio/', '/api/'],
},
],
sitemap: `${siteConfig.url}/sitemap.xml`,
}
}

80
src/app/sitemap.ts Normal file
View File

@ -0,0 +1,80 @@
import type { MetadataRoute } from 'next'
import { client } from '@/shared/lib/sanity'
import { POST_SLUGS_QUERY, EVENT_SLUGS_QUERY, CATEGORIES_QUERY } from '@/shared/lib/sanity'
import { siteConfig } from '@/shared/config/site'
import type { Category } from '@/entities/category'
async function fetchSafely<T>(query: string, fallback: T): Promise<T> {
try {
return await client.fetch(query)
} catch {
return fallback
}
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = siteConfig.url
// Static pages
const staticPages: MetadataRoute.Sitemap = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url: `${baseUrl}/posts`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.9,
},
{
url: `${baseUrl}/events`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.8,
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.7,
},
{
url: `${baseUrl}/contacts`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.7,
},
]
// Dynamic pages - posts
const postSlugs = await fetchSafely<string[]>(POST_SLUGS_QUERY, [])
const postPages: MetadataRoute.Sitemap = postSlugs.map((slug) => ({
url: `${baseUrl}/posts/${slug}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.8,
}))
// Dynamic pages - events
const eventSlugs = await fetchSafely<string[]>(EVENT_SLUGS_QUERY, [])
const eventPages: MetadataRoute.Sitemap = eventSlugs.map((slug) => ({
url: `${baseUrl}/events/${slug}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.7,
}))
// Dynamic pages - categories
const categories = await fetchSafely<Category[]>(CATEGORIES_QUERY, [])
const categoryPages: MetadataRoute.Sitemap = categories.map((category) => ({
url: `${baseUrl}/categories/${category.slug.current}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.6,
}))
return [...staticPages, ...postPages, ...eventPages, ...categoryPages]
}

View File

@ -0,0 +1,22 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Sanity Studio | СО ИМИТ ВолГУ',
description: 'Панель управления контентом',
robots: {
index: false,
follow: false,
},
}
export default function StudioLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ru">
<body style={{ margin: 0 }}>{children}</body>
</html>
)
}

View File

@ -0,0 +1,8 @@
'use client'
import { NextStudio } from 'next-sanity/studio'
import config from '../../../../sanity.config'
export default function StudioPage() {
return <NextStudio config={config} />
}

View File

@ -0,0 +1,2 @@
export { AuthorCard } from './ui/AuthorCard'
export type { Author } from './model/types'

View File

@ -0,0 +1,15 @@
import type { SanityImage } from '@/entities/post'
export interface Author {
_id: string
name: string
slug: { current: string }
avatar?: SanityImage
role?: string
bio?: string
socialLinks?: {
vk?: string
telegram?: string
email?: string
}
}

View File

@ -0,0 +1,126 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { Card } from '@/shared/ui'
import { urlForImage } from '@/shared/lib/sanity'
import type { Author } from '../model/types'
interface AuthorCardProps {
author: Author
variant?: 'compact' | 'full'
}
export function AuthorCard({ author, variant = 'compact' }: AuthorCardProps) {
const avatarUrl = author.avatar
? urlForImage(author.avatar)?.width(200).height(200).url()
: null
if (variant === 'compact') {
return (
<div className="flex items-center gap-4">
<div className="relative w-12 h-12 border-2 border-[var(--color-primary)] flex-shrink-0">
{avatarUrl ? (
<Image
src={avatarUrl}
alt={author.name}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-[var(--color-surface)] flex items-center justify-center">
<span className="font-pixel text-[var(--color-primary)] text-xs">
{author.name.charAt(0)}
</span>
</div>
)}
</div>
<div>
<p className="font-pixel text-[var(--color-text)] text-xs">
{author.name}
</p>
{author.role && (
<p className="text-[var(--color-text-muted)] text-xs mt-1">
{author.role}
</p>
)}
</div>
</div>
)
}
return (
<Card variant="elevated" padding="lg">
<div className="flex flex-col sm:flex-row gap-6 items-start">
{/* Avatar */}
<div className="relative w-24 h-24 border-4 border-[var(--color-primary)] shadow-[4px_4px_0_var(--color-primary-dark)] flex-shrink-0">
{avatarUrl ? (
<Image
src={avatarUrl}
alt={author.name}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-[var(--color-surface)] flex items-center justify-center">
<span className="font-pixel text-[var(--color-primary)] text-2xl">
{author.name.charAt(0)}
</span>
</div>
)}
</div>
{/* Info */}
<div className="flex-1">
<h3 className="font-pixel text-[var(--color-primary)] text-sm mb-2">
{author.name}
</h3>
{author.role && (
<p className="text-[var(--color-text-muted)] text-sm mb-4">
{author.role}
</p>
)}
{author.bio && (
<p className="text-[var(--color-text)] text-sm leading-relaxed">
{author.bio}
</p>
)}
{/* Social links */}
{author.socialLinks && (
<div className="flex gap-4 mt-4">
{author.socialLinks.vk && (
<Link
href={author.socialLinks.vk}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--color-text-muted)] hover:text-[var(--color-primary)] transition-colors text-xs font-pixel"
>
VK
</Link>
)}
{author.socialLinks.telegram && (
<Link
href={author.socialLinks.telegram}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--color-text-muted)] hover:text-[var(--color-primary)] transition-colors text-xs font-pixel"
>
TG
</Link>
)}
{author.socialLinks.email && (
<Link
href={`mailto:${author.socialLinks.email}`}
className="text-[var(--color-text-muted)] hover:text-[var(--color-primary)] transition-colors text-xs font-pixel"
>
EMAIL
</Link>
)}
</div>
)}
</div>
</div>
</Card>
)
}

View File

@ -0,0 +1,2 @@
export { CategoryBadge } from './ui/CategoryBadge'
export type { Category } from './model/types'

View File

@ -0,0 +1,7 @@
export interface Category {
_id: string
title: string
slug: { current: string }
description?: string
color?: string
}

View File

@ -0,0 +1,53 @@
'use client'
import Link from 'next/link'
import type { Category } from '../model/types'
interface CategoryBadgeProps {
category: Category | { title: string; slug: { current: string }; color?: string }
size?: 'sm' | 'md' | 'lg'
asLink?: boolean
}
export function CategoryBadge({ category, size = 'md', asLink = true }: CategoryBadgeProps) {
const sizeStyles = {
sm: 'px-2 py-1 text-[6px]',
md: 'px-3 py-1.5 text-[8px]',
lg: 'px-4 py-2 text-[10px]',
}
const baseStyles = `
inline-flex items-center
font-pixel uppercase tracking-wider
border-2 border-[var(--color-primary)]
transition-all duration-150
${sizeStyles[size]}
`
const content = (
<span
className={`${baseStyles} ${
asLink
? 'hover:bg-[var(--color-primary)] hover:text-[var(--color-background)] cursor-pointer'
: ''
}`}
style={{
backgroundColor: category.color || 'transparent',
color: category.color ? 'var(--color-background)' : 'var(--color-primary)',
borderColor: category.color || 'var(--color-primary)',
}}
>
{category.title}
</span>
)
if (asLink) {
return (
<Link href={`/categories/${category.slug.current}`}>
{content}
</Link>
)
}
return content
}

View File

@ -0,0 +1,3 @@
export { EventCard } from './ui/EventCard'
export type { Event, EventType } from './model/types'
export { eventTypeLabels, eventTypeColors } from './model/types'

View File

@ -0,0 +1,36 @@
import type { PortableTextBlock } from '@portabletext/types'
import type { SanityImage } from '@/entities/post'
export type EventType = 'meeting' | 'workshop' | 'conference' | 'contest' | 'celebration' | 'other'
export interface Event {
_id: string
title: string
slug: { current: string }
eventType?: EventType
date: string
endDate?: string
location?: string
image?: SanityImage
description?: PortableTextBlock[]
isHighlighted?: boolean
registrationLink?: string
}
export const eventTypeLabels: Record<EventType, string> = {
meeting: 'Собрание',
workshop: 'Воркшоп',
conference: 'Конференция',
contest: 'Конкурс',
celebration: 'Праздник',
other: 'Событие',
}
export const eventTypeColors: Record<EventType, string> = {
meeting: '#4A90D9',
workshop: '#50C878',
conference: '#9B59B6',
contest: '#E74C3C',
celebration: '#F39C12',
other: '#95A5A6',
}

View File

@ -0,0 +1,214 @@
'use client'
import Link from 'next/link'
import Image from 'next/image'
import { Card } from '@/shared/ui'
import { urlForImage } from '@/shared/lib/sanity'
import type { Event } from '../model/types'
import { eventTypeLabels, eventTypeColors } from '../model/types'
interface EventCardProps {
event: Event
variant?: 'default' | 'compact' | 'featured'
}
export function EventCard({ event, variant = 'default' }: EventCardProps) {
const imageUrl = event.image
? urlForImage(event.image)?.width(600).height(400).url()
: null
const eventDate = new Date(event.date)
const day = eventDate.getDate()
const month = eventDate.toLocaleDateString('ru-RU', { month: 'short' })
const time = eventDate.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
})
const isPast = eventDate < new Date()
const eventType = event.eventType || 'other'
if (variant === 'compact') {
return (
<Link href={`/events/${event.slug.current}`} className="block group">
<div className="flex gap-4 items-start p-4 border-2 border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors">
{/* Date block */}
<div className="flex-shrink-0 w-14 text-center">
<div className="font-pixel text-[var(--color-primary)] text-lg">
{day}
</div>
<div className="text-[var(--color-text-muted)] text-xs uppercase">
{month}
</div>
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<span
className="inline-block px-2 py-0.5 text-[6px] font-pixel uppercase tracking-wider mb-2"
style={{
backgroundColor: eventTypeColors[eventType],
color: 'var(--color-background)',
}}
>
{eventTypeLabels[eventType]}
</span>
<h4 className="font-pixel text-[var(--color-text)] text-xs group-hover:text-[var(--color-primary)] transition-colors truncate">
{event.title}
</h4>
{event.location && (
<p className="text-[var(--color-text-muted)] text-xs mt-1 truncate">
{event.location}
</p>
)}
</div>
</div>
</Link>
)
}
if (variant === 'featured') {
return (
<Link href={`/events/${event.slug.current}`} className="block group">
<Card variant="elevated" padding="none" className="overflow-hidden">
<div className="flex flex-col md:flex-row">
{/* Image */}
<div className="relative aspect-video md:aspect-auto md:w-1/2 bg-[var(--color-surface-elevated)]">
{imageUrl ? (
<Image
src={imageUrl}
alt={event.image?.alt || event.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<span className="font-pixel text-[var(--color-primary)] text-4xl opacity-30">
{eventTypeLabels[eventType]}
</span>
</div>
)}
{/* Highlighted badge */}
{event.isHighlighted && (
<div className="absolute top-4 right-4">
<span className="px-2 py-1 text-[8px] font-pixel uppercase bg-[var(--color-primary)] text-[var(--color-background)]">
Важно
</span>
</div>
)}
</div>
{/* Content */}
<div className="p-6 md:w-1/2 flex flex-col justify-center">
<span
className="inline-block px-2 py-1 text-[8px] font-pixel uppercase tracking-wider mb-4 self-start"
style={{
backgroundColor: eventTypeColors[eventType],
color: 'var(--color-background)',
}}
>
{eventTypeLabels[eventType]}
</span>
<h3 className="font-pixel text-[var(--color-primary)] text-base mb-4 group-hover:text-[var(--color-primary-light)] transition-colors">
{event.title}
</h3>
<div className="space-y-2 text-sm text-[var(--color-text-muted)]">
<p className="flex items-center gap-2">
<span className="text-[var(--color-primary)]">📅</span>
{eventDate.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
{' в '}
{time}
</p>
{event.location && (
<p className="flex items-center gap-2">
<span className="text-[var(--color-primary)]">📍</span>
{event.location}
</p>
)}
</div>
</div>
</div>
</Card>
</Link>
)
}
// Default variant
return (
<Link href={`/events/${event.slug.current}`} className="block group">
<Card
variant="interactive"
padding="none"
className={`${isPast ? 'opacity-60' : ''}`}
>
{/* Image */}
<div className="relative aspect-[16/9] overflow-hidden bg-[var(--color-surface-elevated)]">
{imageUrl ? (
<Image
src={imageUrl}
alt={event.image?.alt || event.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<span className="font-pixel text-[var(--color-primary)] text-2xl opacity-30">
{eventTypeLabels[eventType]}
</span>
</div>
)}
{/* Date overlay */}
<div className="absolute top-4 left-4 bg-[var(--color-background)] border-2 border-[var(--color-primary)] p-2 text-center">
<div className="font-pixel text-[var(--color-primary)] text-lg leading-none">
{day}
</div>
<div className="text-[var(--color-text)] text-[8px] uppercase mt-1">
{month}
</div>
</div>
{/* Event type badge */}
<div className="absolute top-4 right-4">
<span
className="px-2 py-1 text-[8px] font-pixel uppercase tracking-wider"
style={{
backgroundColor: eventTypeColors[eventType],
color: 'var(--color-background)',
}}
>
{eventTypeLabels[eventType]}
</span>
</div>
{isPast && (
<div className="absolute inset-0 bg-[var(--color-background)]/50 flex items-center justify-center">
<span className="font-pixel text-[var(--color-text-muted)] text-xs">
Завершено
</span>
</div>
)}
</div>
{/* Content */}
<div className="p-5">
<h3 className="font-pixel text-[var(--color-text)] text-xs group-hover:text-[var(--color-primary)] transition-colors mb-3">
{event.title}
</h3>
<div className="space-y-1 text-[var(--color-text-muted)] text-xs">
<p>🕐 {time}</p>
{event.location && <p>📍 {event.location}</p>}
</div>
</div>
</Card>
</Link>
)
}

View File

@ -0,0 +1,3 @@
export { PostCard } from './ui/PostCard'
export { PostContent } from './ui/PostContent'
export type { Post, PostFull, PostAuthor, PostCategory, SanityImage } from './model/types'

View File

@ -0,0 +1,49 @@
import type { PortableTextBlock } from '@portabletext/types'
export interface PostAuthor {
name: string
avatar?: SanityImage
}
export interface PostCategory {
title: string
slug: { current: string }
color?: string
}
export interface SanityImage {
_type: 'image'
asset: {
_ref: string
_type: 'reference'
}
alt?: string
hotspot?: {
x: number
y: number
height: number
width: number
}
}
export interface Post {
_id: string
title: string
slug: { current: string }
excerpt?: string
mainImage?: SanityImage
author?: PostAuthor
categories?: PostCategory[]
publishedAt?: string
body?: PortableTextBlock[]
}
export interface PostFull extends Post {
author?: {
name: string
slug: { current: string }
avatar?: SanityImage
bio?: string
role?: string
}
}

View File

@ -0,0 +1,108 @@
'use client'
import Link from 'next/link'
import Image from 'next/image'
import { Card } from '@/shared/ui'
import { urlForImage } from '@/shared/lib/sanity'
import type { Post } from '../model/types'
interface PostCardProps {
post: Post
priority?: boolean
}
export function PostCard({ post, priority = false }: PostCardProps) {
const imageUrl = post.mainImage ? urlForImage(post.mainImage)?.width(600).height(400).url() : null
const formattedDate = post.publishedAt
? new Date(post.publishedAt).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
: null
return (
<Link href={`/posts/${post.slug.current}`} className="block group">
<Card variant="interactive" padding="none" className="h-full">
{/* Image */}
<div className="relative aspect-[3/2] overflow-hidden bg-[var(--color-surface-elevated)]">
{imageUrl ? (
<Image
src={imageUrl}
alt={post.mainImage?.alt || post.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={priority}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<span className="font-pixel text-[var(--color-primary)] text-4xl opacity-30">
ИМИТ
</span>
</div>
)}
{/* Categories overlay */}
{post.categories && post.categories.length > 0 && (
<div className="absolute top-3 left-3 flex flex-wrap gap-2">
{post.categories.slice(0, 2).map((category) => (
<span
key={category.slug.current}
className="px-2 py-1 text-[8px] font-pixel uppercase tracking-wider bg-[var(--color-background)] text-[var(--color-primary)] border-2 border-[var(--color-primary)]"
style={{
backgroundColor: category.color || 'var(--color-background)',
color: category.color ? 'var(--color-background)' : 'var(--color-primary)',
}}
>
{category.title}
</span>
))}
</div>
)}
</div>
{/* Content */}
<div className="p-5">
{/* Date */}
{formattedDate && (
<time className="block text-[10px] font-pixel text-[var(--color-text-muted)] uppercase tracking-wider mb-2">
{formattedDate}
</time>
)}
{/* Title */}
<h3 className="font-pixel text-[var(--color-text)] text-sm leading-relaxed mb-3 group-hover:text-[var(--color-primary)] transition-colors">
{post.title}
</h3>
{/* Excerpt */}
{post.excerpt && (
<p className="text-[var(--color-text-muted)] text-sm line-clamp-2">
{post.excerpt}
</p>
)}
{/* Author */}
{post.author && (
<div className="mt-4 pt-4 border-t-2 border-[var(--color-border)] flex items-center gap-3">
{post.author.avatar && (
<div className="relative w-8 h-8 border-2 border-[var(--color-primary)]">
<Image
src={urlForImage(post.author.avatar)?.width(64).height(64).url() || ''}
alt={post.author.name}
fill
className="object-cover"
/>
</div>
)}
<span className="text-xs text-[var(--color-text-muted)]">
{post.author.name}
</span>
</div>
)}
</div>
</Card>
</Link>
)
}

View File

@ -0,0 +1,148 @@
'use client'
import Image from 'next/image'
import { PortableText, type PortableTextComponents } from '@portabletext/react'
import type { PortableTextBlock } from '@portabletext/types'
import { urlForImage } from '@/shared/lib/sanity'
interface PostContentProps {
body: PortableTextBlock[]
}
const components: PortableTextComponents = {
block: {
h2: ({ children }) => (
<h2 className="font-pixel text-[var(--color-primary)] text-lg mt-12 mb-6">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="font-pixel text-[var(--color-text)] text-base mt-10 mb-4">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="font-pixel text-[var(--color-text)] text-sm mt-8 mb-3">
{children}
</h4>
),
normal: ({ children }) => (
<p className="text-[var(--color-text)] leading-relaxed mb-6">
{children}
</p>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-[var(--color-primary)] pl-6 py-2 my-8 bg-[var(--color-surface)] italic text-[var(--color-text-muted)]">
{children}
</blockquote>
),
},
list: {
bullet: ({ children }) => (
<ul className="list-none space-y-3 my-6 pl-6">
{children}
</ul>
),
number: ({ children }) => (
<ol className="list-decimal list-inside space-y-3 my-6 pl-6 marker:text-[var(--color-primary)] marker:font-pixel">
{children}
</ol>
),
},
listItem: {
bullet: ({ children }) => (
<li className="relative pl-6 text-[var(--color-text)]">
<span className="absolute left-0 top-2 w-2 h-2 bg-[var(--color-primary)]" />
{children}
</li>
),
number: ({ children }) => (
<li className="text-[var(--color-text)]">
{children}
</li>
),
},
marks: {
strong: ({ children }) => (
<strong className="font-bold text-[var(--color-primary)]">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic">{children}</em>
),
underline: ({ children }) => (
<span className="underline decoration-[var(--color-primary)] decoration-2 underline-offset-4">
{children}
</span>
),
code: ({ children }) => (
<code className="font-mono text-sm bg-[var(--color-surface-elevated)] text-[var(--color-primary)] px-2 py-1 border border-[var(--color-border)]">
{children}
</code>
),
link: ({ children, value }) => {
const target = value?.blank ? '_blank' : undefined
const rel = value?.blank ? 'noopener noreferrer' : undefined
return (
<a
href={value?.href}
target={target}
rel={rel}
className="text-[var(--color-primary)] underline decoration-2 underline-offset-4 hover:text-[var(--color-primary-light)] transition-colors"
>
{children}
</a>
)
},
},
types: {
image: ({ value }) => {
const imageUrl = urlForImage(value)?.width(1200).url()
if (!imageUrl) return null
return (
<figure className="my-10">
<div className="relative border-4 border-[var(--color-border)] shadow-[4px_4px_0_var(--color-primary)]">
<Image
src={imageUrl}
alt={value.alt || 'Изображение'}
width={1200}
height={675}
className="w-full h-auto"
/>
</div>
{value.caption && (
<figcaption className="mt-3 text-sm text-[var(--color-text-muted)] text-center font-pixel">
{value.caption}
</figcaption>
)}
</figure>
)
},
code: ({ value }) => (
<div className="my-8">
{value.filename && (
<div className="bg-[var(--color-surface-elevated)] border-4 border-b-0 border-[var(--color-border)] px-4 py-2">
<span className="font-pixel text-[10px] text-[var(--color-primary)]">
{value.filename}
</span>
</div>
)}
<pre className="bg-[var(--color-surface)] border-4 border-[var(--color-border)] p-4 overflow-x-auto">
<code className="font-mono text-sm text-[var(--color-text)]">
{value.code}
</code>
</pre>
</div>
),
},
}
export function PostContent({ body }: PostContentProps) {
return (
<div className="prose-custom max-w-none">
<PortableText value={body} components={components} />
</div>
)
}

View File

@ -0,0 +1 @@
export { CategoryFilter } from './ui/CategoryFilter'

View File

@ -0,0 +1,79 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import type { Category } from '@/entities/category'
interface CategoryFilterProps {
categories: Category[]
activeSlug?: string
}
export function CategoryFilter({ categories, activeSlug }: CategoryFilterProps) {
const router = useRouter()
const searchParams = useSearchParams()
const handleCategoryClick = (slug: string | null) => {
const params = new URLSearchParams(searchParams.toString())
if (slug) {
params.set('category', slug)
} else {
params.delete('category')
}
router.push(`?${params.toString()}`)
}
return (
<div className="flex flex-wrap gap-3">
{/* All categories button */}
<button
onClick={() => handleCategoryClick(null)}
className={`
px-3 py-1.5 font-pixel text-[8px] uppercase tracking-wider
border-2 transition-all duration-150
${
!activeSlug
? 'bg-[var(--color-primary)] text-[var(--color-background)] border-[var(--color-primary)]'
: 'text-[var(--color-text-muted)] border-[var(--color-border)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]'
}
`}
>
Все
</button>
{/* Category buttons */}
{categories.map((category) => (
<button
key={category._id}
onClick={() => handleCategoryClick(category.slug.current)}
className={`
px-3 py-1.5 font-pixel text-[8px] uppercase tracking-wider
border-2 transition-all duration-150
${
activeSlug === category.slug.current
? 'border-[var(--color-primary)]'
: 'border-[var(--color-border)] hover:border-[var(--color-primary)]'
}
`}
style={{
backgroundColor:
activeSlug === category.slug.current
? category.color || 'var(--color-primary)'
: 'transparent',
color:
activeSlug === category.slug.current
? 'var(--color-background)'
: category.color || 'var(--color-text-muted)',
borderColor:
activeSlug === category.slug.current
? category.color || 'var(--color-primary)'
: undefined,
}}
>
{category.title}
</button>
))}
</div>
)
}

View File

@ -0,0 +1,2 @@
export { SearchPosts } from './ui/SearchPosts'
export { useSearch } from './model/useSearch'

View File

@ -0,0 +1,43 @@
'use client'
import { useCallback, useTransition } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useDebouncedCallback } from 'use-debounce'
export function useSearch() {
const router = useRouter()
const searchParams = useSearchParams()
const [isPending, startTransition] = useTransition()
const query = searchParams.get('q') || ''
const handleSearch = useDebouncedCallback((term: string) => {
const params = new URLSearchParams(searchParams.toString())
if (term) {
params.set('q', term)
} else {
params.delete('q')
}
startTransition(() => {
router.push(`?${params.toString()}`)
})
}, 300)
const clearSearch = useCallback(() => {
const params = new URLSearchParams(searchParams.toString())
params.delete('q')
startTransition(() => {
router.push(`?${params.toString()}`)
})
}, [router, searchParams])
return {
query,
handleSearch,
clearSearch,
isPending,
}
}

View File

@ -0,0 +1,92 @@
'use client'
import { useSearch } from '../model/useSearch'
interface SearchPostsProps {
placeholder?: string
}
export function SearchPosts({ placeholder = 'Поиск...' }: SearchPostsProps) {
const { query, handleSearch, clearSearch, isPending } = useSearch()
return (
<div className="relative">
<div className="relative">
{/* Search icon */}
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 12C9.76142 12 12 9.76142 12 7C12 4.23858 9.76142 2 7 2C4.23858 2 2 4.23858 2 7C2 9.76142 4.23858 12 7 12Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="square"
/>
<path
d="M14 14L10.5 10.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="square"
/>
</svg>
</span>
{/* Input */}
<input
type="text"
defaultValue={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder={placeholder}
className="
w-full pl-12 pr-12 py-3
bg-[var(--color-surface)]
border-4 border-[var(--color-border)]
text-[var(--color-text)] text-sm
placeholder:text-[var(--color-text-muted)]
focus:border-[var(--color-primary)] focus:outline-none
transition-colors
"
/>
{/* Clear button */}
{query && (
<button
onClick={clearSearch}
className="
absolute right-4 top-1/2 -translate-y-1/2
text-[var(--color-text-muted)] hover:text-[var(--color-primary)]
transition-colors
"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 4L12 12M4 12L12 4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="square"
/>
</svg>
</button>
)}
{/* Loading indicator */}
{isPending && (
<span className="absolute right-4 top-1/2 -translate-y-1/2">
<span className="block w-4 h-4 border-2 border-[var(--color-primary)] border-t-transparent animate-spin" />
</span>
)}
</div>
</div>
)
}

View File

@ -0,0 +1 @@
export { SharePost } from './ui/SharePost'

View File

@ -0,0 +1,100 @@
'use client'
import { useState } from 'react'
interface SharePostProps {
title: string
url: string
}
export function SharePost({ title, url }: SharePostProps) {
const [copied, setCopied] = useState(false)
const shareLinks = {
vk: `https://vk.com/share.php?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}`,
telegram: `https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`,
twitter: `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`,
}
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(url)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy:', err)
}
}
return (
<div className="flex flex-wrap items-center gap-3">
<span className="font-pixel text-[var(--color-text-muted)] text-[8px] uppercase tracking-wider">
Поделиться:
</span>
{/* VK */}
<a
href={shareLinks.vk}
target="_blank"
rel="noopener noreferrer"
className="
px-3 py-2 font-pixel text-[8px] uppercase
border-2 border-[var(--color-border)]
text-[var(--color-text-muted)]
hover:border-[#4a76a8] hover:text-[#4a76a8]
transition-colors
"
>
VK
</a>
{/* Telegram */}
<a
href={shareLinks.telegram}
target="_blank"
rel="noopener noreferrer"
className="
px-3 py-2 font-pixel text-[8px] uppercase
border-2 border-[var(--color-border)]
text-[var(--color-text-muted)]
hover:border-[#0088cc] hover:text-[#0088cc]
transition-colors
"
>
TG
</a>
{/* Twitter/X */}
<a
href={shareLinks.twitter}
target="_blank"
rel="noopener noreferrer"
className="
px-3 py-2 font-pixel text-[8px] uppercase
border-2 border-[var(--color-border)]
text-[var(--color-text-muted)]
hover:border-[var(--color-text)] hover:text-[var(--color-text)]
transition-colors
"
>
X
</a>
{/* Copy link */}
<button
onClick={copyToClipboard}
className={`
px-3 py-2 font-pixel text-[8px] uppercase
border-2 transition-colors
${
copied
? 'border-[var(--color-success)] text-[var(--color-success)]'
: 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]'
}
`}
>
{copied ? 'Скопировано!' : 'Копировать'}
</button>
</div>
)
}

20
src/sanity/env.ts Normal file
View File

@ -0,0 +1,20 @@
export const apiVersion =
process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2026-02-01'
export const dataset = assertValue(
process.env.NEXT_PUBLIC_SANITY_DATASET,
'Missing environment variable: NEXT_PUBLIC_SANITY_DATASET'
)
export const projectId = assertValue(
process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
'Missing environment variable: NEXT_PUBLIC_SANITY_PROJECT_ID'
)
function assertValue<T>(v: T | undefined, errorMessage: string): T {
if (v === undefined) {
throw new Error(errorMessage)
}
return v
}

10
src/sanity/lib/client.ts Normal file
View File

@ -0,0 +1,10 @@
import { createClient } from 'next-sanity'
import { apiVersion, dataset, projectId } from '../env'
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: true, // Set to false if statically generating pages, using ISR or tag-based revalidation
})

11
src/sanity/lib/image.ts Normal file
View File

@ -0,0 +1,11 @@
import createImageUrlBuilder from '@sanity/image-url'
import { SanityImageSource } from "@sanity/image-url/lib/types/types";
import { dataset, projectId } from '../env'
// https://www.sanity.io/docs/image-url
const builder = createImageUrlBuilder({ projectId, dataset })
export const urlFor = (source: SanityImageSource) => {
return builder.image(source)
}

9
src/sanity/lib/live.ts Normal file
View File

@ -0,0 +1,9 @@
// Querying with "sanityFetch" will keep content automatically updated
// Before using it, import and render "<SanityLive />" in your layout, see
// https://github.com/sanity-io/next-sanity#live-content-api for more information.
import { defineLive } from "next-sanity/live";
import { client } from './client'
export const { sanityFetch, SanityLive } = defineLive({
client,
});

View File

@ -0,0 +1,46 @@
import {UserIcon} from '@sanity/icons'
import {defineArrayMember, defineField, defineType} from 'sanity'
export const authorType = defineType({
name: 'author',
title: 'Author',
type: 'document',
icon: UserIcon,
fields: [
defineField({
name: 'name',
type: 'string',
}),
defineField({
name: 'slug',
type: 'slug',
options: {
source: 'name',
},
}),
defineField({
name: 'image',
type: 'image',
options: {
hotspot: true,
},
}),
defineField({
name: 'bio',
type: 'array',
of: [
defineArrayMember({
type: 'block',
styles: [{title: 'Normal', value: 'normal'}],
lists: [],
}),
],
}),
],
preview: {
select: {
title: 'name',
media: 'image',
},
},
})

View File

@ -0,0 +1,76 @@
import {defineType, defineArrayMember} from 'sanity'
import {ImageIcon} from '@sanity/icons'
/**
* This is the schema type for block content used in the post document type
* Importing this type into the studio configuration's `schema` property
* lets you reuse it in other document types with:
* {
* name: 'someName',
* title: 'Some title',
* type: 'blockContent'
* }
*/
export const blockContentType = defineType({
title: 'Block Content',
name: 'blockContent',
type: 'array',
of: [
defineArrayMember({
type: 'block',
// Styles let you define what blocks can be marked up as. The default
// set corresponds with HTML tags, but you can set any title or value
// you want, and decide how you want to deal with it where you want to
// use your content.
styles: [
{title: 'Normal', value: 'normal'},
{title: 'H1', value: 'h1'},
{title: 'H2', value: 'h2'},
{title: 'H3', value: 'h3'},
{title: 'H4', value: 'h4'},
{title: 'Quote', value: 'blockquote'},
],
lists: [{title: 'Bullet', value: 'bullet'}],
// Marks let you mark up inline text in the Portable Text Editor
marks: {
// Decorators usually describe a single property e.g. a typographic
// preference or highlighting
decorators: [
{title: 'Strong', value: 'strong'},
{title: 'Emphasis', value: 'em'},
],
// Annotations can be any object structure e.g. a link or a footnote.
annotations: [
{
title: 'URL',
name: 'link',
type: 'object',
fields: [
{
title: 'URL',
name: 'href',
type: 'url',
},
],
},
],
},
}),
// You can add additional types here. Note that you can't use
// primitive types such as 'string' and 'number' in the same array
// as a block type.
defineArrayMember({
type: 'image',
icon: ImageIcon,
options: {hotspot: true},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative Text',
}
]
}),
],
})

View File

@ -0,0 +1,26 @@
import {TagIcon} from '@sanity/icons'
import {defineField, defineType} from 'sanity'
export const categoryType = defineType({
name: 'category',
title: 'Category',
type: 'document',
icon: TagIcon,
fields: [
defineField({
name: 'title',
type: 'string',
}),
defineField({
name: 'slug',
type: 'slug',
options: {
source: 'title',
},
}),
defineField({
name: 'description',
type: 'text',
}),
],
})

View File

@ -0,0 +1,10 @@
import { type SchemaTypeDefinition } from 'sanity'
import {blockContentType} from './blockContentType'
import {categoryType} from './categoryType'
import {postType} from './postType'
import {authorType} from './authorType'
export const schema: { types: SchemaTypeDefinition[] } = {
types: [blockContentType, categoryType, postType, authorType],
}

View File

@ -0,0 +1,65 @@
import {DocumentTextIcon} from '@sanity/icons'
import {defineArrayMember, defineField, defineType} from 'sanity'
export const postType = defineType({
name: 'post',
title: 'Post',
type: 'document',
icon: DocumentTextIcon,
fields: [
defineField({
name: 'title',
type: 'string',
}),
defineField({
name: 'slug',
type: 'slug',
options: {
source: 'title',
},
}),
defineField({
name: 'author',
type: 'reference',
to: {type: 'author'},
}),
defineField({
name: 'mainImage',
type: 'image',
options: {
hotspot: true,
},
fields: [
defineField({
name: 'alt',
type: 'string',
title: 'Alternative text',
})
]
}),
defineField({
name: 'categories',
type: 'array',
of: [defineArrayMember({type: 'reference', to: {type: 'category'}})],
}),
defineField({
name: 'publishedAt',
type: 'datetime',
}),
defineField({
name: 'body',
type: 'blockContent',
}),
],
preview: {
select: {
title: 'title',
author: 'author.name',
media: 'mainImage',
},
prepare(selection) {
const {author} = selection
return {...selection, subtitle: author && `by ${author}`}
},
},
})

15
src/sanity/structure.ts Normal file
View File

@ -0,0 +1,15 @@
import type {StructureResolver} from 'sanity/structure'
// https://www.sanity.io/docs/structure-builder-cheat-sheet
export const structure: StructureResolver = (S) =>
S.list()
.title('Blog')
.items([
S.documentTypeListItem('post').title('Posts'),
S.documentTypeListItem('category').title('Categories'),
S.documentTypeListItem('author').title('Authors'),
S.divider(),
...S.documentTypeListItems().filter(
(item) => item.getId() && !['post', 'category', 'author'].includes(item.getId()!),
),
])

View File

@ -0,0 +1,21 @@
export interface NavItem {
label: string
href: string
external?: boolean
}
export const mainNavigation: NavItem[] = [
{ label: 'Главная', href: '/' },
{ label: 'Новости', href: '/posts' },
{ label: 'События', href: '/events' },
{ label: 'О нас', href: '/about' },
{ label: 'Контакты', href: '/contacts' },
]
export const footerNavigation = {
main: mainNavigation,
social: [
{ label: 'ВКонтакте', href: 'https://vk.com/so_imit', external: true },
{ label: 'Telegram', href: 'https://t.me/so_imit', external: true },
],
}

29
src/shared/config/site.ts Normal file
View File

@ -0,0 +1,29 @@
export const siteConfig = {
name: 'СО ИМИТ ВолГУ',
description: 'Совет обучающихся Института математики и информационных технологий Волгоградского государственного университета',
url: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
locale: 'ru-RU',
// Social links
social: {
vk: 'https://vk.com/so_imit',
telegram: 'https://t.me/so_imit',
email: 'so.imit@volsu.ru',
},
// Contact info
contact: {
address: 'г. Волгоград, пр. Университетский, 100',
building: 'Корпус ИМИТ',
phone: '+7 (8442) XX-XX-XX',
},
// SEO defaults
seo: {
titleTemplate: '%s | СО ИМИТ ВолГУ',
defaultTitle: 'СО ИМИТ ВолГУ',
defaultDescription: 'Новости, события и мероприятия Совета обучающихся ИМИТ ВолГУ',
},
} as const
export type SiteConfig = typeof siteConfig

View File

@ -0,0 +1,12 @@
import { createClient } from 'next-sanity'
export const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!
export const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET || 'production'
export const apiVersion = process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2024-01-01'
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: process.env.NODE_ENV === 'production',
})

View File

@ -0,0 +1,15 @@
import imageUrlBuilder from '@sanity/image-url'
import { client } from './client'
const builder = imageUrlBuilder(client)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function urlFor(source: any) {
return builder.image(source)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function urlForImage(source: any | undefined | null) {
if (!source) return null
return builder.image(source).auto('format').fit('max')
}

View File

@ -0,0 +1,3 @@
export { client, projectId, dataset, apiVersion } from './client'
export { urlFor, urlForImage } from './image'
export * from './queries'

View File

@ -0,0 +1,166 @@
import { defineQuery } from 'next-sanity'
// Posts
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}
}
`)
export const POSTS_LIMITED_QUERY = defineQuery(`
*[_type == "post"] | order(publishedAt desc) [0...$limit] {
_id,
title,
slug,
excerpt,
mainImage,
publishedAt,
"author": author->{name, avatar},
"categories": categories[]->{title, slug, color}
}
`)
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 POST_SLUGS_QUERY = defineQuery(`
*[_type == "post" && defined(slug.current)][].slug.current
`)
// Posts by Category
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}
}
`)
// Categories
export const CATEGORIES_QUERY = defineQuery(`
*[_type == "category"] | order(title asc) {
_id,
title,
slug,
description,
color
}
`)
export const CATEGORY_BY_SLUG_QUERY = defineQuery(`
*[_type == "category" && slug.current == $slug][0] {
_id,
title,
slug,
description,
color
}
`)
// Events
export const EVENTS_QUERY = defineQuery(`
*[_type == "event"] | order(date desc) {
_id,
title,
slug,
eventType,
date,
endDate,
location,
image,
isHighlighted
}
`)
export const UPCOMING_EVENTS_QUERY = defineQuery(`
*[_type == "event" && date >= now()] | order(date asc) [0...$limit] {
_id,
title,
slug,
eventType,
date,
endDate,
location,
image,
isHighlighted
}
`)
export const EVENT_BY_SLUG_QUERY = defineQuery(`
*[_type == "event" && slug.current == $slug][0] {
_id,
title,
slug,
eventType,
date,
endDate,
location,
image,
description,
isHighlighted
}
`)
export const EVENT_SLUGS_QUERY = defineQuery(`
*[_type == "event" && defined(slug.current)][].slug.current
`)
// Authors
export const AUTHORS_QUERY = defineQuery(`
*[_type == "author"] | order(name asc) {
_id,
name,
slug,
avatar,
role,
bio
}
`)
export const AUTHOR_BY_SLUG_QUERY = defineQuery(`
*[_type == "author" && slug.current == $slug][0] {
_id,
name,
slug,
avatar,
role,
bio
}
`)
// Search
export const SEARCH_POSTS_QUERY = defineQuery(`
*[_type == "post" && (title match $query || excerpt match $query)] | order(publishedAt desc) {
_id,
title,
slug,
excerpt,
mainImage,
publishedAt,
"author": author->{name, avatar},
"categories": categories[]->{title, slug, color}
}
`)

View File

@ -0,0 +1,138 @@
/* Glitch Animation */
@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);
}
10% {
transform: skew(2deg);
}
20% {
transform: skew(-1deg);
}
30% {
transform: skew(1.5deg);
}
40% {
transform: skew(-2deg);
}
50% {
transform: skew(0.5deg);
}
60% {
transform: skew(-1deg);
}
70% {
transform: skew(2deg);
}
80% {
transform: skew(-0.5deg);
}
90% {
transform: skew(1deg);
}
100% {
transform: skew(0deg);
}
}
@keyframes glitch-color {
0% {
text-shadow: 2px 0 var(--color-primary), -2px 0 var(--color-text);
}
25% {
text-shadow: -2px 0 var(--color-primary), 2px 0 var(--color-text);
}
50% {
text-shadow: 2px 2px var(--color-primary), -2px -2px var(--color-text);
}
75% {
text-shadow: -2px 2px var(--color-primary), 2px -2px var(--color-text);
}
100% {
text-shadow: 2px 0 var(--color-primary), -2px 0 var(--color-text);
}
}
/* Pixel Fade In */
@keyframes pixel-fade-in {
0% {
opacity: 0;
transform: translateY(8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* Blink Animation */
@keyframes blink {
0%, 50% {
opacity: 1;
}
51%, 100% {
opacity: 0;
}
}
/* Scan Line Effect */
@keyframes scanline {
0% {
transform: translateY(-100%);
}
100% {
transform: translateY(100%);
}
}
/* Pulse Glow */
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 5px var(--color-primary), 0 0 10px var(--color-primary);
}
50% {
box-shadow: 0 0 10px var(--color-primary), 0 0 20px var(--color-primary), 0 0 30px var(--color-primary-dark);
}
}
/* Utility Classes */
.animate-glitch {
animation: glitch 0.3s infinite;
}
.animate-glitch-skew {
animation: glitch-skew 2s infinite linear alternate-reverse;
}
.animate-pixel-fade-in {
animation: pixel-fade-in 0.5s ease-out forwards;
}
.animate-blink {
animation: blink 1s step-end infinite;
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}

View File

@ -0,0 +1,53 @@
:root {
/* Colors */
--color-background: #000000;
--color-surface: #0a0a0a;
--color-surface-elevated: #111111;
--color-primary: #FFD700;
--color-primary-dark: #B8860B;
--color-primary-light: #FFEC8B;
--color-text: #FFFFFF;
--color-text-muted: #888888;
--color-border: #333333;
--color-error: #FF4444;
--color-success: #44FF44;
/* Fonts - --font-pixel is set by next/font in layout.tsx */
--font-body: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
/* Sizes */
--pixel-size: 4px;
--border-width: 4px;
--container-max: 1200px;
--header-height: 80px;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--spacing-2xl: 48px;
--spacing-3xl: 64px;
/* Shadows */
--shadow-pixel: 4px 4px 0 var(--color-primary);
--shadow-pixel-sm: 2px 2px 0 var(--color-primary);
--shadow-pixel-hover: 6px 6px 0 var(--color-primary);
--shadow-pixel-active: 2px 2px 0 var(--color-primary);
/* Border Radius (pixel style = none or minimal) */
--radius-none: 0;
--radius-sm: 2px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-normal: 300ms ease;
--transition-slow: 500ms ease;
/* Z-index */
--z-dropdown: 100;
--z-header: 200;
--z-modal: 300;
--z-tooltip: 400;
}

View File

@ -0,0 +1,113 @@
'use client'
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
isLoading?: boolean
leftIcon?: ReactNode
rightIcon?: ReactNode
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
variant = 'primary',
size = 'md',
isLoading = false,
leftIcon,
rightIcon,
className = '',
disabled,
...props
},
ref
) => {
const baseStyles = `
relative inline-flex items-center justify-center gap-2
font-pixel uppercase tracking-wider
border-4 border-current
transition-all duration-150 ease-out
select-none cursor-pointer
disabled:cursor-not-allowed disabled:opacity-50
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]
`
const variantStyles = {
primary: `
bg-[var(--color-primary)] text-[var(--color-background)]
border-[var(--color-primary-dark)]
shadow-[4px_4px_0_var(--color-primary-dark)]
hover:shadow-[6px_6px_0_var(--color-primary-dark)]
hover:translate-x-[-2px] hover:translate-y-[-2px]
active:shadow-[2px_2px_0_var(--color-primary-dark)]
active:translate-x-[2px] active:translate-y-[2px]
`,
secondary: `
bg-[var(--color-background)] text-[var(--color-primary)]
border-[var(--color-primary)]
shadow-[4px_4px_0_var(--color-primary)]
hover:shadow-[6px_6px_0_var(--color-primary)]
hover:translate-x-[-2px] hover:translate-y-[-2px]
active:shadow-[2px_2px_0_var(--color-primary)]
active:translate-x-[2px] active:translate-y-[2px]
`,
ghost: `
bg-transparent text-[var(--color-primary)]
border-transparent
hover:bg-[var(--color-surface)]
hover:border-[var(--color-primary)]
active:bg-[var(--color-surface-elevated)]
`,
danger: `
bg-[var(--color-error)] text-[var(--color-background)]
border-[#aa0000]
shadow-[4px_4px_0_#aa0000]
hover:shadow-[6px_6px_0_#aa0000]
hover:translate-x-[-2px] hover:translate-y-[-2px]
active:shadow-[2px_2px_0_#aa0000]
active:translate-x-[2px] active:translate-y-[2px]
`,
}
const sizeStyles = {
sm: 'px-3 py-1.5 text-[8px]',
md: 'px-5 py-2.5 text-[10px]',
lg: 'px-7 py-3.5 text-[12px]',
}
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
{...props}
>
{isLoading ? (
<>
<span className="inline-block w-4 h-4 border-2 border-current border-t-transparent animate-spin" />
<span>Loading...</span>
</>
) : (
<>
{leftIcon && <span className="inline-flex">{leftIcon}</span>}
{children}
{rightIcon && <span className="inline-flex">{rightIcon}</span>}
</>
)}
{/* Pixel corner decorations for primary/secondary */}
{(variant === 'primary' || variant === 'secondary') && (
<>
<span className="absolute -top-1 -left-1 w-2 h-2 bg-[var(--color-primary-light)]" />
<span className="absolute -bottom-1 -right-1 w-2 h-2 bg-[var(--color-primary-dark)]" />
</>
)}
</button>
)
}
)
Button.displayName = 'Button'

View File

@ -0,0 +1 @@
export { Button, type ButtonProps } from './Button'

142
src/shared/ui/card/Card.tsx Normal file
View File

@ -0,0 +1,142 @@
'use client'
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'elevated' | 'outlined' | 'interactive'
padding?: 'none' | 'sm' | 'md' | 'lg'
header?: ReactNode
footer?: ReactNode
}
export const Card = forwardRef<HTMLDivElement, CardProps>(
(
{
children,
variant = 'default',
padding = 'md',
header,
footer,
className = '',
...props
},
ref
) => {
const baseStyles = `
relative
bg-[var(--color-surface)]
border-4 border-[var(--color-border)]
overflow-hidden
`
const variantStyles = {
default: `
shadow-[4px_4px_0_var(--color-border)]
`,
elevated: `
border-[var(--color-primary)]
shadow-[4px_4px_0_var(--color-primary),8px_8px_0_var(--color-primary-dark)]
`,
outlined: `
bg-transparent
border-[var(--color-primary)]
shadow-none
`,
interactive: `
border-[var(--color-border)]
shadow-[4px_4px_0_var(--color-border)]
transition-all duration-150
hover:border-[var(--color-primary)]
hover:shadow-[6px_6px_0_var(--color-primary)]
hover:translate-x-[-2px] hover:translate-y-[-2px]
cursor-pointer
`,
}
const paddingStyles = {
none: '',
sm: 'p-3',
md: 'p-5',
lg: 'p-7',
}
return (
<div
ref={ref}
className={`${baseStyles} ${variantStyles[variant]} ${className}`}
{...props}
>
{/* Pixel corner accents */}
<span className="absolute top-0 left-0 w-3 h-3 border-b-4 border-r-4 border-[var(--color-primary)] opacity-50" />
<span className="absolute top-0 right-0 w-3 h-3 border-b-4 border-l-4 border-[var(--color-primary)] opacity-50" />
<span className="absolute bottom-0 left-0 w-3 h-3 border-t-4 border-r-4 border-[var(--color-primary)] opacity-50" />
<span className="absolute bottom-0 right-0 w-3 h-3 border-t-4 border-l-4 border-[var(--color-primary)] opacity-50" />
{header && (
<div className="border-b-4 border-[var(--color-border)] px-5 py-3 bg-[var(--color-surface-elevated)]">
{header}
</div>
)}
<div className={paddingStyles[padding]}>{children}</div>
{footer && (
<div className="border-t-4 border-[var(--color-border)] px-5 py-3 bg-[var(--color-surface-elevated)]">
{footer}
</div>
)}
</div>
)
}
)
Card.displayName = 'Card'
// Card sub-components
export type CardHeaderProps = HTMLAttributes<HTMLDivElement>
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
({ children, className = '', ...props }, ref) => (
<div
ref={ref}
className={`font-pixel text-[var(--color-primary)] text-sm uppercase tracking-wider ${className}`}
{...props}
>
{children}
</div>
)
)
CardHeader.displayName = 'CardHeader'
export type CardTitleProps = HTMLAttributes<HTMLHeadingElement>
export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
({ children, className = '', ...props }, ref) => (
<h3
ref={ref}
className={`font-pixel text-[var(--color-text)] text-base leading-relaxed ${className}`}
{...props}
>
{children}
</h3>
)
)
CardTitle.displayName = 'CardTitle'
export type CardDescriptionProps = HTMLAttributes<HTMLParagraphElement>
export const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionProps>(
({ children, className = '', ...props }, ref) => (
<p
ref={ref}
className={`text-[var(--color-text-muted)] text-sm leading-relaxed ${className}`}
{...props}
>
{children}
</p>
)
)
CardDescription.displayName = 'CardDescription'

View File

@ -0,0 +1,10 @@
export {
Card,
CardHeader,
CardTitle,
CardDescription,
type CardProps,
type CardHeaderProps,
type CardTitleProps,
type CardDescriptionProps,
} from './Card'

View File

@ -0,0 +1,55 @@
'use client'
import { forwardRef, type HTMLAttributes, type Ref } from 'react'
export interface ContainerProps extends HTMLAttributes<HTMLDivElement> {
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
padding?: 'none' | 'sm' | 'md' | 'lg'
as?: 'div' | 'section' | 'main' | 'article'
}
export const Container = forwardRef<HTMLDivElement, ContainerProps>(
(
{
children,
size = 'lg',
padding = 'md',
as: Component = 'div',
className = '',
...props
},
ref
) => {
const sizeStyles = {
sm: 'max-w-2xl', // 672px
md: 'max-w-4xl', // 896px
lg: 'max-w-6xl', // 1152px
xl: 'max-w-7xl', // 1280px
full: 'max-w-full',
}
const paddingStyles = {
none: 'px-0',
sm: 'px-4',
md: 'px-6 md:px-8',
lg: 'px-8 md:px-12',
}
return (
<Component
ref={ref as Ref<HTMLDivElement>}
className={`
w-full mx-auto
${sizeStyles[size]}
${paddingStyles[padding]}
${className}
`}
{...props}
>
{children}
</Component>
)
}
)
Container.displayName = 'Container'

View File

@ -0,0 +1 @@
export { Container, type ContainerProps } from './Container'

View File

@ -0,0 +1,157 @@
'use client'
import { forwardRef, type HTMLAttributes } from 'react'
export interface GlitchTextProps extends HTMLAttributes<HTMLElement> {
as?: 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'p'
intensity?: 'subtle' | 'medium' | 'intense'
color?: 'primary' | 'white' | 'inherit'
animate?: boolean
}
export const GlitchText = forwardRef<HTMLElement, GlitchTextProps>(
(
{
children,
as: Component = 'span',
intensity = 'medium',
color = 'primary',
animate = true,
className = '',
...props
},
ref
) => {
const text = typeof children === 'string' ? children : ''
const colorStyles = {
primary: 'text-[var(--color-primary)]',
white: 'text-[var(--color-text)]',
inherit: '',
}
const intensityConfig = {
subtle: {
offset: '1px',
duration: '4s',
clipTop: '45%',
clipBottom: '55%',
},
medium: {
offset: '2px',
duration: '2.5s',
clipTop: '35%',
clipBottom: '65%',
},
intense: {
offset: '3px',
duration: '1.5s',
clipTop: '25%',
clipBottom: '75%',
},
}
const config = intensityConfig[intensity]
const baseStyles = `
relative inline-block
font-pixel uppercase tracking-wider
${colorStyles[color]}
`
return (
<Component
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref={ref as any}
className={`glitch-wrapper ${baseStyles} ${className}`}
data-text={text}
{...props}
>
{/* Main text */}
<span className="relative z-10">{children}</span>
{/* Glitch layers - only render if animate is true */}
{animate && text && (
<>
{/* Top glitch layer */}
<span
className="absolute inset-0 z-20 opacity-80"
style={{
color: 'var(--color-primary)',
clipPath: `polygon(0 0, 100% 0, 100% ${config.clipTop}, 0 ${config.clipTop})`,
animation: animate
? `glitch-top ${config.duration} infinite linear alternate-reverse`
: 'none',
}}
aria-hidden="true"
>
{children}
</span>
{/* Bottom glitch layer */}
<span
className="absolute inset-0 z-20 opacity-80"
style={{
color: 'var(--color-text)',
clipPath: `polygon(0 ${config.clipBottom}, 100% ${config.clipBottom}, 100% 100%, 0 100%)`,
animation: animate
? `glitch-bottom ${config.duration} infinite linear alternate-reverse`
: 'none',
animationDelay: '-0.5s',
}}
aria-hidden="true"
>
{children}
</span>
</>
)}
<style jsx>{`
@keyframes glitch-top {
0%, 100% {
transform: translate(0);
opacity: 0.8;
}
20% {
transform: translate(${config.offset}, -${config.offset});
}
40% {
transform: translate(-${config.offset}, ${config.offset});
opacity: 0.6;
}
60% {
transform: translate(${config.offset}, ${config.offset});
}
80% {
transform: translate(-${config.offset}, -${config.offset});
opacity: 0.9;
}
}
@keyframes glitch-bottom {
0%, 100% {
transform: translate(0);
opacity: 0.8;
}
20% {
transform: translate(-${config.offset}, ${config.offset});
}
40% {
transform: translate(${config.offset}, -${config.offset});
opacity: 0.6;
}
60% {
transform: translate(-${config.offset}, -${config.offset});
}
80% {
transform: translate(${config.offset}, ${config.offset});
opacity: 0.9;
}
}
`}</style>
</Component>
)
}
)
GlitchText.displayName = 'GlitchText'

View File

@ -0,0 +1 @@
export { GlitchText, type GlitchTextProps } from './GlitchText'

26
src/shared/ui/index.ts Normal file
View File

@ -0,0 +1,26 @@
// Button
export { Button, type ButtonProps } from './button'
// Card
export {
Card,
CardHeader,
CardTitle,
CardDescription,
type CardProps,
type CardHeaderProps,
type CardTitleProps,
type CardDescriptionProps,
} from './card'
// GlitchText
export { GlitchText, type GlitchTextProps } from './glitch-text'
// PixelBorder
export { PixelBorder, type PixelBorderProps } from './pixel-border'
// Container
export { Container, type ContainerProps } from './container'
// PixelLogo
export { PixelLogo, type PixelLogoProps } from './pixel-logo'

View File

@ -0,0 +1,196 @@
'use client'
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
export interface PixelBorderProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'solid' | 'dashed' | 'double' | 'corner-only'
color?: 'primary' | 'muted' | 'white'
thickness?: 'thin' | 'medium' | 'thick'
glow?: boolean
children?: ReactNode
}
export const PixelBorder = forwardRef<HTMLDivElement, PixelBorderProps>(
(
{
children,
variant = 'solid',
color = 'primary',
thickness = 'medium',
glow = false,
className = '',
...props
},
ref
) => {
const colorMap = {
primary: 'var(--color-primary)',
muted: 'var(--color-border)',
white: 'var(--color-text)',
}
const thicknessMap = {
thin: '2px',
medium: '4px',
thick: '6px',
}
const borderColor = colorMap[color]
const borderWidth = thicknessMap[thickness]
const baseStyles = `relative`
const glowStyles = glow
? `shadow-[0_0_10px_${borderColor},0_0_20px_${borderColor}]`
: ''
// Corner-only variant uses pseudo-elements
if (variant === 'corner-only') {
return (
<div
ref={ref}
className={`${baseStyles} ${glowStyles} ${className}`}
{...props}
>
{/* Top-left corner */}
<span
className="absolute top-0 left-0 pointer-events-none"
style={{
width: '16px',
height: borderWidth,
backgroundColor: borderColor,
}}
/>
<span
className="absolute top-0 left-0 pointer-events-none"
style={{
width: borderWidth,
height: '16px',
backgroundColor: borderColor,
}}
/>
{/* Top-right corner */}
<span
className="absolute top-0 right-0 pointer-events-none"
style={{
width: '16px',
height: borderWidth,
backgroundColor: borderColor,
}}
/>
<span
className="absolute top-0 right-0 pointer-events-none"
style={{
width: borderWidth,
height: '16px',
backgroundColor: borderColor,
}}
/>
{/* Bottom-left corner */}
<span
className="absolute bottom-0 left-0 pointer-events-none"
style={{
width: '16px',
height: borderWidth,
backgroundColor: borderColor,
}}
/>
<span
className="absolute bottom-0 left-0 pointer-events-none"
style={{
width: borderWidth,
height: '16px',
backgroundColor: borderColor,
}}
/>
{/* Bottom-right corner */}
<span
className="absolute bottom-0 right-0 pointer-events-none"
style={{
width: '16px',
height: borderWidth,
backgroundColor: borderColor,
}}
/>
<span
className="absolute bottom-0 right-0 pointer-events-none"
style={{
width: borderWidth,
height: '16px',
backgroundColor: borderColor,
}}
/>
{children}
</div>
)
}
const borderStyles = {
solid: {
borderStyle: 'solid',
borderWidth,
borderColor,
},
dashed: {
borderStyle: 'dashed',
borderWidth,
borderColor,
backgroundImage: `
repeating-linear-gradient(
90deg,
${borderColor},
${borderColor} 8px,
transparent 8px,
transparent 16px
)
`,
backgroundSize: '100% ' + borderWidth,
backgroundPosition: 'top, bottom',
backgroundRepeat: 'no-repeat',
},
double: {
borderStyle: 'double',
borderWidth: `calc(${borderWidth} * 1.5)`,
borderColor,
},
}
return (
<div
ref={ref}
className={`${baseStyles} ${glowStyles} ${className}`}
style={borderStyles[variant]}
{...props}
>
{/* Pixel accent squares at corners for solid variant */}
{variant === 'solid' && (
<>
<span
className="absolute -top-[2px] -left-[2px] pointer-events-none"
style={{
width: borderWidth,
height: borderWidth,
backgroundColor: 'var(--color-primary-light)',
}}
/>
<span
className="absolute -bottom-[2px] -right-[2px] pointer-events-none"
style={{
width: borderWidth,
height: borderWidth,
backgroundColor: 'var(--color-primary-dark)',
}}
/>
</>
)}
{children}
</div>
)
}
)
PixelBorder.displayName = 'PixelBorder'

View File

@ -0,0 +1 @@
export { PixelBorder, type PixelBorderProps } from './PixelBorder'

View File

@ -0,0 +1,135 @@
'use client'
import { forwardRef, type SVGAttributes } from 'react'
export interface PixelLogoProps extends SVGAttributes<SVGSVGElement> {
size?: 'sm' | 'md' | 'lg' | 'xl'
animate?: boolean
}
export const PixelLogo = forwardRef<SVGSVGElement, PixelLogoProps>(
({ size = 'md', animate = true, className = '', ...props }, ref) => {
const sizeMap = {
sm: { width: 120, height: 40 },
md: { width: 180, height: 60 },
lg: { width: 240, height: 80 },
xl: { width: 320, height: 100 },
}
const { width, height } = sizeMap[size]
// Isometric 3D pixel logo "ИМИТ"
return (
<svg
ref={ref}
width={width}
height={height}
viewBox="0 0 180 60"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`${animate ? 'hover:scale-105 transition-transform duration-300' : ''} ${className}`}
role="img"
aria-label="ИМИТ логотип"
{...props}
>
<defs>
{/* Gradient for 3D effect */}
<linearGradient id="pixelGold" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#FFEC8B" />
<stop offset="50%" stopColor="#FFD700" />
<stop offset="100%" stopColor="#B8860B" />
</linearGradient>
<linearGradient id="pixelShadow" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#B8860B" />
<stop offset="100%" stopColor="#8B6914" />
</linearGradient>
{/* Glow filter */}
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="2" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<g filter={animate ? 'url(#glow)' : undefined}>
{/* И - first letter */}
<g>
{/* Main face */}
<rect x="8" y="12" width="6" height="36" fill="url(#pixelGold)" />
<rect x="26" y="12" width="6" height="36" fill="url(#pixelGold)" />
{/* Diagonal */}
<polygon points="14,12 20,24 26,12 20,12" fill="url(#pixelGold)" />
<polygon points="14,48 20,36 26,48 20,48" fill="url(#pixelGold)" />
<rect x="17" y="24" width="6" height="12" fill="url(#pixelGold)" />
{/* 3D depth - right side */}
<polygon points="32,12 32,48 34,50 34,14" fill="url(#pixelShadow)" />
{/* 3D depth - bottom */}
<polygon points="8,48 32,48 34,50 10,50" fill="url(#pixelShadow)" />
</g>
{/* М - second letter */}
<g>
{/* Left pillar */}
<rect x="42" y="12" width="6" height="36" fill="url(#pixelGold)" />
{/* Right pillar */}
<rect x="66" y="12" width="6" height="36" fill="url(#pixelGold)" />
{/* Peak */}
<polygon points="48,12 57,28 66,12 60,12 57,20 54,12" fill="url(#pixelGold)" />
{/* 3D depth */}
<polygon points="72,12 72,48 74,50 74,14" fill="url(#pixelShadow)" />
<polygon points="42,48 72,48 74,50 44,50" fill="url(#pixelShadow)" />
</g>
{/* И - third letter */}
<g>
{/* Main face */}
<rect x="82" y="12" width="6" height="36" fill="url(#pixelGold)" />
<rect x="100" y="12" width="6" height="36" fill="url(#pixelGold)" />
{/* Diagonal */}
<polygon points="88,12 94,24 100,12 94,12" fill="url(#pixelGold)" />
<polygon points="88,48 94,36 100,48 94,48" fill="url(#pixelGold)" />
<rect x="91" y="24" width="6" height="12" fill="url(#pixelGold)" />
{/* 3D depth */}
<polygon points="106,12 106,48 108,50 108,14" fill="url(#pixelShadow)" />
<polygon points="82,48 106,48 108,50 84,50" fill="url(#pixelShadow)" />
</g>
{/* Т - fourth letter */}
<g>
{/* Horizontal top bar */}
<rect x="116" y="12" width="36" height="6" fill="url(#pixelGold)" />
{/* Vertical stem */}
<rect x="131" y="18" width="6" height="30" fill="url(#pixelGold)" />
{/* 3D depth */}
<polygon points="152,12 152,18 154,20 154,14" fill="url(#pixelShadow)" />
<polygon points="116,18 152,18 154,20 118,20" fill="url(#pixelShadow)" />
<polygon points="137,18 137,48 139,50 139,20" fill="url(#pixelShadow)" />
<polygon points="131,48 137,48 139,50 133,50" fill="url(#pixelShadow)" />
</g>
</g>
{/* Decorative pixels */}
{animate && (
<>
<rect x="2" y="8" width="4" height="4" fill="#FFD700" opacity="0.6">
<animate attributeName="opacity" values="0.6;1;0.6" dur="2s" repeatCount="indefinite" />
</rect>
<rect x="160" y="8" width="4" height="4" fill="#FFD700" opacity="0.6">
<animate attributeName="opacity" values="0.6;1;0.6" dur="2s" repeatCount="indefinite" begin="0.5s" />
</rect>
<rect x="2" y="48" width="4" height="4" fill="#FFD700" opacity="0.6">
<animate attributeName="opacity" values="0.6;1;0.6" dur="2s" repeatCount="indefinite" begin="1s" />
</rect>
<rect x="160" y="48" width="4" height="4" fill="#FFD700" opacity="0.6">
<animate attributeName="opacity" values="0.6;1;0.6" dur="2s" repeatCount="indefinite" begin="1.5s" />
</rect>
</>
)}
</svg>
)
}
)
PixelLogo.displayName = 'PixelLogo'

View File

@ -0,0 +1 @@
export { PixelLogo, type PixelLogoProps } from './PixelLogo'

View File

@ -0,0 +1 @@
export { EventsTimeline } from './ui/EventsTimeline'

View File

@ -0,0 +1,90 @@
'use client'
import { EventCard } from '@/entities/event'
import type { Event } from '@/entities/event'
import { GlitchText, Button } from '@/shared/ui'
import Link from 'next/link'
interface EventsTimelineProps {
events: Event[]
title?: string
showViewAll?: boolean
variant?: 'grid' | 'list' | 'featured'
}
export function EventsTimeline({
events,
title,
showViewAll = false,
variant = 'grid',
}: EventsTimelineProps) {
if (events.length === 0) {
return (
<section className="py-12">
<div className="text-center py-16 border-4 border-dashed border-[var(--color-border)]">
<p className="font-pixel text-[var(--color-text-muted)] text-sm">
Событий пока нет
</p>
</div>
</section>
)
}
return (
<section className="py-12">
{/* Header */}
{(title || showViewAll) && (
<div className="flex items-center justify-between mb-8">
{title && (
<GlitchText
as="h2"
intensity="subtle"
className="text-xl sm:text-2xl"
>
{title}
</GlitchText>
)}
{showViewAll && (
<Link href="/events">
<Button variant="ghost" size="sm">
Все события
</Button>
</Link>
)}
</div>
)}
{/* Featured variant - first event large, rest in grid */}
{variant === 'featured' && events.length > 0 && (
<div className="space-y-8">
<EventCard event={events[0]} variant="featured" />
{events.length > 1 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{events.slice(1).map((event) => (
<EventCard key={event._id} event={event} />
))}
</div>
)}
</div>
)}
{/* Grid variant */}
{variant === 'grid' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{events.map((event) => (
<EventCard key={event._id} event={event} />
))}
</div>
)}
{/* List variant */}
{variant === 'list' && (
<div className="space-y-4">
{events.map((event) => (
<EventCard key={event._id} event={event} variant="compact" />
))}
</div>
)}
</section>
)
}

View File

@ -0,0 +1 @@
export { Footer } from './ui/Footer'

View File

@ -0,0 +1,109 @@
'use client'
import Link from 'next/link'
import { Container, PixelLogo, PixelBorder } from '@/shared/ui'
import { mainNavigation, footerNavigation } from '@/shared/config/navigation'
import { siteConfig } from '@/shared/config/site'
export function Footer() {
const currentYear = new Date().getFullYear()
return (
<footer className="bg-[var(--color-surface)] border-t-4 border-[var(--color-border)] mt-auto">
<Container size="xl" padding="lg">
<div className="py-12">
{/* Main footer content */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12">
{/* Logo & Description */}
<div className="lg:col-span-2">
<Link href="/" className="inline-block mb-4">
<PixelLogo size="md" />
</Link>
<p className="text-[var(--color-text-muted)] text-sm max-w-md mb-6">
{siteConfig.description}
</p>
{/* Social links */}
<div className="flex gap-3">
{footerNavigation.social.map((item) => (
<Link
key={item.href}
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="
px-4 py-2 font-pixel text-[8px] uppercase
border-2 border-[var(--color-border)]
text-[var(--color-text-muted)]
hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]
transition-colors
"
>
{item.label}
</Link>
))}
</div>
</div>
{/* Navigation */}
<div>
<h4 className="font-pixel text-[var(--color-primary)] text-[10px] uppercase mb-4">
Навигация
</h4>
<nav className="space-y-2">
{mainNavigation.map((item) => (
<Link
key={item.href}
href={item.href}
className="
block text-[var(--color-text-muted)] text-sm
hover:text-[var(--color-primary)] transition-colors
"
>
{item.label}
</Link>
))}
</nav>
</div>
{/* Contact */}
<div>
<h4 className="font-pixel text-[var(--color-primary)] text-[10px] uppercase mb-4">
Контакты
</h4>
<address className="not-italic space-y-2 text-[var(--color-text-muted)] text-sm">
<p>{siteConfig.contact.address}</p>
<p>{siteConfig.contact.building}</p>
<p>
<Link
href={`mailto:${siteConfig.social.email}`}
className="hover:text-[var(--color-primary)] transition-colors"
>
{siteConfig.social.email}
</Link>
</p>
</address>
</div>
</div>
{/* Divider */}
<PixelBorder variant="corner-only" color="muted" className="my-8 h-px" />
{/* Bottom bar */}
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
<p className="text-[var(--color-text-muted)] text-xs">
© {currentYear} {siteConfig.name}. Все права защищены.
</p>
{/* Pixel decoration */}
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-[var(--color-primary)]" />
<span className="w-2 h-2 bg-[var(--color-primary)] opacity-60" />
<span className="w-2 h-2 bg-[var(--color-primary)] opacity-30" />
</div>
</div>
</div>
</Container>
</footer>
)
}

View File

@ -0,0 +1 @@
export { Header } from './ui/Header'

View File

@ -0,0 +1,120 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Container, PixelLogo } from '@/shared/ui'
import { mainNavigation } from '@/shared/config/navigation'
export function Header() {
const [isMenuOpen, setIsMenuOpen] = useState(false)
const pathname = usePathname()
return (
<header className="fixed top-0 left-0 right-0 z-[var(--z-header)] bg-[var(--color-background)] border-b-4 border-[var(--color-border)]">
<Container size="xl" padding="md">
<div className="flex items-center justify-between h-[var(--header-height)]">
{/* Logo */}
<Link href="/" className="flex items-center gap-3 group">
<PixelLogo size="sm" />
<div className="hidden sm:block">
<span className="font-pixel text-[var(--color-primary)] text-[8px] block">
СОВЕТ ОБУЧАЮЩИХСЯ
</span>
<span className="text-[var(--color-text-muted)] text-[10px]">
ИМИТ ВолГУ
</span>
</div>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-1">
{mainNavigation.map((item) => {
const isActive = pathname === item.href
return (
<Link
key={item.href}
href={item.href}
className={`
px-4 py-2 font-pixel text-[10px] uppercase tracking-wider
transition-all duration-150
border-2 border-transparent
${
isActive
? 'text-[var(--color-background)] bg-[var(--color-primary)] border-[var(--color-primary-dark)]'
: 'text-[var(--color-text)] hover:text-[var(--color-primary)] hover:border-[var(--color-primary)]'
}
`}
>
{item.label}
</Link>
)
})}
</nav>
{/* Mobile Menu Button */}
<button
className="md:hidden p-2 border-2 border-[var(--color-primary)] text-[var(--color-primary)]"
onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-label={isMenuOpen ? 'Закрыть меню' : 'Открыть меню'}
>
<div className="w-6 h-5 flex flex-col justify-between">
<span
className={`block h-0.5 bg-current transition-transform duration-300 ${
isMenuOpen ? 'rotate-45 translate-y-2' : ''
}`}
/>
<span
className={`block h-0.5 bg-current transition-opacity duration-300 ${
isMenuOpen ? 'opacity-0' : ''
}`}
/>
<span
className={`block h-0.5 bg-current transition-transform duration-300 ${
isMenuOpen ? '-rotate-45 -translate-y-2' : ''
}`}
/>
</div>
</button>
</div>
</Container>
{/* Mobile Navigation */}
<div
className={`
md:hidden
absolute top-full left-0 right-0
bg-[var(--color-background)] border-b-4 border-[var(--color-border)]
transition-all duration-300
${isMenuOpen ? 'opacity-100 visible' : 'opacity-0 invisible'}
`}
>
<Container size="xl" padding="md">
<nav className="py-4 space-y-2">
{mainNavigation.map((item) => {
const isActive = pathname === item.href
return (
<Link
key={item.href}
href={item.href}
onClick={() => setIsMenuOpen(false)}
className={`
block px-4 py-3 font-pixel text-[10px] uppercase tracking-wider
border-2 transition-colors
${
isActive
? 'text-[var(--color-background)] bg-[var(--color-primary)] border-[var(--color-primary)]'
: 'text-[var(--color-text)] border-[var(--color-border)] hover:border-[var(--color-primary)]'
}
`}
>
{item.label}
</Link>
)
})}
</nav>
</Container>
</div>
</header>
)
}

View File

@ -0,0 +1 @@
export { HeroSection } from './ui/HeroSection'

View File

@ -0,0 +1,126 @@
'use client'
import { GlitchText, Button, Container, PixelBorder } from '@/shared/ui'
import Link from 'next/link'
interface HeroSectionProps {
title?: string
subtitle?: string
showCTA?: boolean
}
export function HeroSection({
title = 'СО ИМИТ',
subtitle = 'Совет обучающихся Института математики и информационных технологий',
showCTA = true,
}: HeroSectionProps) {
return (
<section className="relative min-h-[60vh] flex items-center py-20 overflow-hidden">
{/* Background grid */}
<div className="absolute inset-0 pixel-grid opacity-20" />
{/* Animated background pixels - predefined positions for SSR */}
<div className="absolute inset-0 overflow-hidden">
{[
{ left: 10, top: 15, dur: 3, del: 0.2 },
{ left: 25, top: 40, dur: 4, del: 0.5 },
{ left: 45, top: 20, dur: 2.5, del: 1 },
{ left: 65, top: 70, dur: 3.5, del: 0.8 },
{ left: 80, top: 30, dur: 4.5, del: 1.5 },
{ left: 15, top: 80, dur: 3, del: 0.3 },
{ left: 55, top: 55, dur: 2, del: 1.2 },
{ left: 90, top: 45, dur: 3.8, del: 0.7 },
{ left: 35, top: 85, dur: 4.2, del: 1.8 },
{ left: 75, top: 10, dur: 2.8, del: 0.4 },
].map((pixel, i) => (
<div
key={i}
className="absolute w-2 h-2 bg-[var(--color-primary)] opacity-20"
style={{
left: `${pixel.left}%`,
top: `${pixel.top}%`,
animation: `pixel-fade-in ${pixel.dur}s ease-in-out infinite`,
animationDelay: `${pixel.del}s`,
}}
/>
))}
</div>
<Container size="lg" className="relative z-10">
<div className="text-center">
{/* Decorative line */}
<div className="flex items-center justify-center gap-4 mb-8">
<span className="w-16 h-1 bg-[var(--color-primary)]" />
<span className="w-2 h-2 bg-[var(--color-primary)] rotate-45" />
<span className="w-16 h-1 bg-[var(--color-primary)]" />
</div>
{/* Main title with glitch */}
<h1 className="mb-6">
<GlitchText
as="span"
intensity="medium"
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl"
>
{title}
</GlitchText>
</h1>
{/* Subtitle */}
<PixelBorder variant="corner-only" color="primary" className="inline-block p-6 mb-8">
<p className="text-[var(--color-text)] text-sm sm:text-base md:text-lg max-w-2xl mx-auto">
{subtitle}
</p>
</PixelBorder>
{/* CTA Buttons */}
{showCTA && (
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link href="/posts">
<Button variant="primary" size="lg">
Читать новости
</Button>
</Link>
<Link href="/events">
<Button variant="secondary" size="lg">
События
</Button>
</Link>
</div>
)}
{/* Scroll indicator */}
<div className="mt-16 flex flex-col items-center animate-bounce">
<span className="w-4 h-4 border-r-2 border-b-2 border-[var(--color-primary)] rotate-45" />
</div>
</div>
</Container>
{/* Side decorations */}
<div className="absolute left-4 top-1/2 -translate-y-1/2 hidden lg:flex flex-col gap-2">
{[...Array(5)].map((_, i) => (
<span
key={i}
className="w-1 bg-[var(--color-primary)]"
style={{
height: `${20 + i * 10}px`,
opacity: 1 - i * 0.15,
}}
/>
))}
</div>
<div className="absolute right-4 top-1/2 -translate-y-1/2 hidden lg:flex flex-col gap-2 items-end">
{[...Array(5)].map((_, i) => (
<span
key={i}
className="w-1 bg-[var(--color-primary)]"
style={{
height: `${20 + i * 10}px`,
opacity: 1 - i * 0.15,
}}
/>
))}
</div>
</section>
)
}

View File

@ -0,0 +1 @@
export { PostsGrid } from './ui/PostsGrid'

View File

@ -0,0 +1,73 @@
'use client'
import { PostCard } from '@/entities/post'
import type { Post } from '@/entities/post'
import { GlitchText, Container, Button } from '@/shared/ui'
import Link from 'next/link'
interface PostsGridProps {
posts: Post[]
title?: string
showViewAll?: boolean
columns?: 2 | 3 | 4
}
export function PostsGrid({
posts,
title,
showViewAll = false,
columns = 3,
}: PostsGridProps) {
const gridCols = {
2: 'md:grid-cols-2',
3: 'md:grid-cols-2 lg:grid-cols-3',
4: 'md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
}
if (posts.length === 0) {
return (
<section className="py-12">
<Container>
<div className="text-center py-16 border-4 border-dashed border-[var(--color-border)]">
<p className="font-pixel text-[var(--color-text-muted)] text-sm">
Постов пока нет
</p>
</div>
</Container>
</section>
)
}
return (
<section className="py-12">
{/* Header */}
{(title || showViewAll) && (
<div className="flex items-center justify-between mb-8">
{title && (
<GlitchText
as="h2"
intensity="subtle"
className="text-xl sm:text-2xl"
>
{title}
</GlitchText>
)}
{showViewAll && (
<Link href="/posts">
<Button variant="ghost" size="sm">
Все посты
</Button>
</Link>
)}
</div>
)}
{/* Grid */}
<div className={`grid grid-cols-1 ${gridCols[columns]} gap-6`}>
{posts.map((post, index) => (
<PostCard key={post._id} post={post} priority={index < 3} />
))}
</div>
</section>
)
}