feat: Init commit
This commit is contained in:
parent
bfa578e9a6
commit
61ef2dee66
51
.dockerignore
Normal file
51
.dockerignore
Normal 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
10
.env.example
Normal 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
7
.gitignore
vendored
@ -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
686
CLAUDE.md
Normal 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
36
Dockerfile
Normal 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
20
docker-compose.yml
Normal 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
|
||||
@ -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
|
||||
|
||||
12
package.json
12
package.json
@ -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
13431
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
8
sanity.cli.ts
Normal file
8
sanity.cli.ts
Normal 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
61
sanity.config.ts
Normal 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
|
||||
},
|
||||
},
|
||||
})
|
||||
63
sanity/schemas/documents/author.ts
Normal file
63
sanity/schemas/documents/author.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
55
sanity/schemas/documents/category.ts
Normal file
55
sanity/schemas/documents/category.ts
Normal 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) + '...' : '',
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
134
sanity/schemas/documents/event.ts
Normal file
134
sanity/schemas/documents/event.ts
Normal 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' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
139
sanity/schemas/documents/post.ts
Normal file
139
sanity/schemas/documents/post.ts
Normal 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
17
sanity/schemas/index.ts
Normal 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,
|
||||
]
|
||||
93
sanity/schemas/objects/blockContent.ts
Normal file
93
sanity/schemas/objects/blockContent.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
114
src/app/(site)/about/page.tsx
Normal file
114
src/app/(site)/about/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
src/app/(site)/categories/[slug]/page.tsx
Normal file
97
src/app/(site)/categories/[slug]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
125
src/app/(site)/contacts/page.tsx
Normal file
125
src/app/(site)/contacts/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
191
src/app/(site)/events/[slug]/page.tsx
Normal file
191
src/app/(site)/events/[slug]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
src/app/(site)/events/page.tsx
Normal file
71
src/app/(site)/events/page.tsx
Normal 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
18
src/app/(site)/layout.tsx
Normal 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
53
src/app/(site)/page.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
172
src/app/(site)/posts/[slug]/page.tsx
Normal file
172
src/app/(site)/posts/[slug]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
src/app/(site)/posts/page.tsx
Normal file
95
src/app/(site)/posts/page.tsx
Normal 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> по запросу “{params.q}”</span>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Posts grid */}
|
||||
<PostsGrid posts={posts} columns={3} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
64
src/app/not-found.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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
15
src/app/robots.ts
Normal 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
80
src/app/sitemap.ts
Normal 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]
|
||||
}
|
||||
22
src/app/studio/[[...tool]]/layout.tsx
Normal file
22
src/app/studio/[[...tool]]/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
8
src/app/studio/[[...tool]]/page.tsx
Normal file
8
src/app/studio/[[...tool]]/page.tsx
Normal 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} />
|
||||
}
|
||||
2
src/entities/author/index.ts
Normal file
2
src/entities/author/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { AuthorCard } from './ui/AuthorCard'
|
||||
export type { Author } from './model/types'
|
||||
15
src/entities/author/model/types.ts
Normal file
15
src/entities/author/model/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
126
src/entities/author/ui/AuthorCard.tsx
Normal file
126
src/entities/author/ui/AuthorCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2
src/entities/category/index.ts
Normal file
2
src/entities/category/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { CategoryBadge } from './ui/CategoryBadge'
|
||||
export type { Category } from './model/types'
|
||||
7
src/entities/category/model/types.ts
Normal file
7
src/entities/category/model/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface Category {
|
||||
_id: string
|
||||
title: string
|
||||
slug: { current: string }
|
||||
description?: string
|
||||
color?: string
|
||||
}
|
||||
53
src/entities/category/ui/CategoryBadge.tsx
Normal file
53
src/entities/category/ui/CategoryBadge.tsx
Normal 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
|
||||
}
|
||||
3
src/entities/event/index.ts
Normal file
3
src/entities/event/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { EventCard } from './ui/EventCard'
|
||||
export type { Event, EventType } from './model/types'
|
||||
export { eventTypeLabels, eventTypeColors } from './model/types'
|
||||
36
src/entities/event/model/types.ts
Normal file
36
src/entities/event/model/types.ts
Normal 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',
|
||||
}
|
||||
214
src/entities/event/ui/EventCard.tsx
Normal file
214
src/entities/event/ui/EventCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
src/entities/post/index.ts
Normal file
3
src/entities/post/index.ts
Normal 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'
|
||||
49
src/entities/post/model/types.ts
Normal file
49
src/entities/post/model/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
108
src/entities/post/ui/PostCard.tsx
Normal file
108
src/entities/post/ui/PostCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
src/entities/post/ui/PostContent.tsx
Normal file
148
src/entities/post/ui/PostContent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/features/category-filter/index.ts
Normal file
1
src/features/category-filter/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { CategoryFilter } from './ui/CategoryFilter'
|
||||
79
src/features/category-filter/ui/CategoryFilter.tsx
Normal file
79
src/features/category-filter/ui/CategoryFilter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2
src/features/search-posts/index.ts
Normal file
2
src/features/search-posts/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { SearchPosts } from './ui/SearchPosts'
|
||||
export { useSearch } from './model/useSearch'
|
||||
43
src/features/search-posts/model/useSearch.ts
Normal file
43
src/features/search-posts/model/useSearch.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
92
src/features/search-posts/ui/SearchPosts.tsx
Normal file
92
src/features/search-posts/ui/SearchPosts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/features/share-post/index.ts
Normal file
1
src/features/share-post/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { SharePost } from './ui/SharePost'
|
||||
100
src/features/share-post/ui/SharePost.tsx
Normal file
100
src/features/share-post/ui/SharePost.tsx
Normal 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
20
src/sanity/env.ts
Normal 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
10
src/sanity/lib/client.ts
Normal 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
11
src/sanity/lib/image.ts
Normal 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
9
src/sanity/lib/live.ts
Normal 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,
|
||||
});
|
||||
46
src/sanity/schemaTypes/authorType.ts
Normal file
46
src/sanity/schemaTypes/authorType.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
76
src/sanity/schemaTypes/blockContentType.ts
Normal file
76
src/sanity/schemaTypes/blockContentType.ts
Normal 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',
|
||||
}
|
||||
]
|
||||
}),
|
||||
],
|
||||
})
|
||||
26
src/sanity/schemaTypes/categoryType.ts
Normal file
26
src/sanity/schemaTypes/categoryType.ts
Normal 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',
|
||||
}),
|
||||
],
|
||||
})
|
||||
10
src/sanity/schemaTypes/index.ts
Normal file
10
src/sanity/schemaTypes/index.ts
Normal 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],
|
||||
}
|
||||
65
src/sanity/schemaTypes/postType.ts
Normal file
65
src/sanity/schemaTypes/postType.ts
Normal 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
15
src/sanity/structure.ts
Normal 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()!),
|
||||
),
|
||||
])
|
||||
21
src/shared/config/navigation.ts
Normal file
21
src/shared/config/navigation.ts
Normal 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
29
src/shared/config/site.ts
Normal 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
|
||||
12
src/shared/lib/sanity/client.ts
Normal file
12
src/shared/lib/sanity/client.ts
Normal 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',
|
||||
})
|
||||
15
src/shared/lib/sanity/image.ts
Normal file
15
src/shared/lib/sanity/image.ts
Normal 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')
|
||||
}
|
||||
3
src/shared/lib/sanity/index.ts
Normal file
3
src/shared/lib/sanity/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { client, projectId, dataset, apiVersion } from './client'
|
||||
export { urlFor, urlForImage } from './image'
|
||||
export * from './queries'
|
||||
166
src/shared/lib/sanity/queries.ts
Normal file
166
src/shared/lib/sanity/queries.ts
Normal 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}
|
||||
}
|
||||
`)
|
||||
138
src/shared/styles/animations.css
Normal file
138
src/shared/styles/animations.css
Normal 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;
|
||||
}
|
||||
53
src/shared/styles/variables.css
Normal file
53
src/shared/styles/variables.css
Normal 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;
|
||||
}
|
||||
113
src/shared/ui/button/Button.tsx
Normal file
113
src/shared/ui/button/Button.tsx
Normal 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'
|
||||
1
src/shared/ui/button/index.ts
Normal file
1
src/shared/ui/button/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Button, type ButtonProps } from './Button'
|
||||
142
src/shared/ui/card/Card.tsx
Normal file
142
src/shared/ui/card/Card.tsx
Normal 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'
|
||||
10
src/shared/ui/card/index.ts
Normal file
10
src/shared/ui/card/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
type CardProps,
|
||||
type CardHeaderProps,
|
||||
type CardTitleProps,
|
||||
type CardDescriptionProps,
|
||||
} from './Card'
|
||||
55
src/shared/ui/container/Container.tsx
Normal file
55
src/shared/ui/container/Container.tsx
Normal 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'
|
||||
1
src/shared/ui/container/index.ts
Normal file
1
src/shared/ui/container/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Container, type ContainerProps } from './Container'
|
||||
157
src/shared/ui/glitch-text/GlitchText.tsx
Normal file
157
src/shared/ui/glitch-text/GlitchText.tsx
Normal 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'
|
||||
1
src/shared/ui/glitch-text/index.ts
Normal file
1
src/shared/ui/glitch-text/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GlitchText, type GlitchTextProps } from './GlitchText'
|
||||
26
src/shared/ui/index.ts
Normal file
26
src/shared/ui/index.ts
Normal 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'
|
||||
196
src/shared/ui/pixel-border/PixelBorder.tsx
Normal file
196
src/shared/ui/pixel-border/PixelBorder.tsx
Normal 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'
|
||||
1
src/shared/ui/pixel-border/index.ts
Normal file
1
src/shared/ui/pixel-border/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { PixelBorder, type PixelBorderProps } from './PixelBorder'
|
||||
135
src/shared/ui/pixel-logo/PixelLogo.tsx
Normal file
135
src/shared/ui/pixel-logo/PixelLogo.tsx
Normal 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'
|
||||
1
src/shared/ui/pixel-logo/index.ts
Normal file
1
src/shared/ui/pixel-logo/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { PixelLogo, type PixelLogoProps } from './PixelLogo'
|
||||
1
src/widgets/events-timeline/index.ts
Normal file
1
src/widgets/events-timeline/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { EventsTimeline } from './ui/EventsTimeline'
|
||||
90
src/widgets/events-timeline/ui/EventsTimeline.tsx
Normal file
90
src/widgets/events-timeline/ui/EventsTimeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/widgets/footer/index.ts
Normal file
1
src/widgets/footer/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Footer } from './ui/Footer'
|
||||
109
src/widgets/footer/ui/Footer.tsx
Normal file
109
src/widgets/footer/ui/Footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/widgets/header/index.ts
Normal file
1
src/widgets/header/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Header } from './ui/Header'
|
||||
120
src/widgets/header/ui/Header.tsx
Normal file
120
src/widgets/header/ui/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/widgets/hero-section/index.ts
Normal file
1
src/widgets/hero-section/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { HeroSection } from './ui/HeroSection'
|
||||
126
src/widgets/hero-section/ui/HeroSection.tsx
Normal file
126
src/widgets/hero-section/ui/HeroSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/widgets/posts-grid/index.ts
Normal file
1
src/widgets/posts-grid/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { PostsGrid } from './ui/PostsGrid'
|
||||
73
src/widgets/posts-grid/ui/PostsGrid.tsx
Normal file
73
src/widgets/posts-grid/ui/PostsGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user