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*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files
|
||||||
.env*
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.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 = {
|
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"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "16.1.6",
|
||||||
|
"next-sanity": "^11.6.12",
|
||||||
"react": "19.2.3",
|
"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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@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";
|
@import "tailwindcss";
|
||||||
|
@import "../shared/styles/variables.css";
|
||||||
:root {
|
@import "../shared/styles/animations.css";
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
/* Colors from design system */
|
||||||
--color-foreground: var(--foreground);
|
--color-background: var(--color-background);
|
||||||
--font-sans: var(--font-geist-sans);
|
--color-foreground: var(--color-text);
|
||||||
--font-mono: var(--font-geist-mono);
|
--color-surface: var(--color-surface);
|
||||||
}
|
--color-surface-elevated: var(--color-surface-elevated);
|
||||||
|
--color-primary: var(--color-primary);
|
||||||
@media (prefers-color-scheme: dark) {
|
--color-primary-dark: var(--color-primary-dark);
|
||||||
:root {
|
--color-primary-light: var(--color-primary-light);
|
||||||
--background: #0a0a0a;
|
--color-muted: var(--color-text-muted);
|
||||||
--foreground: #ededed;
|
--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 {
|
body {
|
||||||
background: var(--background);
|
background: var(--color-background);
|
||||||
color: var(--foreground);
|
color: var(--color-text);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
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 type { Metadata } from 'next'
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Press_Start_2P } from 'next/font/google'
|
||||||
import "./globals.css";
|
import './globals.css'
|
||||||
|
import { siteConfig } from '@/shared/config/site'
|
||||||
|
|
||||||
const geistSans = Geist({
|
const pressStart2P = Press_Start_2P({
|
||||||
variable: "--font-geist-sans",
|
weight: '400',
|
||||||
subsets: ["latin"],
|
subsets: ['latin'],
|
||||||
});
|
variable: '--font-pixel',
|
||||||
|
display: 'swap',
|
||||||
const geistMono = Geist_Mono({
|
})
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: {
|
||||||
description: "Generated by create next app",
|
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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="ru" className={pressStart2P.variable}>
|
||||||
<body
|
<body className="min-h-screen flex flex-col">
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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